mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-09-20 06:54:20 +00:00
Merge branch 'main' into callback-endpoint
This commit is contained in:
@@ -192,6 +192,13 @@ func getAggregatedAPIServerConfig(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// temporarily disable max inflight checks for mutating requests until we
|
||||
// pick up a fix for https://github.com/kubernetes/kubernetes/issues/95300
|
||||
// we do not need to set MaxRequestsInFlight to 0 because we are constantly
|
||||
// hammered by the kubelet for /healthz and the api server for discovery
|
||||
// which keeps the non-mutating request watermark histograms up to date
|
||||
serverConfig.Config.MaxMutatingRequestsInFlight = 0
|
||||
|
||||
apiServerConfig := &apiserver.Config{
|
||||
GenericConfig: serverConfig,
|
||||
ExtraConfig: apiserver.ExtraConfig{
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package crud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
|
||||
"go.pinniped.dev/internal/constable"
|
||||
)
|
||||
|
||||
//nolint:gosec // ignore lint warnings that these are credentials
|
||||
const (
|
||||
secretNameFormat = "pinniped-storage-%s-%s"
|
||||
secretLabelKey = "storage.pinniped.dev"
|
||||
secretTypeFormat = "storage.pinniped.dev/%s"
|
||||
secretVersion = "1"
|
||||
secretDataKey = "pinniped-storage-data"
|
||||
secretVersionKey = "pinniped-storage-version"
|
||||
|
||||
ErrSecretTypeMismatch = constable.Error("secret storage data has incorrect type")
|
||||
ErrSecretLabelMismatch = constable.Error("secret storage data has incorrect label")
|
||||
ErrSecretVersionMismatch = constable.Error("secret storage data has incorrect version") // TODO do we need this?
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
Create(ctx context.Context, signature string, data JSON) (resourceVersion string, err error)
|
||||
Get(ctx context.Context, signature string, data JSON) (resourceVersion string, err error)
|
||||
Update(ctx context.Context, signature, resourceVersion string, data JSON) (newResourceVersion string, err error)
|
||||
Delete(ctx context.Context, signature string) error
|
||||
}
|
||||
|
||||
type JSON interface{} // document that we need valid JSON types
|
||||
|
||||
func New(resource string, secrets corev1client.SecretInterface) Storage {
|
||||
return &secretsStorage{
|
||||
resource: resource,
|
||||
secretType: corev1.SecretType(fmt.Sprintf(secretTypeFormat, resource)),
|
||||
secretVersion: []byte(secretVersion),
|
||||
secrets: secrets,
|
||||
}
|
||||
}
|
||||
|
||||
type secretsStorage struct {
|
||||
resource string
|
||||
secretType corev1.SecretType
|
||||
secretVersion []byte
|
||||
secrets corev1client.SecretInterface
|
||||
}
|
||||
|
||||
func (s *secretsStorage) Create(ctx context.Context, signature string, data JSON) (string, error) {
|
||||
secret, err := s.toSecret(signature, "", data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, err = s.secrets.Create(ctx, secret, metav1.CreateOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create %s for signature %s: %w", s.resource, signature, err)
|
||||
}
|
||||
return secret.ResourceVersion, nil
|
||||
}
|
||||
|
||||
func (s *secretsStorage) Get(ctx context.Context, signature string, data JSON) (string, error) {
|
||||
secret, err := s.secrets.Get(ctx, s.getName(signature), metav1.GetOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get %s for signature %s: %w", s.resource, signature, err)
|
||||
}
|
||||
if err := s.validateSecret(secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := json.Unmarshal(secret.Data[secretDataKey], data); err != nil {
|
||||
return "", fmt.Errorf("failed to decode %s for signature %s: %w", s.resource, signature, err)
|
||||
}
|
||||
return secret.ResourceVersion, nil
|
||||
}
|
||||
|
||||
func (s *secretsStorage) validateSecret(secret *corev1.Secret) error {
|
||||
if secret.Type != s.secretType {
|
||||
return fmt.Errorf("%w: %s must equal %s", ErrSecretTypeMismatch, secret.Type, s.secretType)
|
||||
}
|
||||
if labelResource := secret.Labels[secretLabelKey]; labelResource != s.resource {
|
||||
return fmt.Errorf("%w: %s must equal %s", ErrSecretLabelMismatch, labelResource, s.resource)
|
||||
}
|
||||
if !bytes.Equal(secret.Data[secretVersionKey], s.secretVersion) {
|
||||
return ErrSecretVersionMismatch // TODO should this be fatal or not?
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *secretsStorage) Update(ctx context.Context, signature, resourceVersion string, data JSON) (string, error) {
|
||||
secret, err := s.toSecret(signature, resourceVersion, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, err = s.secrets.Update(ctx, secret, metav1.UpdateOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to update %s for signature %s at resource version %s: %w", s.resource, signature, resourceVersion, err)
|
||||
}
|
||||
return secret.ResourceVersion, nil
|
||||
}
|
||||
|
||||
func (s *secretsStorage) Delete(ctx context.Context, signature string) error {
|
||||
if err := s.secrets.Delete(ctx, s.getName(signature), metav1.DeleteOptions{}); err != nil {
|
||||
return fmt.Errorf("failed to delete %s for signature %s: %w", s.resource, signature, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint: gochecknoglobals
|
||||
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||
|
||||
func (s *secretsStorage) getName(signature string) string {
|
||||
// try to decode base64 signatures to prevent double encoding of binary data
|
||||
signatureBytes := maybeBase64Decode(signature)
|
||||
// lower case base32 encoding insures that our secret name is valid per ValidateSecretName in k/k
|
||||
signatureAsValidName := strings.ToLower(b32.EncodeToString(signatureBytes))
|
||||
return fmt.Sprintf(secretNameFormat, s.resource, signatureAsValidName)
|
||||
}
|
||||
|
||||
func (s *secretsStorage) toSecret(signature, resourceVersion string, data JSON) (*corev1.Secret, error) {
|
||||
buf, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode secret data for %s: %w", s.getName(signature), err)
|
||||
}
|
||||
return &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: s.getName(signature),
|
||||
ResourceVersion: resourceVersion,
|
||||
Labels: map[string]string{
|
||||
secretLabelKey: s.resource, // make it easier to find this stuff via kubectl
|
||||
},
|
||||
OwnerReferences: nil, // TODO we should set this to make sure stuff gets clean up
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
secretDataKey: buf,
|
||||
secretVersionKey: s.secretVersion,
|
||||
},
|
||||
Type: s.secretType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func maybeBase64Decode(signature string) []byte {
|
||||
for _, encoding := range []*base64.Encoding{
|
||||
// ordered in most likely used by HMAC, JWT, etc signatures
|
||||
base64.RawURLEncoding,
|
||||
base64.URLEncoding,
|
||||
base64.RawStdEncoding,
|
||||
base64.StdEncoding,
|
||||
} {
|
||||
if signatureBytes, err := encoding.DecodeString(signature); err == nil {
|
||||
return signatureBytes
|
||||
}
|
||||
}
|
||||
return []byte(signature)
|
||||
}
|
||||
@@ -0,0 +1,635 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package crud
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/ory/fosite/compose"
|
||||
"github.com/stretchr/testify/require"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
"k8s.io/apimachinery/pkg/api/validation"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
coretesting "k8s.io/client-go/testing"
|
||||
)
|
||||
|
||||
func TestStorage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
secretsGVR := schema.GroupVersionResource{
|
||||
Group: "",
|
||||
Version: "v1",
|
||||
Resource: "secrets",
|
||||
}
|
||||
|
||||
type testJSON struct {
|
||||
Data string
|
||||
}
|
||||
|
||||
type mocker interface {
|
||||
AddReactor(verb, resource string, reaction coretesting.ReactionFunc)
|
||||
PrependReactor(verb, resource string, reaction coretesting.ReactionFunc)
|
||||
Tracker() coretesting.ObjectTracker
|
||||
}
|
||||
|
||||
hmac := compose.NewOAuth2HMACStrategy(&compose.Config{}, []byte("super-secret-32-byte-for-testing"), nil)
|
||||
// test data generation via:
|
||||
// code, signature, err := hmac.GenerateAuthorizeCode(ctx, nil)
|
||||
|
||||
validateSecretName := validation.NameIsDNSSubdomain // matches k/k
|
||||
|
||||
const (
|
||||
namespace = "test-ns"
|
||||
authorizationCode1 = "81qE408EKL-e99gcXo3UnXBz9W05yGm92_hBmvXeadM.R5h38Bmw7yOaWNy0ypB3feh9toM-3T2zlwMXQyeE9B0"
|
||||
authorizationCode2 = "p7aIiOLy-btBBlCro5RWm1QABANKCiC0JmDPhUtfOY4.XXJsYsMWhnSMJi9TXJcPO6SDVO2R_QXImwroxxnQPA8"
|
||||
authorizationCode3 = "skKp1RjGgIwZhT3vaB_k1F3cIj2yp7U8a7UD0xAaemU.5aUhdNmfWLW3yKX8Zfz5ztS5IiiWBgu36Gja-o2xl0I"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
mocks func(*testing.T, mocker)
|
||||
run func(*testing.T, Storage) error
|
||||
wantActions []coretesting.Action
|
||||
wantSecrets []corev1.Secret
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "get non-existent",
|
||||
resource: "authorization-codes",
|
||||
mocks: nil,
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
_, err := storage.Get(ctx, "not-exists", nil)
|
||||
return err
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-authorization-codes-t2fx46yyvs3a"),
|
||||
},
|
||||
wantSecrets: nil,
|
||||
wantErr: `failed to get authorization-codes for signature not-exists: secrets "pinniped-storage-authorization-codes-t2fx46yyvs3a" not found`,
|
||||
},
|
||||
{
|
||||
name: "delete non-existent",
|
||||
resource: "tokens",
|
||||
mocks: nil,
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
return storage.Delete(ctx, "not-a-token")
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-tokens-t2fx427lnci6s"),
|
||||
},
|
||||
wantSecrets: nil,
|
||||
wantErr: `failed to delete tokens for signature not-a-token: secrets "pinniped-storage-tokens-t2fx427lnci6s" not found`,
|
||||
},
|
||||
{
|
||||
name: "create and get",
|
||||
resource: "access-tokens",
|
||||
mocks: nil,
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
signature := hmac.AuthorizeCodeSignature(authorizationCode1)
|
||||
require.NotEmpty(t, signature)
|
||||
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
|
||||
|
||||
data := &testJSON{Data: "create-and-get"}
|
||||
rv1, err := storage.Create(ctx, signature, data)
|
||||
require.Empty(t, rv1) // fake client does not set this
|
||||
require.NoError(t, err)
|
||||
|
||||
out := &testJSON{}
|
||||
rv2, err := storage.Get(ctx, signature, out)
|
||||
require.Empty(t, rv2) // fake client does not set this
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, data, out)
|
||||
|
||||
return nil
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq",
|
||||
ResourceVersion: "",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "access-tokens",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"create-and-get"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/access-tokens",
|
||||
}),
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq"),
|
||||
},
|
||||
wantSecrets: []corev1.Secret{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-access-tokens-i6mhp4azwdxshgsy3s2mvedxpxuh3nudh3ot3m4xamlugj4e6qoq",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "access-tokens",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"create-and-get"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/access-tokens",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "get existing",
|
||||
resource: "pandas-are-best",
|
||||
mocks: func(t *testing.T, mock mocker) {
|
||||
err := mock.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-pandas-are-best-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "pandas-are-best",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"snorlax"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/pandas-are-best",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
},
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
signature := hmac.AuthorizeCodeSignature(authorizationCode2)
|
||||
require.NotEmpty(t, signature)
|
||||
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
|
||||
|
||||
data := &testJSON{Data: "snorlax"}
|
||||
out := &testJSON{}
|
||||
rv1, err := storage.Get(ctx, signature, out)
|
||||
require.Empty(t, rv1) // fake client does not set this
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, data, out)
|
||||
|
||||
return nil
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-pandas-are-best-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq"),
|
||||
},
|
||||
wantSecrets: []corev1.Secret{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-pandas-are-best-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "pandas-are-best",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"snorlax"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/pandas-are-best",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "update existing",
|
||||
resource: "stores",
|
||||
mocks: func(t *testing.T, mock mocker) {
|
||||
err := mock.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "35",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "stores",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"pants"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/stores",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
mock.PrependReactor("update", "secrets", func(action coretesting.Action) (handled bool, ret runtime.Object, err error) {
|
||||
secret := action.(coretesting.UpdateAction).GetObject().(*corev1.Secret)
|
||||
secret.ResourceVersion = "45"
|
||||
return false, nil, nil // we mutated the secret in place but we do not "handle" it
|
||||
})
|
||||
},
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
signature := hmac.AuthorizeCodeSignature(authorizationCode3)
|
||||
require.NotEmpty(t, signature)
|
||||
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
|
||||
|
||||
data := &testJSON{Data: "pants"}
|
||||
out := &testJSON{}
|
||||
rv1, err := storage.Get(ctx, signature, out)
|
||||
require.Equal(t, "35", rv1) // set in mock above
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, data, out)
|
||||
|
||||
newData := &testJSON{Data: "shirts"}
|
||||
rv2, err := storage.Update(ctx, signature, rv1, newData)
|
||||
require.Equal(t, "45", rv2) // mock sets to a higher value on update
|
||||
require.NoError(t, err)
|
||||
|
||||
newOut := &testJSON{}
|
||||
rv3, err := storage.Get(ctx, signature, newOut)
|
||||
require.Equal(t, "45", rv3) // we should see new rv now
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newData, newOut)
|
||||
|
||||
return nil
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"),
|
||||
coretesting.NewUpdateAction(secretsGVR, namespace, &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
ResourceVersion: "35", // update at initial RV
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "stores",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"shirts"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/stores",
|
||||
}),
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"),
|
||||
},
|
||||
wantSecrets: []corev1.Secret{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-stores-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "45", // final list at new RV
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "stores",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"shirts"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/stores",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "delete existing",
|
||||
resource: "seals",
|
||||
mocks: func(t *testing.T, mock mocker) {
|
||||
err := mock.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "seals",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"sad-seal"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/seals",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
},
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
signature := hmac.AuthorizeCodeSignature(authorizationCode2)
|
||||
require.NotEmpty(t, signature)
|
||||
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
|
||||
|
||||
return storage.Delete(ctx, signature)
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewDeleteAction(secretsGVR, namespace, "pinniped-storage-seals-lvzgyywdc2dhjdbgf5jvzfyphosigvhnsh6qlse3blumogoqhqhq"),
|
||||
},
|
||||
wantSecrets: nil,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "invalid exiting secret type",
|
||||
resource: "candies",
|
||||
mocks: func(t *testing.T, mock mocker) {
|
||||
err := mock.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "55",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "candies",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"twizzlers"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/candies-not",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
},
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
signature := hmac.AuthorizeCodeSignature(authorizationCode3)
|
||||
require.NotEmpty(t, signature)
|
||||
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
|
||||
|
||||
out := &testJSON{}
|
||||
rv1, err := storage.Get(ctx, signature, out)
|
||||
require.Empty(t, rv1)
|
||||
require.Empty(t, out.Data)
|
||||
require.True(t, errors.Is(err, ErrSecretTypeMismatch))
|
||||
require.EqualError(t, err, "secret storage data has incorrect type: storage.pinniped.dev/candies-not must equal storage.pinniped.dev/candies")
|
||||
|
||||
return nil
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"),
|
||||
},
|
||||
wantSecrets: []corev1.Secret{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "55",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "candies",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"twizzlers"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/candies-not",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "invalid exiting secret wrong label",
|
||||
resource: "candies",
|
||||
mocks: func(t *testing.T, mock mocker) {
|
||||
err := mock.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "55",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "candies-are-bad",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"twizzlers"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/candies",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
},
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
signature := hmac.AuthorizeCodeSignature(authorizationCode3)
|
||||
require.NotEmpty(t, signature)
|
||||
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
|
||||
|
||||
out := &testJSON{}
|
||||
rv1, err := storage.Get(ctx, signature, out)
|
||||
require.Empty(t, rv1)
|
||||
require.Empty(t, out.Data)
|
||||
require.True(t, errors.Is(err, ErrSecretLabelMismatch))
|
||||
require.EqualError(t, err, "secret storage data has incorrect label: candies-are-bad must equal candies")
|
||||
|
||||
return nil
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"),
|
||||
},
|
||||
wantSecrets: []corev1.Secret{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "55",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "candies-are-bad",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"twizzlers"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/candies",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "invalid exiting secret wrong version",
|
||||
resource: "candies",
|
||||
mocks: func(t *testing.T, mock mocker) {
|
||||
err := mock.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "55",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "candies",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"twizzlers"}`),
|
||||
"pinniped-storage-version": []byte("77"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/candies",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
},
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
signature := hmac.AuthorizeCodeSignature(authorizationCode3)
|
||||
require.NotEmpty(t, signature)
|
||||
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
|
||||
|
||||
out := &testJSON{}
|
||||
rv1, err := storage.Get(ctx, signature, out)
|
||||
require.Empty(t, rv1)
|
||||
require.Empty(t, out.Data)
|
||||
require.True(t, errors.Is(err, ErrSecretVersionMismatch))
|
||||
require.EqualError(t, err, "secret storage data has incorrect version")
|
||||
|
||||
return nil
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"),
|
||||
},
|
||||
wantSecrets: []corev1.Secret{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "55",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "candies",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"Data":"twizzlers"}`),
|
||||
"pinniped-storage-version": []byte("77"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/candies",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "invalid exiting secret not json",
|
||||
resource: "candies",
|
||||
mocks: func(t *testing.T, mock mocker) {
|
||||
err := mock.Tracker().Add(&corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "55",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "candies",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`}}bad data{{`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/candies",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
},
|
||||
run: func(t *testing.T, storage Storage) error {
|
||||
signature := hmac.AuthorizeCodeSignature(authorizationCode3)
|
||||
require.NotEmpty(t, signature)
|
||||
require.NotEmpty(t, validateSecretName(signature, false)) // signature is not valid secret name as-is
|
||||
|
||||
out := &testJSON{}
|
||||
rv1, err := storage.Get(ctx, signature, out)
|
||||
require.Empty(t, rv1)
|
||||
require.Empty(t, out.Data)
|
||||
require.EqualError(t, err, "failed to decode candies for signature 5aUhdNmfWLW3yKX8Zfz5ztS5IiiWBgu36Gja-o2xl0I: invalid character '}' looking for beginning of value")
|
||||
|
||||
return nil
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba"),
|
||||
},
|
||||
wantSecrets: []corev1.Secret{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-candies-4wssc5gzt5mlln6iux6gl7hzz3klsirisydaxn7indnpvdnrs5ba",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "55",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "candies",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`}}bad data{{`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/candies",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fake.NewSimpleClientset()
|
||||
if tt.mocks != nil {
|
||||
tt.mocks(t, client)
|
||||
}
|
||||
secrets := client.CoreV1().Secrets(namespace)
|
||||
storage := New(tt.resource, secrets)
|
||||
|
||||
err := tt.run(t, storage)
|
||||
|
||||
require.Equal(t, tt.wantErr, errString(err))
|
||||
require.Equal(t, tt.wantActions, client.Actions())
|
||||
checkSecretActionNames(t, client.Actions())
|
||||
actualSecrets, err := secrets.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantSecrets, actualSecrets.Items)
|
||||
checkSecretListNames(t, actualSecrets.Items)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkSecretActionNames(t *testing.T, actions []coretesting.Action) {
|
||||
t.Helper()
|
||||
|
||||
for _, action := range actions {
|
||||
name := getName(t, action)
|
||||
assertValidName(t, name)
|
||||
}
|
||||
}
|
||||
|
||||
func checkSecretListNames(t *testing.T, secrets []corev1.Secret) {
|
||||
t.Helper()
|
||||
|
||||
for _, secret := range secrets {
|
||||
assertValidName(t, secret.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func assertValidName(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
|
||||
validateSecretName := validation.NameIsDNSSubdomain // matches k/k
|
||||
|
||||
require.NotEmpty(t, name)
|
||||
require.Empty(t, validateSecretName(name, false))
|
||||
require.Empty(t, validateSecretName(name, true)) // I do not think we actually care about this case
|
||||
}
|
||||
|
||||
func getName(t *testing.T, action coretesting.Action) string {
|
||||
t.Helper()
|
||||
|
||||
if getter, ok := action.(interface {
|
||||
GetName() string
|
||||
}); ok {
|
||||
return getter.GetName()
|
||||
}
|
||||
|
||||
if getter, ok := action.(interface {
|
||||
GetObject() runtime.Object
|
||||
}); ok {
|
||||
accessor, err := meta.Accessor(getter.GetObject())
|
||||
require.NoError(t, err)
|
||||
return accessor.GetName()
|
||||
}
|
||||
|
||||
t.Fatalf("failed to get name for action: %#v", action)
|
||||
panic("unreachable")
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package authorizationcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/handler/oauth2"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"k8s.io/apimachinery/pkg/api/errors"
|
||||
corev1client "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||
|
||||
"go.pinniped.dev/internal/constable"
|
||||
"go.pinniped.dev/internal/crud"
|
||||
)
|
||||
|
||||
const (
|
||||
ErrInvalidAuthorizeRequestType = constable.Error("authorization request must be of type fosite.AuthorizeRequest")
|
||||
ErrInvalidAuthorizeRequestData = constable.Error("authorization request data must not be nil")
|
||||
ErrInvalidAuthorizeRequestVersion = constable.Error("authorization request data has wrong version")
|
||||
|
||||
authorizeCodeStorageVersion = "1"
|
||||
)
|
||||
|
||||
var _ oauth2.AuthorizeCodeStorage = &authorizeCodeStorage{}
|
||||
|
||||
type authorizeCodeStorage struct {
|
||||
storage crud.Storage
|
||||
}
|
||||
|
||||
type AuthorizeCodeSession struct {
|
||||
Active bool `json:"active"`
|
||||
Request *fosite.AuthorizeRequest `json:"request"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func New(secrets corev1client.SecretInterface) oauth2.AuthorizeCodeStorage {
|
||||
return &authorizeCodeStorage{storage: crud.New("authorization-codes", secrets)}
|
||||
}
|
||||
|
||||
func (a *authorizeCodeStorage) CreateAuthorizeCodeSession(ctx context.Context, signature string, requester fosite.Requester) error {
|
||||
// this conversion assumes that we do not wrap the default type in any way
|
||||
// i.e. we use the default fosite.OAuth2Provider.NewAuthorizeRequest implementation
|
||||
// note that because this type is serialized and stored in Kube, we cannot easily change the implementation later
|
||||
// TODO hydra uses the fosite.Request struct and ignores the extra fields in fosite.AuthorizeRequest
|
||||
request, err := validateAndExtractAuthorizeRequest(requester)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO hydra stores specific fields from the requester
|
||||
// request ID
|
||||
// requestedAt
|
||||
// OAuth client ID
|
||||
// requested scopes, granted scopes
|
||||
// requested audience, granted audience
|
||||
// url encoded request form
|
||||
// session as JSON bytes with (optional) encryption
|
||||
// session subject
|
||||
// consent challenge from session which is the identifier ("authorization challenge")
|
||||
// of the consent authorization request. It is used to identify the session.
|
||||
// signature for lookup in the DB
|
||||
|
||||
_, err = a.storage.Create(ctx, signature, &AuthorizeCodeSession{Active: true, Request: request, Version: authorizeCodeStorageVersion})
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *authorizeCodeStorage) GetAuthorizeCodeSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
|
||||
// TODO hydra uses the incoming fosite.Session to provide the type needed to json.Unmarshal their session bytes
|
||||
|
||||
// TODO hydra gets the client from its DB as a concrete type via client ID,
|
||||
// the hydra memory client just validates that the client ID exists
|
||||
|
||||
// TODO hydra uses the sha512.Sum384 hash of signature when using JWT as access token to reduce length
|
||||
|
||||
session, _, err := a.getSession(ctx, signature)
|
||||
|
||||
// we need to always pass both the request and error back
|
||||
if session == nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return session.Request, err
|
||||
}
|
||||
|
||||
func (a *authorizeCodeStorage) InvalidateAuthorizeCodeSession(ctx context.Context, signature string) error {
|
||||
// TODO write garbage collector for these codes
|
||||
|
||||
session, rv, err := a.getSession(ctx, signature)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
session.Active = false
|
||||
if _, err := a.storage.Update(ctx, signature, rv, session); err != nil {
|
||||
if errors.IsConflict(err) {
|
||||
return &errSerializationFailureWithCause{cause: err}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authorizeCodeStorage) getSession(ctx context.Context, signature string) (*AuthorizeCodeSession, string, error) {
|
||||
session := NewValidEmptyAuthorizeCodeSession()
|
||||
rv, err := a.storage.Get(ctx, signature, session)
|
||||
|
||||
if errors.IsNotFound(err) {
|
||||
return nil, "", fosite.ErrNotFound.WithCause(err).WithDebug(err.Error())
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to get authorization code session for %s: %w", signature, err)
|
||||
}
|
||||
|
||||
if version := session.Version; version != authorizeCodeStorageVersion {
|
||||
return nil, "", fmt.Errorf("%w: authorization code session for %s has version %s instead of %s",
|
||||
ErrInvalidAuthorizeRequestVersion, signature, version, authorizeCodeStorageVersion)
|
||||
}
|
||||
|
||||
if session.Request == nil {
|
||||
return nil, "", fmt.Errorf("malformed authorization code session for %s: %w", signature, ErrInvalidAuthorizeRequestData)
|
||||
}
|
||||
|
||||
// we must return the session in this case to allow fosite to revoke the associated tokens
|
||||
if !session.Active {
|
||||
return session, rv, fmt.Errorf("authorization code session for %s has already been used: %w", signature, fosite.ErrInvalidatedAuthorizeCode)
|
||||
}
|
||||
|
||||
return session, rv, nil
|
||||
}
|
||||
|
||||
func NewValidEmptyAuthorizeCodeSession() *AuthorizeCodeSession {
|
||||
return &AuthorizeCodeSession{
|
||||
Request: &fosite.AuthorizeRequest{
|
||||
Request: fosite.Request{
|
||||
Client: &fosite.DefaultOpenIDConnectClient{},
|
||||
Session: &openid.DefaultSession{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func validateAndExtractAuthorizeRequest(requester fosite.Requester) (*fosite.AuthorizeRequest, error) {
|
||||
request, ok1 := requester.(*fosite.AuthorizeRequest)
|
||||
if !ok1 {
|
||||
return nil, ErrInvalidAuthorizeRequestType
|
||||
}
|
||||
_, ok2 := request.Client.(*fosite.DefaultOpenIDConnectClient)
|
||||
_, ok3 := request.Session.(*openid.DefaultSession)
|
||||
|
||||
valid := ok2 && ok3
|
||||
if !valid {
|
||||
return nil, ErrInvalidAuthorizeRequestType
|
||||
}
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
var _ interface {
|
||||
Is(error) bool
|
||||
Unwrap() error
|
||||
error
|
||||
} = &errSerializationFailureWithCause{}
|
||||
|
||||
type errSerializationFailureWithCause struct {
|
||||
cause error
|
||||
}
|
||||
|
||||
func (e *errSerializationFailureWithCause) Is(err error) bool {
|
||||
return stderrors.Is(fosite.ErrSerializationFailure, err)
|
||||
}
|
||||
|
||||
func (e *errSerializationFailureWithCause) Unwrap() error {
|
||||
return e.cause
|
||||
}
|
||||
|
||||
func (e *errSerializationFailureWithCause) Error() string {
|
||||
return fmt.Sprintf("%s: %s", fosite.ErrSerializationFailure, e.cause)
|
||||
}
|
||||
|
||||
// ExpectedAuthorizeCodeSessionJSONFromFuzzing is used for round tripping tests.
|
||||
// It is exported to allow integration tests to use it.
|
||||
const ExpectedAuthorizeCodeSessionJSONFromFuzzing = `{
|
||||
"active": true,
|
||||
"request": {
|
||||
"responseTypes": [
|
||||
"¥Îʒ襧.ɕ7崛瀇莒AȒ[ɠ牐7#$ɭ",
|
||||
".5ȿELj9ûF済(D疻翋膗",
|
||||
"螤Yɫüeɯ紤邥翔勋\\RBʒ;-"
|
||||
],
|
||||
"redirectUri": {
|
||||
"Scheme": "ħesƻU赒M喦_ģ",
|
||||
"Opaque": "Ġ/_章Ņ缘T蝟NJ儱礹燃ɢ",
|
||||
"User": {},
|
||||
"Host": "ȳ4螘Wo",
|
||||
"Path": "}i{",
|
||||
"RawPath": "5Dža丝eF0eė鱊hǒx蔼Q",
|
||||
"ForceQuery": true,
|
||||
"RawQuery": "熤1bbWV",
|
||||
"Fragment": "ȋc剠鏯ɽÿ¸",
|
||||
"RawFragment": "qƤ"
|
||||
},
|
||||
"state": "@n,x竘Şǥ嗾稀'ã击漰怼禝穞梠Ǫs",
|
||||
"handledResponseTypes": [
|
||||
"m\"e尚鬞ƻɼ抹d誉y鿜Ķ"
|
||||
],
|
||||
"id": "ō澩ć|3U2Ǜl霨ǦǵpƉ",
|
||||
"requestedAt": "1989-11-05T22:02:31.105295894Z",
|
||||
"client": {
|
||||
"id": "[:c顎疻紵D",
|
||||
"client_secret": "mQ==",
|
||||
"redirect_uris": [
|
||||
"恣S@T嵇LJV,Æ櫔袆鋹奘菲",
|
||||
"ãƻʚ肈ą8O+a駣Ʉɼk瘸'鴵y"
|
||||
],
|
||||
"grant_types": [
|
||||
".湆ê\"唐",
|
||||
"曎餄FxD溪躲珫ÈşɜȨû臓嬣\"ǃŤz"
|
||||
],
|
||||
"response_types": [
|
||||
"Ņʘʟ車sʊ儓JǐŪɺǣy|耑ʄ"
|
||||
],
|
||||
"scopes": [
|
||||
"Ą",
|
||||
"萙Į(潶饏熞ĝƌĆ1",
|
||||
"əȤ4Į筦p煖鵄$睱奐耡q"
|
||||
],
|
||||
"audience": [
|
||||
"Ʃǣ鿫/Ò敫ƤV"
|
||||
],
|
||||
"public": true,
|
||||
"jwks_uri": "ȩđ[嬧鱒Ȁ彆媚杨嶒ĤG",
|
||||
"jwks": {
|
||||
"keys": [
|
||||
{
|
||||
"kty": "OKP",
|
||||
"crv": "Ed25519",
|
||||
"x": "JmA-6KpjzqKu0lq9OiB6ORL4s2UzBFPsE1hm6vESeXM",
|
||||
"x5u": {
|
||||
"Scheme": "",
|
||||
"Opaque": "",
|
||||
"User": null,
|
||||
"Host": "",
|
||||
"Path": "",
|
||||
"RawPath": "",
|
||||
"ForceQuery": false,
|
||||
"RawQuery": "",
|
||||
"Fragment": "",
|
||||
"RawFragment": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"kty": "OKP",
|
||||
"crv": "Ed25519",
|
||||
"x": "LbRC1_3HEe5o7Japk9jFp3_7Ou7Gi2gpqrVrIi0eLDQ",
|
||||
"x5u": {
|
||||
"Scheme": "",
|
||||
"Opaque": "",
|
||||
"User": null,
|
||||
"Host": "",
|
||||
"Path": "",
|
||||
"RawPath": "",
|
||||
"ForceQuery": false,
|
||||
"RawQuery": "",
|
||||
"Fragment": "",
|
||||
"RawFragment": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"kty": "OKP",
|
||||
"crv": "Ed25519",
|
||||
"x": "Ovk4DF8Yn3mkULuTqnlGJxFnKGu9EL6Xcf2Nql9lK3c",
|
||||
"x5u": {
|
||||
"Scheme": "",
|
||||
"Opaque": "",
|
||||
"User": null,
|
||||
"Host": "",
|
||||
"Path": "",
|
||||
"RawPath": "",
|
||||
"ForceQuery": false,
|
||||
"RawQuery": "",
|
||||
"Fragment": "",
|
||||
"RawFragment": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"token_endpoint_auth_method": "\u0026(K鵢Kj ŏ9Q韉Ķ%嶑輫ǘ(",
|
||||
"request_uris": [
|
||||
":",
|
||||
"6ě#嫀^xz Ū胧r"
|
||||
],
|
||||
"request_object_signing_alg": "^¡!犃ĹĐJí¿ō擫ų懫砰¿",
|
||||
"token_endpoint_auth_signing_alg": "ƈŮå"
|
||||
},
|
||||
"scopes": [
|
||||
"阃.Ù頀ʌGa皶竇瞍涘¹",
|
||||
"ȽŮ切衖庀ŰŒ矠",
|
||||
"楓)馻řĝǕ菸Tĕ1伞柲\u003c\"ʗȆ\\雤"
|
||||
],
|
||||
"grantedScopes": [
|
||||
"ơ鮫R嫁ɍUƞ9+u!Ȱ",
|
||||
"}Ă岜"
|
||||
],
|
||||
"form": {
|
||||
"旸Ť/Õ薝隧;綡,鼞纂=": [
|
||||
"[滮]憀",
|
||||
"3\u003eÙœ蓄UK嗤眇疟Țƒ1v¸KĶ"
|
||||
]
|
||||
},
|
||||
"session": {
|
||||
"Claims": {
|
||||
"JTI": "};Ų斻遟a衪荖舃",
|
||||
"Issuer": "芠顋敀拲h蝺$!",
|
||||
"Subject": "}j%(=ſ氆]垲莲顇",
|
||||
"Audience": [
|
||||
"彑V\\廳蟕Țǡ蔯ʠ浵Ī龉磈螖畭5",
|
||||
"渇Ȯʕc"
|
||||
],
|
||||
"Nonce": "Ǖ=rlƆ褡{ǏS",
|
||||
"ExpiresAt": "1975-11-17T14:21:34.205609651Z",
|
||||
"IssuedAt": "2104-07-03T15:40:03.66710966Z",
|
||||
"RequestedAt": "2031-05-18T05:14:19.449350555Z",
|
||||
"AuthTime": "2018-01-27T07:55:06.056862114Z",
|
||||
"AccessTokenHash": "鹰肁躧",
|
||||
"AuthenticationContextClassReference": "}Ɇ",
|
||||
"AuthenticationMethodsReference": "DQh:uȣ",
|
||||
"CodeHash": "ɘȏıȒ諃龟",
|
||||
"Extra": {
|
||||
"a": {
|
||||
"^i臏f恡ƨ彮": {
|
||||
"DĘ敨ýÏʥZq7烱藌\\": null,
|
||||
"V": {
|
||||
"őŧQĝ微'X焌襱ǭɕņ殥!_n": false
|
||||
}
|
||||
},
|
||||
"Ż猁": [
|
||||
1706822246
|
||||
]
|
||||
},
|
||||
"Ò椪)ɫqň2搞Ŀ高摠鲒鿮禗O": 1233332227
|
||||
}
|
||||
},
|
||||
"Headers": {
|
||||
"Extra": {
|
||||
"?戋璖$9\u0026": {
|
||||
"µcɕ餦ÑEǰ哤癨浦浏1R": [
|
||||
3761201123
|
||||
],
|
||||
"頓ć§蚲6rǦ\u003cqċ": {
|
||||
"Łʀ§ȏœɽDz斡冭ȸěaʜD捛?½ʀ+": null,
|
||||
"ɒúIJ誠ƉyÖ.峷1藍殙菥趏": {
|
||||
"jHȬȆ#)\u003cX": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"U": 1354158262
|
||||
}
|
||||
},
|
||||
"ExpiresAt": {
|
||||
"\"嘬ȹĹaó剺撱Ȱ": "1985-09-09T04:35:40.533197189Z",
|
||||
"ʆ\u003e": "1998-08-07T05:37:11.759718906Z",
|
||||
"柏ʒ鴙*鸆偡Ȓ肯Ûx": "2036-12-19T06:36:14.414805124Z"
|
||||
},
|
||||
"Username": "qmʎaðƠ绗ʢ緦Hū",
|
||||
"Subject": "屾Ê窢ɋ鄊qɠ谫ǯǵƕ牀1鞊\\ȹ)"
|
||||
},
|
||||
"requestedAudience": [
|
||||
"鉍商OɄƣ圔,xĪɏV鵅砍"
|
||||
],
|
||||
"grantedAudience": [
|
||||
"C笜嚯\u003cǐšɚĀĥʋ6鉅\\þc涎漄Ɨ腼"
|
||||
]
|
||||
},
|
||||
"version": "1"
|
||||
}`
|
||||
@@ -0,0 +1,335 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package authorizationcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
fuzz "github.com/google/gofuzz"
|
||||
"github.com/ory/fosite"
|
||||
"github.com/ory/fosite/handler/oauth2"
|
||||
"github.com/ory/fosite/handler/openid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/client-go/kubernetes/fake"
|
||||
coretesting "k8s.io/client-go/testing"
|
||||
)
|
||||
|
||||
func TestAuthorizeCodeStorage(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
secretsGVR := schema.GroupVersionResource{
|
||||
Group: "",
|
||||
Version: "v1",
|
||||
Resource: "secrets",
|
||||
}
|
||||
|
||||
const namespace = "test-ns"
|
||||
|
||||
type mocker interface {
|
||||
AddReactor(verb, resource string, reaction coretesting.ReactionFunc)
|
||||
PrependReactor(verb, resource string, reaction coretesting.ReactionFunc)
|
||||
Tracker() coretesting.ObjectTracker
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mocks func(*testing.T, mocker)
|
||||
run func(*testing.T, oauth2.AuthorizeCodeStorage) error
|
||||
wantActions []coretesting.Action
|
||||
wantSecrets []corev1.Secret
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "create, get, invalidate standard flow",
|
||||
mocks: nil,
|
||||
run: func(t *testing.T, storage oauth2.AuthorizeCodeStorage) error {
|
||||
request := &fosite.AuthorizeRequest{
|
||||
ResponseTypes: fosite.Arguments{"not-code"},
|
||||
RedirectURI: &url.URL{
|
||||
Scheme: "",
|
||||
Opaque: "weee",
|
||||
User: &url.Userinfo{},
|
||||
Host: "",
|
||||
Path: "/callback",
|
||||
RawPath: "",
|
||||
ForceQuery: false,
|
||||
RawQuery: "",
|
||||
Fragment: "",
|
||||
RawFragment: "",
|
||||
},
|
||||
State: "stated",
|
||||
HandledResponseTypes: fosite.Arguments{"not-type"},
|
||||
Request: fosite.Request{
|
||||
ID: "abcd-1",
|
||||
RequestedAt: time.Time{},
|
||||
Client: &fosite.DefaultOpenIDConnectClient{
|
||||
DefaultClient: &fosite.DefaultClient{
|
||||
ID: "pinny",
|
||||
Secret: nil,
|
||||
RedirectURIs: nil,
|
||||
GrantTypes: nil,
|
||||
ResponseTypes: nil,
|
||||
Scopes: nil,
|
||||
Audience: nil,
|
||||
Public: true,
|
||||
},
|
||||
JSONWebKeysURI: "where",
|
||||
JSONWebKeys: nil,
|
||||
TokenEndpointAuthMethod: "something",
|
||||
RequestURIs: nil,
|
||||
RequestObjectSigningAlgorithm: "",
|
||||
TokenEndpointAuthSigningAlgorithm: "",
|
||||
},
|
||||
RequestedScope: nil,
|
||||
GrantedScope: nil,
|
||||
Form: url.Values{"key": []string{"val"}},
|
||||
Session: &openid.DefaultSession{
|
||||
Claims: nil,
|
||||
Headers: nil,
|
||||
ExpiresAt: nil,
|
||||
Username: "snorlax",
|
||||
Subject: "panda",
|
||||
},
|
||||
RequestedAudience: nil,
|
||||
GrantedAudience: nil,
|
||||
},
|
||||
}
|
||||
err := storage.CreateAuthorizeCodeSession(ctx, "fancy-signature", request)
|
||||
require.NoError(t, err)
|
||||
|
||||
newRequest, err := storage.GetAuthorizeCodeSession(ctx, "fancy-signature", nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, request, newRequest)
|
||||
|
||||
return storage.InvalidateAuthorizeCodeSession(ctx, "fancy-signature")
|
||||
},
|
||||
wantActions: []coretesting.Action{
|
||||
coretesting.NewCreateAction(secretsGVR, namespace, &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-authorization-codes-pwu5zs7lekbhnln2w4",
|
||||
ResourceVersion: "",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "authorization-codes",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"active":true,"request":{"responseTypes":["not-code"],"redirectUri":{"Scheme":"","Opaque":"weee","User":{},"Host":"","Path":"/callback","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"state":"stated","handledResponseTypes":["not-type"],"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/authorization-codes",
|
||||
}),
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-authorization-codes-pwu5zs7lekbhnln2w4"),
|
||||
coretesting.NewGetAction(secretsGVR, namespace, "pinniped-storage-authorization-codes-pwu5zs7lekbhnln2w4"),
|
||||
coretesting.NewUpdateAction(secretsGVR, namespace, &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-authorization-codes-pwu5zs7lekbhnln2w4",
|
||||
ResourceVersion: "",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "authorization-codes",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"active":false,"request":{"responseTypes":["not-code"],"redirectUri":{"Scheme":"","Opaque":"weee","User":{},"Host":"","Path":"/callback","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"state":"stated","handledResponseTypes":["not-type"],"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/authorization-codes",
|
||||
}),
|
||||
},
|
||||
wantSecrets: []corev1.Secret{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: "pinniped-storage-authorization-codes-pwu5zs7lekbhnln2w4",
|
||||
Namespace: namespace,
|
||||
ResourceVersion: "",
|
||||
Labels: map[string]string{
|
||||
"storage.pinniped.dev": "authorization-codes",
|
||||
},
|
||||
},
|
||||
Data: map[string][]byte{
|
||||
"pinniped-storage-data": []byte(`{"active":false,"request":{"responseTypes":["not-code"],"redirectUri":{"Scheme":"","Opaque":"weee","User":{},"Host":"","Path":"/callback","RawPath":"","ForceQuery":false,"RawQuery":"","Fragment":"","RawFragment":""},"state":"stated","handledResponseTypes":["not-type"],"id":"abcd-1","requestedAt":"0001-01-01T00:00:00Z","client":{"id":"pinny","redirect_uris":null,"grant_types":null,"response_types":null,"scopes":null,"audience":null,"public":true,"jwks_uri":"where","jwks":null,"token_endpoint_auth_method":"something","request_uris":null,"request_object_signing_alg":"","token_endpoint_auth_signing_alg":""},"scopes":null,"grantedScopes":null,"form":{"key":["val"]},"session":{"Claims":null,"Headers":null,"ExpiresAt":null,"Username":"snorlax","Subject":"panda"},"requestedAudience":null,"grantedAudience":null},"version":"1"}`),
|
||||
"pinniped-storage-version": []byte("1"),
|
||||
},
|
||||
Type: "storage.pinniped.dev/authorization-codes",
|
||||
},
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := fake.NewSimpleClientset()
|
||||
if tt.mocks != nil {
|
||||
tt.mocks(t, client)
|
||||
}
|
||||
secrets := client.CoreV1().Secrets(namespace)
|
||||
storage := New(secrets)
|
||||
|
||||
err := tt.run(t, storage)
|
||||
|
||||
require.Equal(t, tt.wantErr, errString(err))
|
||||
require.Equal(t, tt.wantActions, client.Actions())
|
||||
|
||||
actualSecrets, err := secrets.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantSecrets, actualSecrets.Items)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func errString(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession asserts that we can correctly round trip our authorize code session.
|
||||
// It will detect any changes to fosite.AuthorizeRequest and guarantees that all interface types have concrete implementations.
|
||||
func TestFuzzAndJSONNewValidEmptyAuthorizeCodeSession(t *testing.T) {
|
||||
validSession := NewValidEmptyAuthorizeCodeSession()
|
||||
|
||||
// sanity check our valid session
|
||||
extractedRequest, err := validateAndExtractAuthorizeRequest(validSession.Request)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, validSession.Request, extractedRequest)
|
||||
|
||||
// checked above
|
||||
defaultClient := validSession.Request.Request.Client.(*fosite.DefaultOpenIDConnectClient)
|
||||
defaultSession := validSession.Request.Request.Session.(*openid.DefaultSession)
|
||||
|
||||
// makes it easier to use a raw string
|
||||
replacer := strings.NewReplacer("`", "a")
|
||||
randString := func(c fuzz.Continue) string {
|
||||
for {
|
||||
s := c.RandString()
|
||||
if len(s) == 0 {
|
||||
continue // skip empty string
|
||||
}
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
}
|
||||
|
||||
// deterministic fuzzing of fosite.AuthorizeRequest
|
||||
f := fuzz.New().RandSource(rand.NewSource(1)).NilChance(0).NumElements(1, 3).Funcs(
|
||||
// these functions guarantee that these are the only interface types we need to fill out
|
||||
// if fosite.AuthorizeRequest changes to add more, the fuzzer will panic
|
||||
func(fc *fosite.Client, c fuzz.Continue) {
|
||||
c.Fuzz(defaultClient)
|
||||
*fc = defaultClient
|
||||
},
|
||||
func(fs *fosite.Session, c fuzz.Continue) {
|
||||
c.Fuzz(defaultSession)
|
||||
*fs = defaultSession
|
||||
},
|
||||
|
||||
// these types contain an interface{} that we need to handle
|
||||
// this is safe because we explicitly provide the openid.DefaultSession concrete type
|
||||
func(value *map[string]interface{}, c fuzz.Continue) {
|
||||
// cover all the JSON data types just in case
|
||||
*value = map[string]interface{}{
|
||||
randString(c): float64(c.Intn(1 << 32)),
|
||||
randString(c): map[string]interface{}{
|
||||
randString(c): []interface{}{float64(c.Intn(1 << 32))},
|
||||
randString(c): map[string]interface{}{
|
||||
randString(c): nil,
|
||||
randString(c): map[string]interface{}{
|
||||
randString(c): c.RandBool(),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
// JWK contains an interface{} Key that we need to handle
|
||||
// this is safe because JWK explicitly implements JSON marshalling and unmarshalling
|
||||
func(jwk *jose.JSONWebKey, c fuzz.Continue) {
|
||||
key, _, err := ed25519.GenerateKey(c)
|
||||
require.NoError(t, err)
|
||||
jwk.Key = key
|
||||
|
||||
// set these fields to make the .Equal comparison work
|
||||
jwk.Certificates = []*x509.Certificate{}
|
||||
jwk.CertificatesURL = &url.URL{}
|
||||
jwk.CertificateThumbprintSHA1 = []byte{}
|
||||
jwk.CertificateThumbprintSHA256 = []byte{}
|
||||
},
|
||||
|
||||
// set this to make the .Equal comparison work
|
||||
// this is safe because Time explicitly implements JSON marshalling and unmarshalling
|
||||
func(tp *time.Time, c fuzz.Continue) {
|
||||
*tp = time.Unix(c.Int63n(1<<32), c.Int63n(1<<32)).UTC()
|
||||
},
|
||||
|
||||
// make random strings that do not contain any ` characters
|
||||
func(s *string, c fuzz.Continue) {
|
||||
*s = randString(c)
|
||||
},
|
||||
// handle string type alias
|
||||
func(s *fosite.TokenType, c fuzz.Continue) {
|
||||
*s = fosite.TokenType(randString(c))
|
||||
},
|
||||
// handle string type alias
|
||||
func(s *fosite.Arguments, c fuzz.Continue) {
|
||||
n := c.Intn(3) + 1 // 1 to 3 items
|
||||
arguments := make(fosite.Arguments, n)
|
||||
for i := range arguments {
|
||||
arguments[i] = randString(c)
|
||||
}
|
||||
*s = arguments
|
||||
},
|
||||
)
|
||||
|
||||
f.Fuzz(validSession)
|
||||
|
||||
const name = "fuzz" // value is irrelevant
|
||||
ctx := context.Background()
|
||||
secrets := fake.NewSimpleClientset().CoreV1().Secrets(name)
|
||||
storage := New(secrets)
|
||||
|
||||
// issue a create using the fuzzed request to confirm that marshalling works
|
||||
err = storage.CreateAuthorizeCodeSession(ctx, name, validSession.Request)
|
||||
require.NoError(t, err)
|
||||
|
||||
// retrieve a copy of the fuzzed request from storage to confirm that unmarshalling works
|
||||
newRequest, err := storage.GetAuthorizeCodeSession(ctx, name, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// the fuzzed request and the copy from storage should be exactly the same
|
||||
require.Equal(t, validSession.Request, newRequest)
|
||||
|
||||
secretList, err := secrets.List(ctx, metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, secretList.Items, 1)
|
||||
authorizeCodeSessionJSONFromStorage := string(secretList.Items[0].Data["pinniped-storage-data"])
|
||||
|
||||
// set these to match CreateAuthorizeCodeSession so that .JSONEq works
|
||||
validSession.Active = true
|
||||
validSession.Version = "1"
|
||||
|
||||
validSessionJSONBytes, err := json.MarshalIndent(validSession, "", "\t")
|
||||
require.NoError(t, err)
|
||||
authorizeCodeSessionJSONFromFuzzing := string(validSessionJSONBytes)
|
||||
|
||||
// the fuzzed session and storage session should have identical JSON
|
||||
require.JSONEq(t, authorizeCodeSessionJSONFromFuzzing, authorizeCodeSessionJSONFromStorage)
|
||||
|
||||
// while the fuzzer will panic if AuthorizeRequest changes in a way that cannot be fuzzed,
|
||||
// if it adds a new field that can be fuzzed, this check will fail
|
||||
// thus if AuthorizeRequest changes, we will detect it here (though we could possibly miss an omitempty field)
|
||||
require.Equal(t, ExpectedAuthorizeCodeSessionJSONFromFuzzing, authorizeCodeSessionJSONFromFuzzing)
|
||||
}
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
"go.pinniped.dev/internal/oidc"
|
||||
"go.pinniped.dev/internal/oidc/csrftoken"
|
||||
"go.pinniped.dev/internal/oidc/provider"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/internal/plog"
|
||||
"go.pinniped.dev/pkg/oidcclient/nonce"
|
||||
"go.pinniped.dev/pkg/oidcclient/pkce"
|
||||
)
|
||||
|
||||
func NewHandler(
|
||||
|
||||
@@ -22,8 +22,8 @@ import (
|
||||
"go.pinniped.dev/internal/oidc/csrftoken"
|
||||
"go.pinniped.dev/internal/oidc/oidctestutil"
|
||||
"go.pinniped.dev/internal/oidc/provider"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/pkg/oidcclient/nonce"
|
||||
"go.pinniped.dev/pkg/oidcclient/pkce"
|
||||
)
|
||||
|
||||
func TestAuthorizationEndpoint(t *testing.T) {
|
||||
|
||||
@@ -22,10 +22,10 @@ import (
|
||||
|
||||
"go.pinniped.dev/internal/oidc"
|
||||
"go.pinniped.dev/internal/oidc/oidctestutil"
|
||||
"go.pinniped.dev/internal/oidcclient"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/internal/testutil"
|
||||
"go.pinniped.dev/pkg/oidcclient"
|
||||
"go.pinniped.dev/pkg/oidcclient/nonce"
|
||||
"go.pinniped.dev/pkg/oidcclient/pkce"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
|
||||
"go.pinniped.dev/internal/oidc/csrftoken"
|
||||
"go.pinniped.dev/internal/oidc/provider"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/pkg/oidcclient/nonce"
|
||||
"go.pinniped.dev/pkg/oidcclient/pkce"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"net/url"
|
||||
|
||||
"go.pinniped.dev/internal/oidc/provider"
|
||||
"go.pinniped.dev/internal/oidcclient"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/pkg/oidcclient"
|
||||
"go.pinniped.dev/pkg/oidcclient/nonce"
|
||||
"go.pinniped.dev/pkg/oidcclient/pkce"
|
||||
)
|
||||
|
||||
// Test helpers for the OIDC package.
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"go.pinniped.dev/internal/oidcclient"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/pkg/oidcclient"
|
||||
"go.pinniped.dev/pkg/oidcclient/nonce"
|
||||
"go.pinniped.dev/pkg/oidcclient/pkce"
|
||||
)
|
||||
|
||||
type UpstreamOIDCIdentityProviderI interface {
|
||||
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
"go.pinniped.dev/internal/oidc/discovery"
|
||||
"go.pinniped.dev/internal/oidc/jwks"
|
||||
"go.pinniped.dev/internal/oidc/provider"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/internal/plog"
|
||||
"go.pinniped.dev/pkg/oidcclient/nonce"
|
||||
"go.pinniped.dev/pkg/oidcclient/pkce"
|
||||
)
|
||||
|
||||
// Manager can manage multiple active OIDC providers. It acts as a request router for them.
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package cachefile implements the file format for session caches.
|
||||
package filesession
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"sigs.k8s.io/yaml"
|
||||
|
||||
"go.pinniped.dev/internal/oidcclient"
|
||||
)
|
||||
|
||||
var (
|
||||
// errUnsupportedVersion is returned (internally) when we encounter a version of the session cache file that we
|
||||
// don't understand how to handle (such as one produced by a future version of Pinniped).
|
||||
errUnsupportedVersion = fmt.Errorf("unsupported session version")
|
||||
)
|
||||
|
||||
const (
|
||||
// apiVersion is the Kubernetes-style API version of the session file object.
|
||||
apiVersion = "config.supervisor.pinniped.dev/v1alpha1"
|
||||
|
||||
// apiKind is the Kubernetes-style Kind of the session file object.
|
||||
apiKind = "SessionCache"
|
||||
|
||||
// sessionExpiration is how long a session can remain unused before it is automatically pruned from the session cache.
|
||||
sessionExpiration = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type (
|
||||
// sessionCache is the object which is YAML-serialized to form the contents of the cache file.
|
||||
sessionCache struct {
|
||||
metav1.TypeMeta
|
||||
Sessions []sessionEntry `json:"sessions"`
|
||||
}
|
||||
|
||||
// sessionEntry is a single cache entry in the cache file.
|
||||
sessionEntry struct {
|
||||
Key oidcclient.SessionCacheKey `json:"key"`
|
||||
CreationTimestamp metav1.Time `json:"creationTimestamp"`
|
||||
LastUsedTimestamp metav1.Time `json:"lastUsedTimestamp"`
|
||||
Tokens oidcclient.Token `json:"tokens"`
|
||||
}
|
||||
)
|
||||
|
||||
// readSessionCache loads a sessionCache from a path on disk. If the requested path does not exist, it returns an empty cache.
|
||||
func readSessionCache(path string) (*sessionCache, error) {
|
||||
cacheYAML, err := ioutil.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// If the file was not found, generate a freshly initialized empty cache.
|
||||
return emptySessionCache(), nil
|
||||
}
|
||||
// Otherwise bubble up the error.
|
||||
return nil, fmt.Errorf("could not read session file: %w", err)
|
||||
}
|
||||
|
||||
// If we read the file successfully, unmarshal it from YAML.
|
||||
var cache sessionCache
|
||||
if err := yaml.Unmarshal(cacheYAML, &cache); err != nil {
|
||||
return nil, fmt.Errorf("invalid session file: %w", err)
|
||||
}
|
||||
|
||||
// Validate that we're reading a version of the config we understand how to parse.
|
||||
if !(cache.TypeMeta.APIVersion == apiVersion && cache.TypeMeta.Kind == apiKind) {
|
||||
return nil, fmt.Errorf("%w: %#v", errUnsupportedVersion, cache.TypeMeta)
|
||||
}
|
||||
return &cache, nil
|
||||
}
|
||||
|
||||
// emptySessionCache returns an empty, initialized sessionCache.
|
||||
func emptySessionCache() *sessionCache {
|
||||
return &sessionCache{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: apiVersion, Kind: apiKind},
|
||||
Sessions: make([]sessionEntry, 0, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// writeTo writes the cache to the specified file path.
|
||||
func (c *sessionCache) writeTo(path string) error {
|
||||
// Marshal the session back to YAML and save it to the file.
|
||||
cacheYAML, err := yaml.Marshal(c)
|
||||
if err == nil {
|
||||
err = ioutil.WriteFile(path, cacheYAML, 0600)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// normalized returns a copy of the sessionCache with stale entries removed and entries sorted in a canonical order.
|
||||
func (c *sessionCache) normalized() *sessionCache {
|
||||
result := emptySessionCache()
|
||||
|
||||
// Clean up expired/invalid tokens.
|
||||
now := time.Now()
|
||||
result.Sessions = make([]sessionEntry, 0, len(c.Sessions))
|
||||
|
||||
for _, s := range c.Sessions {
|
||||
// Nil out any tokens that are empty or expired.
|
||||
if s.Tokens.IDToken != nil {
|
||||
if s.Tokens.IDToken.Token == "" || s.Tokens.IDToken.Expiry.Time.Before(now) {
|
||||
s.Tokens.IDToken = nil
|
||||
}
|
||||
}
|
||||
if s.Tokens.AccessToken != nil {
|
||||
if s.Tokens.AccessToken.Token == "" || s.Tokens.AccessToken.Expiry.Time.Before(now) {
|
||||
s.Tokens.AccessToken = nil
|
||||
}
|
||||
}
|
||||
if s.Tokens.RefreshToken != nil && s.Tokens.RefreshToken.Token == "" {
|
||||
s.Tokens.RefreshToken = nil
|
||||
}
|
||||
|
||||
// Filter out any entries that no longer contain any tokens.
|
||||
if s.Tokens.IDToken == nil && s.Tokens.AccessToken == nil && s.Tokens.RefreshToken == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter out entries that haven't been used in the last sessionExpiration.
|
||||
cutoff := metav1.NewTime(now.Add(-1 * sessionExpiration))
|
||||
if s.LastUsedTimestamp.Before(&cutoff) {
|
||||
continue
|
||||
}
|
||||
|
||||
result.Sessions = append(result.Sessions, s)
|
||||
}
|
||||
|
||||
// Sort the sessions by creation time.
|
||||
sort.SliceStable(result.Sessions, func(i, j int) bool {
|
||||
return result.Sessions[i].CreationTimestamp.Before(&result.Sessions[j].CreationTimestamp)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// lookup a cache entry by key. May return nil.
|
||||
func (c *sessionCache) lookup(key oidcclient.SessionCacheKey) *sessionEntry {
|
||||
for i := range c.Sessions {
|
||||
if reflect.DeepEqual(c.Sessions[i].Key, key) {
|
||||
return &c.Sessions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insert a cache entry.
|
||||
func (c *sessionCache) insert(entries ...sessionEntry) {
|
||||
c.Sessions = append(c.Sessions, entries...)
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package filesession
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"go.pinniped.dev/internal/oidcclient"
|
||||
)
|
||||
|
||||
// validSession should be the same data as `testdata/valid.yaml`.
|
||||
var validSession = sessionCache{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "config.supervisor.pinniped.dev/v1alpha1", Kind: "SessionCache"},
|
||||
Sessions: []sessionEntry{
|
||||
{
|
||||
Key: oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
CreationTimestamp: metav1.NewTime(time.Date(2020, 10, 20, 18, 42, 7, 0, time.UTC).Local()),
|
||||
LastUsedTimestamp: metav1.NewTime(time.Date(2020, 10, 20, 18, 45, 31, 0, time.UTC).Local()),
|
||||
Tokens: oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "test-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(time.Date(2020, 10, 20, 19, 46, 30, 0, time.UTC).Local()),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "test-id-token",
|
||||
Expiry: metav1.NewTime(time.Date(2020, 10, 20, 19, 42, 07, 0, time.UTC).Local()),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func TestReadSessionCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want *sessionCache
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "does not exist",
|
||||
path: "./testdata/does-not-exist.yaml",
|
||||
want: &sessionCache{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "config.supervisor.pinniped.dev/v1alpha1", Kind: "SessionCache"},
|
||||
Sessions: []sessionEntry{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "other file error",
|
||||
path: "./testdata/",
|
||||
wantErr: "could not read session file: read ./testdata/: is a directory",
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
path: "./testdata/invalid.yaml",
|
||||
wantErr: "invalid session file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type filesession.sessionCache",
|
||||
},
|
||||
{
|
||||
name: "wrong version",
|
||||
path: "./testdata/wrong-version.yaml",
|
||||
wantErr: `unsupported session version: v1.TypeMeta{Kind:"NotASessionCache", APIVersion:"config.supervisor.pinniped.dev/v2alpha6"}`,
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
path: "./testdata/valid.yaml",
|
||||
want: &validSession,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := readSessionCache(tt.path)
|
||||
if tt.wantErr != "" {
|
||||
require.EqualError(t, err, tt.wantErr)
|
||||
require.Nil(t, got)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySessionCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := emptySessionCache()
|
||||
require.Equal(t, metav1.TypeMeta{APIVersion: "config.supervisor.pinniped.dev/v1alpha1", Kind: "SessionCache"}, got.TypeMeta)
|
||||
require.Equal(t, 0, len(got.Sessions))
|
||||
require.Equal(t, 1, cap(got.Sessions))
|
||||
}
|
||||
|
||||
func TestWriteTo(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("io error", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir() + "/sessions.yaml"
|
||||
require.NoError(t, os.Mkdir(tmp, 0700))
|
||||
err := validSession.writeTo(tmp)
|
||||
require.EqualError(t, err, "open "+tmp+": is a directory")
|
||||
})
|
||||
|
||||
t.Run("success", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.NoError(t, validSession.writeTo(t.TempDir()+"/sessions.yaml"))
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalized(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Equal(t, emptySessionCache(), emptySessionCache().normalized())
|
||||
})
|
||||
|
||||
t.Run("nonempty", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := emptySessionCache()
|
||||
now := time.Now()
|
||||
input.Sessions = []sessionEntry{
|
||||
// ID token is empty, but not nil.
|
||||
{
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Minute)),
|
||||
},
|
||||
},
|
||||
},
|
||||
// ID token is expired.
|
||||
{
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "test-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||
},
|
||||
},
|
||||
},
|
||||
// Access token is empty, but not nil.
|
||||
{
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Minute)),
|
||||
},
|
||||
},
|
||||
},
|
||||
// Access token is expired.
|
||||
{
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "test-access-token",
|
||||
Expiry: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||
},
|
||||
},
|
||||
},
|
||||
// Refresh token is empty, but not nil.
|
||||
{
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Session has a refresh token but it hasn't been used in >90 days.
|
||||
{
|
||||
LastUsedTimestamp: metav1.NewTime(now.AddDate(-1, 0, 0)),
|
||||
Tokens: oidcclient.Token{
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
// Two entries that are still valid.
|
||||
{
|
||||
CreationTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Expect that all but the last two valid session are pruned, and that they're sorted.
|
||||
require.Equal(t, &sessionCache{
|
||||
TypeMeta: metav1.TypeMeta{APIVersion: "config.supervisor.pinniped.dev/v1alpha1", Kind: "SessionCache"},
|
||||
Sessions: []sessionEntry{
|
||||
{
|
||||
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
CreationTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
|
||||
LastUsedTimestamp: metav1.NewTime(now),
|
||||
Tokens: oidcclient.Token{
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, input.normalized())
|
||||
})
|
||||
}
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
require.Nil(t, validSession.lookup(oidcclient.SessionCacheKey{}))
|
||||
require.NotNil(t, validSession.lookup(oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
}))
|
||||
}
|
||||
|
||||
func TestInsert(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := emptySessionCache()
|
||||
c.insert(sessionEntry{})
|
||||
require.Len(t, c.Sessions, 1)
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package filesession implements a simple YAML file-based login.sessionCache.
|
||||
package filesession
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/gofrs/flock"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"go.pinniped.dev/internal/oidcclient"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultFileLockTimeout is how long we will wait trying to acquire the file lock on the session file before timing out.
|
||||
defaultFileLockTimeout = 10 * time.Second
|
||||
|
||||
// defaultFileLockRetryInterval is how often we will poll while waiting for the file lock to become available.
|
||||
defaultFileLockRetryInterval = 10 * time.Millisecond
|
||||
)
|
||||
|
||||
// Option configures a cache in New().
|
||||
type Option func(*Cache)
|
||||
|
||||
// WithErrorReporter is an Option that specifies a callback which will be invoked for each error reported during
|
||||
// session cache operations. By default, these errors are silently ignored.
|
||||
func WithErrorReporter(reporter func(error)) Option {
|
||||
return func(c *Cache) {
|
||||
c.errReporter = reporter
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a login.SessionCache implementation backed by the specified file path.
|
||||
func New(path string, options ...Option) *Cache {
|
||||
lock := flock.New(path + ".lock")
|
||||
c := Cache{
|
||||
path: path,
|
||||
trylockFunc: func() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), defaultFileLockTimeout)
|
||||
defer cancel()
|
||||
_, err := lock.TryLockContext(ctx, defaultFileLockRetryInterval)
|
||||
return err
|
||||
},
|
||||
unlockFunc: lock.Unlock,
|
||||
errReporter: func(_ error) {},
|
||||
}
|
||||
for _, opt := range options {
|
||||
opt(&c)
|
||||
}
|
||||
return &c
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
path string
|
||||
errReporter func(error)
|
||||
trylockFunc func() error
|
||||
unlockFunc func() error
|
||||
}
|
||||
|
||||
// GetToken looks up the cached data for the given parameters. It may return nil if no valid matching session is cached.
|
||||
func (c *Cache) GetToken(key oidcclient.SessionCacheKey) *oidcclient.Token {
|
||||
// If the cache file does not exist, exit immediately with no error log
|
||||
if _, err := os.Stat(c.path); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read the cache and lookup the matching entry. If one exists, update its last used timestamp and return it.
|
||||
var result *oidcclient.Token
|
||||
c.withCache(func(cache *sessionCache) {
|
||||
if entry := cache.lookup(key); entry != nil {
|
||||
result = &entry.Tokens
|
||||
entry.LastUsedTimestamp = metav1.Now()
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
// PutToken stores the provided token into the session cache under the given parameters. It does not return an error
|
||||
// but may silently fail to update the session cache.
|
||||
func (c *Cache) PutToken(key oidcclient.SessionCacheKey, token *oidcclient.Token) {
|
||||
// Create the cache directory if it does not exist.
|
||||
if err := os.MkdirAll(filepath.Dir(c.path), 0700); err != nil && !errors.Is(err, os.ErrExist) {
|
||||
c.errReporter(fmt.Errorf("could not create session cache directory: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Mutate the cache to upsert the new session entry.
|
||||
c.withCache(func(cache *sessionCache) {
|
||||
// Find the existing entry, if one exists
|
||||
if match := cache.lookup(key); match != nil {
|
||||
// Update the stored token.
|
||||
match.Tokens = *token
|
||||
match.LastUsedTimestamp = metav1.Now()
|
||||
return
|
||||
}
|
||||
|
||||
// If there's not an entry for this key, insert one.
|
||||
now := metav1.Now()
|
||||
cache.insert(sessionEntry{
|
||||
Key: key,
|
||||
CreationTimestamp: now,
|
||||
LastUsedTimestamp: now,
|
||||
Tokens: *token,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// withCache is an internal helper which locks, reads the cache, processes/mutates it with the provided function, then
|
||||
// saves it back to the file.
|
||||
func (c *Cache) withCache(transact func(*sessionCache)) {
|
||||
// Grab the file lock so we have exclusive access to read the file.
|
||||
if err := c.trylockFunc(); err != nil {
|
||||
c.errReporter(fmt.Errorf("could not lock session file: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Unlock the file at the end of this call, bubbling up the error if things were otherwise successful.
|
||||
defer func() {
|
||||
if err := c.unlockFunc(); err != nil {
|
||||
c.errReporter(fmt.Errorf("could not unlock session file: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
// Try to read the existing cache.
|
||||
cache, err := readSessionCache(c.path)
|
||||
if err != nil {
|
||||
// If that fails, fall back to resetting to a blank slate.
|
||||
c.errReporter(fmt.Errorf("failed to read cache, resetting: %w", err))
|
||||
cache = emptySessionCache()
|
||||
}
|
||||
|
||||
// Process/mutate the session using the provided function.
|
||||
transact(cache)
|
||||
|
||||
// Marshal the session back to YAML and save it to the file.
|
||||
if err := cache.writeTo(c.path); err != nil {
|
||||
c.errReporter(fmt.Errorf("could not write session cache: %w", err))
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package filesession
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"go.pinniped.dev/internal/oidcclient"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir() + "/sessions.yaml"
|
||||
c := New(tmp)
|
||||
require.NotNil(t, c)
|
||||
require.Equal(t, tmp, c.path)
|
||||
require.NotNil(t, c.errReporter)
|
||||
c.errReporter(fmt.Errorf("some error"))
|
||||
}
|
||||
|
||||
func TestGetToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Now().Round(1 * time.Second)
|
||||
tests := []struct {
|
||||
name string
|
||||
makeTestFile func(t *testing.T, tmp string)
|
||||
trylockFunc func(*testing.T) error
|
||||
unlockFunc func(*testing.T) error
|
||||
key oidcclient.SessionCacheKey
|
||||
want *oidcclient.Token
|
||||
wantErrors []string
|
||||
wantTestFile func(t *testing.T, tmp string)
|
||||
}{
|
||||
{
|
||||
name: "not found",
|
||||
key: oidcclient.SessionCacheKey{},
|
||||
},
|
||||
{
|
||||
name: "file lock error",
|
||||
makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, ioutil.WriteFile(tmp, []byte(""), 0600)) },
|
||||
trylockFunc: func(t *testing.T) error { return fmt.Errorf("some lock error") },
|
||||
unlockFunc: func(t *testing.T) error { require.Fail(t, "should not be called"); return nil },
|
||||
key: oidcclient.SessionCacheKey{},
|
||||
wantErrors: []string{"could not lock session file: some lock error"},
|
||||
},
|
||||
{
|
||||
name: "invalid file",
|
||||
makeTestFile: func(t *testing.T, tmp string) {
|
||||
require.NoError(t, ioutil.WriteFile(tmp, []byte("invalid yaml"), 0600))
|
||||
},
|
||||
key: oidcclient.SessionCacheKey{},
|
||||
wantErrors: []string{
|
||||
"failed to read cache, resetting: invalid session file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type filesession.sessionCache",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid file, fail to unlock",
|
||||
makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, ioutil.WriteFile(tmp, []byte("invalid"), 0600)) },
|
||||
trylockFunc: func(t *testing.T) error { return nil },
|
||||
unlockFunc: func(t *testing.T) error { return fmt.Errorf("some unlock error") },
|
||||
key: oidcclient.SessionCacheKey{},
|
||||
wantErrors: []string{
|
||||
"failed to read cache, resetting: invalid session file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type filesession.sessionCache",
|
||||
"could not unlock session file: some unlock error",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unreadable file",
|
||||
makeTestFile: func(t *testing.T, tmp string) {
|
||||
require.NoError(t, os.Mkdir(tmp, 0700))
|
||||
},
|
||||
key: oidcclient.SessionCacheKey{},
|
||||
wantErrors: []string{
|
||||
"failed to read cache, resetting: could not read session file: read TEMPFILE: is a directory",
|
||||
"could not write session cache: open TEMPFILE: is a directory",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid file but cache miss",
|
||||
makeTestFile: func(t *testing.T, tmp string) {
|
||||
validCache := emptySessionCache()
|
||||
validCache.insert(sessionEntry{
|
||||
Key: oidcclient.SessionCacheKey{
|
||||
Issuer: "not-the-test-issuer",
|
||||
ClientID: "not-the-test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
|
||||
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
|
||||
Tokens: oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "test-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "test-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, validCache.writeTo(tmp))
|
||||
},
|
||||
key: oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
wantErrors: []string{},
|
||||
},
|
||||
{
|
||||
name: "valid file with cache hit",
|
||||
makeTestFile: func(t *testing.T, tmp string) {
|
||||
validCache := emptySessionCache()
|
||||
validCache.insert(sessionEntry{
|
||||
Key: oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
|
||||
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
|
||||
Tokens: oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "test-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "test-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, validCache.writeTo(tmp))
|
||||
},
|
||||
key: oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
wantErrors: []string{},
|
||||
want: &oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "test-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour).Local()),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "test-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour).Local()),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "test-refresh-token",
|
||||
},
|
||||
},
|
||||
wantTestFile: func(t *testing.T, tmp string) {
|
||||
cache, err := readSessionCache(tmp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cache.Sessions, 1)
|
||||
require.Less(t, time.Since(cache.Sessions[0].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir() + "/sessions.yaml"
|
||||
if tt.makeTestFile != nil {
|
||||
tt.makeTestFile(t, tmp)
|
||||
}
|
||||
|
||||
// Initialize a cache with a reporter that collects errors
|
||||
errors := errorCollector{t: t}
|
||||
c := New(tmp, errors.collect())
|
||||
if tt.trylockFunc != nil {
|
||||
c.trylockFunc = func() error { return tt.trylockFunc(t) }
|
||||
}
|
||||
if tt.unlockFunc != nil {
|
||||
c.unlockFunc = func() error { return tt.unlockFunc(t) }
|
||||
}
|
||||
|
||||
got := c.GetToken(tt.key)
|
||||
require.Equal(t, tt.want, got)
|
||||
errors.require(tt.wantErrors, "TEMPFILE", tmp)
|
||||
if tt.wantTestFile != nil {
|
||||
tt.wantTestFile(t, tmp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
now := time.Now().Round(1 * time.Second)
|
||||
tests := []struct {
|
||||
name string
|
||||
makeTestFile func(t *testing.T, tmp string)
|
||||
key oidcclient.SessionCacheKey
|
||||
token *oidcclient.Token
|
||||
wantErrors []string
|
||||
wantTestFile func(t *testing.T, tmp string)
|
||||
}{
|
||||
{
|
||||
name: "fail to create directory",
|
||||
makeTestFile: func(t *testing.T, tmp string) {
|
||||
require.NoError(t, ioutil.WriteFile(filepath.Dir(tmp), []byte{}, 0600))
|
||||
},
|
||||
wantErrors: []string{
|
||||
"could not create session cache directory: mkdir TEMPDIR: not a directory",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "update to existing entry",
|
||||
makeTestFile: func(t *testing.T, tmp string) {
|
||||
validCache := emptySessionCache()
|
||||
validCache.insert(sessionEntry{
|
||||
Key: oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
|
||||
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
|
||||
Tokens: oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "old-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "old-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "old-refresh-token",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(tmp), 0700))
|
||||
require.NoError(t, validCache.writeTo(tmp))
|
||||
},
|
||||
key: oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
token: &oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "new-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "new-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "new-refresh-token",
|
||||
},
|
||||
},
|
||||
wantTestFile: func(t *testing.T, tmp string) {
|
||||
cache, err := readSessionCache(tmp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cache.Sessions, 1)
|
||||
require.Less(t, time.Since(cache.Sessions[0].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
|
||||
require.Equal(t, oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "new-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "new-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "new-refresh-token",
|
||||
},
|
||||
}, cache.Sessions[0].Tokens)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "new entry",
|
||||
makeTestFile: func(t *testing.T, tmp string) {
|
||||
validCache := emptySessionCache()
|
||||
validCache.insert(sessionEntry{
|
||||
Key: oidcclient.SessionCacheKey{
|
||||
Issuer: "not-the-test-issuer",
|
||||
ClientID: "not-the-test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
|
||||
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
|
||||
Tokens: oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "old-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "old-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(1 * time.Hour)),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "old-refresh-token",
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(tmp), 0700))
|
||||
require.NoError(t, validCache.writeTo(tmp))
|
||||
},
|
||||
key: oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
token: &oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "new-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "new-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "new-refresh-token",
|
||||
},
|
||||
},
|
||||
wantTestFile: func(t *testing.T, tmp string) {
|
||||
cache, err := readSessionCache(tmp)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, cache.Sessions, 2)
|
||||
require.Less(t, time.Since(cache.Sessions[1].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
|
||||
require.Equal(t, oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "new-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "new-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "new-refresh-token",
|
||||
},
|
||||
}, cache.Sessions[1].Tokens)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "error writing cache",
|
||||
makeTestFile: func(t *testing.T, tmp string) {
|
||||
require.NoError(t, os.MkdirAll(tmp, 0700))
|
||||
// require.NoError(t, emptySessionCache().writeTo(tmp))
|
||||
// require.NoError(t, os.Chmod(tmp, 0400))
|
||||
},
|
||||
key: oidcclient.SessionCacheKey{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"email", "offline_access", "openid", "profile"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
},
|
||||
token: &oidcclient.Token{
|
||||
AccessToken: &oidcclient.AccessToken{
|
||||
Token: "new-access-token",
|
||||
Type: "Bearer",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
IDToken: &oidcclient.IDToken{
|
||||
Token: "new-id-token",
|
||||
Expiry: metav1.NewTime(now.Add(2 * time.Hour).Local()),
|
||||
},
|
||||
RefreshToken: &oidcclient.RefreshToken{
|
||||
Token: "new-refresh-token",
|
||||
},
|
||||
},
|
||||
wantErrors: []string{
|
||||
"failed to read cache, resetting: could not read session file: read TEMPFILE: is a directory",
|
||||
"could not write session cache: open TEMPFILE: is a directory",
|
||||
},
|
||||
wantTestFile: func(t *testing.T, tmp string) {
|
||||
// cache, err := readSessionCache(tmp)
|
||||
// require.NoError(t, err)
|
||||
// require.Len(t, cache.Sessions, 0)
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
tmp := t.TempDir() + "/sessiondir/sessions.yaml"
|
||||
if tt.makeTestFile != nil {
|
||||
tt.makeTestFile(t, tmp)
|
||||
}
|
||||
// Initialize a cache with a reporter that collects errors
|
||||
errors := errorCollector{t: t}
|
||||
c := New(tmp, errors.collect())
|
||||
c.PutToken(tt.key, tt.token)
|
||||
errors.require(tt.wantErrors, "TEMPFILE", tmp, "TEMPDIR", filepath.Dir(tmp))
|
||||
if tt.wantTestFile != nil {
|
||||
tt.wantTestFile(t, tmp)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type errorCollector struct {
|
||||
t *testing.T
|
||||
saw []error
|
||||
}
|
||||
|
||||
func (e *errorCollector) collect() Option {
|
||||
return WithErrorReporter(func(err error) {
|
||||
e.saw = append(e.saw, err)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *errorCollector) require(want []string, subs ...string) {
|
||||
require.Len(e.t, e.saw, len(want))
|
||||
for i, w := range want {
|
||||
for i := 0; i < len(subs); i += 2 {
|
||||
w = strings.ReplaceAll(w, subs[i], subs[i+1])
|
||||
}
|
||||
require.EqualError(e.t, e.saw[i], w)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
invalid YAML
|
||||
@@ -1,24 +0,0 @@
|
||||
apiVersion: config.supervisor.pinniped.dev/v1alpha1
|
||||
kind: SessionCache
|
||||
sessions:
|
||||
- creationTimestamp: "2020-10-20T18:42:07Z"
|
||||
key:
|
||||
clientID: test-client-id
|
||||
issuer: test-issuer
|
||||
redirect_uri: http://localhost:0/callback
|
||||
scopes:
|
||||
- email
|
||||
- offline_access
|
||||
- openid
|
||||
- profile
|
||||
lastUsedTimestamp: "2020-10-20T18:45:31Z"
|
||||
tokens:
|
||||
access:
|
||||
expiryTimestamp: "2020-10-20T19:46:30Z"
|
||||
token: test-access-token
|
||||
type: Bearer
|
||||
id:
|
||||
expiryTimestamp: "2020-10-20T19:42:07Z"
|
||||
token: test-id-token
|
||||
refresh:
|
||||
token: test-refresh-token
|
||||
@@ -1,3 +0,0 @@
|
||||
apiVersion: config.supervisor.pinniped.dev/v2alpha6
|
||||
kind: NotASessionCache
|
||||
sessions: []
|
||||
@@ -1,384 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package oidcclient implements a CLI OIDC login flow.
|
||||
package oidcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/pkg/browser"
|
||||
"golang.org/x/oauth2"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"go.pinniped.dev/internal/httputil/httperr"
|
||||
"go.pinniped.dev/internal/httputil/securityheader"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/internal/oidcclient/state"
|
||||
)
|
||||
|
||||
const (
|
||||
// minIDTokenValidity is the minimum amount of time that a cached ID token must be still be valid to be considered.
|
||||
// This is non-zero to ensure that most of the time, your ID token won't expire in the middle of a multi-step k8s
|
||||
// API operation.
|
||||
minIDTokenValidity = 10 * time.Minute
|
||||
|
||||
// refreshTimeout is the amount of time allotted for OAuth2 refresh operations. Since these don't involve any
|
||||
// user interaction, they should always be roughly as fast as network latency.
|
||||
refreshTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type handlerState struct {
|
||||
// Basic parameters.
|
||||
ctx context.Context
|
||||
issuer string
|
||||
clientID string
|
||||
scopes []string
|
||||
cache SessionCache
|
||||
|
||||
httpClient *http.Client
|
||||
|
||||
// Parameters of the localhost listener.
|
||||
listenAddr string
|
||||
callbackPath string
|
||||
|
||||
// Generated parameters of a login flow.
|
||||
idTokenVerifier *oidc.IDTokenVerifier
|
||||
oauth2Config *oauth2.Config
|
||||
state state.State
|
||||
nonce nonce.Nonce
|
||||
pkce pkce.Code
|
||||
|
||||
// External calls for things.
|
||||
generateState func() (state.State, error)
|
||||
generatePKCE func() (pkce.Code, error)
|
||||
generateNonce func() (nonce.Nonce, error)
|
||||
openURL func(string) error
|
||||
oidcDiscover func(context.Context, string) (discoveryI, error)
|
||||
|
||||
callbacks chan callbackResult
|
||||
}
|
||||
|
||||
type callbackResult struct {
|
||||
token *Token
|
||||
err error
|
||||
}
|
||||
|
||||
// Option is an optional configuration for Login().
|
||||
type Option func(*handlerState) error
|
||||
|
||||
// WithContext specifies a specific context.Context under which to perform the login. If this option is not specified,
|
||||
// login happens under context.Background().
|
||||
func WithContext(ctx context.Context) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.ctx = ctx
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithListenPort specifies a TCP listen port on localhost, which will be used for the redirect_uri and to handle the
|
||||
// authorization code callback. By default, a random high port will be chosen which requires the authorization server
|
||||
// to support wildcard port numbers as described by https://tools.ietf.org/html/rfc8252:
|
||||
// The authorization server MUST allow any port to be specified at the
|
||||
// time of the request for loopback IP redirect URIs, to accommodate
|
||||
// clients that obtain an available ephemeral port from the operating
|
||||
// system at the time of the request.
|
||||
func WithListenPort(port uint16) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.listenAddr = fmt.Sprintf("localhost:%d", port)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithScopes sets the OAuth2 scopes to request during login. If not specified, it defaults to
|
||||
// "offline_access openid email profile".
|
||||
func WithScopes(scopes []string) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.scopes = scopes
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithBrowserOpen overrides the default "open browser" functionality with a custom callback. If not specified,
|
||||
// an implementation using https://github.com/pkg/browser will be used by default.
|
||||
func WithBrowserOpen(openURL func(url string) error) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.openURL = openURL
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithSessionCache sets the session cache backend for storing and retrieving previously-issued ID tokens and refresh tokens.
|
||||
func WithSessionCache(cache SessionCache) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.cache = cache
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithClient sets the HTTP client used to make CLI-to-provider requests.
|
||||
func WithClient(httpClient *http.Client) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.httpClient = httpClient
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// nopCache is a SessionCache that doesn't actually do anything.
|
||||
type nopCache struct{}
|
||||
|
||||
func (*nopCache) GetToken(SessionCacheKey) *Token { return nil }
|
||||
func (*nopCache) PutToken(SessionCacheKey, *Token) {}
|
||||
|
||||
type discoveryI interface {
|
||||
Endpoint() oauth2.Endpoint
|
||||
Verifier(*oidc.Config) *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// Login performs an OAuth2/OIDC authorization code login using a localhost listener.
|
||||
func Login(issuer string, clientID string, opts ...Option) (*Token, error) {
|
||||
h := handlerState{
|
||||
issuer: issuer,
|
||||
clientID: clientID,
|
||||
listenAddr: "localhost:0",
|
||||
scopes: []string{"offline_access", "openid", "email", "profile"},
|
||||
cache: &nopCache{},
|
||||
callbackPath: "/callback",
|
||||
ctx: context.Background(),
|
||||
callbacks: make(chan callbackResult),
|
||||
httpClient: http.DefaultClient,
|
||||
|
||||
// Default implementations of external dependencies (to be mocked in tests).
|
||||
generateState: state.Generate,
|
||||
generateNonce: nonce.Generate,
|
||||
generatePKCE: pkce.Generate,
|
||||
openURL: browser.OpenURL,
|
||||
oidcDiscover: func(ctx context.Context, iss string) (discoveryI, error) {
|
||||
return oidc.NewProvider(ctx, iss)
|
||||
},
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if err := opt(&h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Always set a long, but non-infinite timeout for this operation.
|
||||
ctx, cancel := context.WithTimeout(h.ctx, 10*time.Minute)
|
||||
defer cancel()
|
||||
ctx = oidc.ClientContext(ctx, h.httpClient)
|
||||
h.ctx = ctx
|
||||
|
||||
// Initialize login parameters.
|
||||
var err error
|
||||
h.state, err = h.generateState()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.nonce, err = h.generateNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.pkce, err = h.generatePKCE()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check the cache for a previous session issued with the same parameters.
|
||||
sort.Strings(h.scopes)
|
||||
cacheKey := SessionCacheKey{
|
||||
Issuer: h.issuer,
|
||||
ClientID: h.clientID,
|
||||
Scopes: h.scopes,
|
||||
RedirectURI: (&url.URL{Scheme: "http", Host: h.listenAddr, Path: h.callbackPath}).String(),
|
||||
}
|
||||
|
||||
// If the ID token is still valid for a bit, return it immediately and skip the rest of the flow.
|
||||
cached := h.cache.GetToken(cacheKey)
|
||||
if cached != nil && cached.IDToken != nil && time.Until(cached.IDToken.Expiry.Time) > minIDTokenValidity {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Perform OIDC discovery.
|
||||
discovered, err := h.oidcDiscover(h.ctx, h.issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not perform OIDC discovery for %q: %w", h.issuer, err)
|
||||
}
|
||||
h.idTokenVerifier = discovered.Verifier(&oidc.Config{ClientID: h.clientID})
|
||||
|
||||
// Build an OAuth2 configuration based on the OIDC discovery data and our callback endpoint.
|
||||
h.oauth2Config = &oauth2.Config{
|
||||
ClientID: h.clientID,
|
||||
Endpoint: discovered.Endpoint(),
|
||||
Scopes: h.scopes,
|
||||
}
|
||||
|
||||
// If there was a cached refresh token, attempt to use the refresh flow instead of a fresh login.
|
||||
if cached != nil && cached.RefreshToken != nil && cached.RefreshToken.Token != "" {
|
||||
freshToken, err := h.handleRefresh(ctx, cached.RefreshToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// If we got a fresh token, we can update the cache and return it. Otherwise we fall through to the full refresh flow.
|
||||
if freshToken != nil {
|
||||
h.cache.PutToken(cacheKey, freshToken)
|
||||
return freshToken, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Open a TCP listener and update the OAuth2 redirect_uri to match (in case we are using an ephemeral port number).
|
||||
listener, err := net.Listen("tcp", h.listenAddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open callback listener: %w", err)
|
||||
}
|
||||
h.oauth2Config.RedirectURL = (&url.URL{
|
||||
Scheme: "http",
|
||||
Host: listener.Addr().String(),
|
||||
Path: h.callbackPath,
|
||||
}).String()
|
||||
|
||||
// Start a callback server in a background goroutine.
|
||||
shutdown := h.serve(listener)
|
||||
defer shutdown()
|
||||
|
||||
// Open the authorize URL in the users browser.
|
||||
authorizeURL := h.oauth2Config.AuthCodeURL(
|
||||
h.state.String(),
|
||||
oauth2.AccessTypeOffline,
|
||||
h.nonce.Param(),
|
||||
h.pkce.Challenge(),
|
||||
h.pkce.Method(),
|
||||
)
|
||||
if err := h.openURL(authorizeURL); err != nil {
|
||||
return nil, fmt.Errorf("could not open browser: %w", err)
|
||||
}
|
||||
|
||||
// Wait for either the callback or a timeout.
|
||||
select {
|
||||
case <-h.ctx.Done():
|
||||
return nil, fmt.Errorf("timed out waiting for token callback: %w", h.ctx.Err())
|
||||
case callback := <-h.callbacks:
|
||||
if callback.err != nil {
|
||||
return nil, fmt.Errorf("error handling callback: %w", callback.err)
|
||||
}
|
||||
h.cache.PutToken(cacheKey, callback.token)
|
||||
return callback.token, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handlerState) handleRefresh(ctx context.Context, refreshToken *RefreshToken) (*Token, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
|
||||
defer cancel()
|
||||
refreshSource := h.oauth2Config.TokenSource(ctx, &oauth2.Token{RefreshToken: refreshToken.Token})
|
||||
|
||||
refreshed, err := refreshSource.Token()
|
||||
if err != nil {
|
||||
// Ignore errors during refresh, but return nil which will trigger the full login flow.
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at least
|
||||
// some providers do not include one, so we skip the nonce validation here (but not other validations).
|
||||
return h.validateToken(ctx, refreshed, false)
|
||||
}
|
||||
|
||||
func (h *handlerState) handleAuthCodeCallback(w http.ResponseWriter, r *http.Request) (err error) {
|
||||
// If we return an error, also report it back over the channel to the main CLI thread.
|
||||
defer func() {
|
||||
if err != nil {
|
||||
h.callbacks <- callbackResult{err: err}
|
||||
}
|
||||
}()
|
||||
|
||||
// Return HTTP 405 for anything that's not a GET.
|
||||
if r.Method != http.MethodGet {
|
||||
return httperr.Newf(http.StatusMethodNotAllowed, "wanted GET")
|
||||
}
|
||||
|
||||
// Validate OAuth2 state and fail if it's incorrect (to block CSRF).
|
||||
params := r.URL.Query()
|
||||
if err := h.state.Validate(params.Get("state")); err != nil {
|
||||
return httperr.New(http.StatusForbidden, "missing or invalid state parameter")
|
||||
}
|
||||
|
||||
// Check for error response parameters.
|
||||
if errorParam := params.Get("error"); errorParam != "" {
|
||||
return httperr.Newf(http.StatusBadRequest, "login failed with code %q", errorParam)
|
||||
}
|
||||
|
||||
// Exchange the authorization code for access, ID, and refresh tokens.
|
||||
oauth2Tok, err := h.oauth2Config.Exchange(r.Context(), params.Get("code"), h.pkce.Verifier())
|
||||
if err != nil {
|
||||
return httperr.Wrap(http.StatusBadRequest, "could not complete code exchange", err)
|
||||
}
|
||||
|
||||
// Perform required validations on the returned ID token.
|
||||
token, err := h.validateToken(r.Context(), oauth2Tok, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.callbacks <- callbackResult{token: token}
|
||||
_, _ = w.Write([]byte("you have been logged in and may now close this tab"))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *handlerState) validateToken(ctx context.Context, tok *oauth2.Token, checkNonce bool) (*Token, error) {
|
||||
idTok, hasIDTok := tok.Extra("id_token").(string)
|
||||
if !hasIDTok {
|
||||
return nil, httperr.New(http.StatusBadRequest, "received response missing ID token")
|
||||
}
|
||||
validated, err := h.idTokenVerifier.Verify(ctx, idTok)
|
||||
if err != nil {
|
||||
return nil, httperr.Wrap(http.StatusBadRequest, "received invalid ID token", err)
|
||||
}
|
||||
if validated.AccessTokenHash != "" {
|
||||
if err := validated.VerifyAccessToken(tok.AccessToken); err != nil {
|
||||
return nil, httperr.Wrap(http.StatusBadRequest, "received invalid ID token", err)
|
||||
}
|
||||
}
|
||||
if checkNonce {
|
||||
if err := h.nonce.Validate(validated); err != nil {
|
||||
return nil, httperr.Wrap(http.StatusBadRequest, "received ID token with invalid nonce", err)
|
||||
}
|
||||
}
|
||||
return &Token{
|
||||
AccessToken: &AccessToken{
|
||||
Token: tok.AccessToken,
|
||||
Type: tok.TokenType,
|
||||
Expiry: metav1.NewTime(tok.Expiry),
|
||||
},
|
||||
RefreshToken: &RefreshToken{
|
||||
Token: tok.RefreshToken,
|
||||
},
|
||||
IDToken: &IDToken{
|
||||
Token: idTok,
|
||||
Expiry: metav1.NewTime(validated.Expiry),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *handlerState) serve(listener net.Listener) func() {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle(h.callbackPath, httperr.HandlerFunc(h.handleAuthCodeCallback))
|
||||
srv := http.Server{
|
||||
Handler: securityheader.Wrap(mux),
|
||||
BaseContext: func(_ net.Listener) context.Context { return h.ctx },
|
||||
}
|
||||
go func() { _ = srv.Serve(listener) }()
|
||||
return func() {
|
||||
// Gracefully shut down the server, allowing up to 5 seconds for
|
||||
// clients to receive any in-flight responses.
|
||||
shutdownCtx, cancel := context.WithTimeout(h.ctx, 1*time.Second)
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
@@ -1,685 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package oidcclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
"gopkg.in/square/go-jose.v2"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
"go.pinniped.dev/internal/httputil/httperr"
|
||||
"go.pinniped.dev/internal/mocks/mockkeyset"
|
||||
"go.pinniped.dev/internal/oidcclient/nonce"
|
||||
"go.pinniped.dev/internal/oidcclient/pkce"
|
||||
"go.pinniped.dev/internal/oidcclient/state"
|
||||
"go.pinniped.dev/internal/testutil"
|
||||
)
|
||||
|
||||
// mockSessionCache exists to avoid an import cycle if we generate mocks into another package.
|
||||
type mockSessionCache struct {
|
||||
t *testing.T
|
||||
getReturnsToken *Token
|
||||
sawGetKeys []SessionCacheKey
|
||||
sawPutKeys []SessionCacheKey
|
||||
sawPutTokens []*Token
|
||||
}
|
||||
|
||||
func (m *mockSessionCache) GetToken(key SessionCacheKey) *Token {
|
||||
m.t.Logf("saw mock session cache GetToken() with client ID %s", key.ClientID)
|
||||
m.sawGetKeys = append(m.sawGetKeys, key)
|
||||
return m.getReturnsToken
|
||||
}
|
||||
|
||||
func (m *mockSessionCache) PutToken(key SessionCacheKey, token *Token) {
|
||||
m.t.Logf("saw mock session cache PutToken() with client ID %s and ID token %s", key.ClientID, token.IDToken.Token)
|
||||
m.sawPutKeys = append(m.sawPutKeys, key)
|
||||
m.sawPutTokens = append(m.sawPutTokens, token)
|
||||
}
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
time1 := time.Date(2035, 10, 12, 13, 14, 15, 16, time.UTC)
|
||||
time1Unix := int64(2075807775)
|
||||
require.Equal(t, time1Unix, time1.Add(2*time.Minute).Unix())
|
||||
|
||||
testToken := Token{
|
||||
AccessToken: &AccessToken{
|
||||
Token: "test-access-token",
|
||||
Expiry: metav1.NewTime(time1.Add(1 * time.Minute)),
|
||||
},
|
||||
RefreshToken: &RefreshToken{
|
||||
Token: "test-refresh-token",
|
||||
},
|
||||
IDToken: &IDToken{
|
||||
// Test JWT generated with https://smallstep.com/docs/cli/crypto/jwt/ (using time1Unix from above):
|
||||
// step crypto keypair key.pub key.priv --kty RSA --no-password --insecure --force && echo '{}' | step crypto jwt sign --key key.priv --aud test-client-id --sub test-user --subtle --kid="test-kid" --jti="test-jti" --exp 2075807775
|
||||
Token: "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImV4cCI6MjA3NTgwNzc3NSwiaWF0IjoxNjAzMzk5NTY4LCJpc3MiOiJ0ZXN0LWlzc3VlciIsImp0aSI6InRlc3QtanRpIiwibmJmIjoxNjAzMzk5NTY4LCJzdWIiOiJ0ZXN0LXVzZXIifQ.CdwUWQb6xELeFlC4u84K4rzks7YiDJiXxIo_SaRvCHBijxtil812RBRfPuAyYKJlGwFx1g-JYvkUg69X5NmvmLXkaOdHIKUAT7Nqa7yqd1xOAP9IlFj9qZM3Q7s8gWWW9da-_ryagzN4fyGfNfYeGhzIriSMaVpuBGz1eg6f-6VuuulnoiOpl8A0l50u0MdRjjsxRHuiR2loIhUxoIQQ9xN8w53UiP0R1uz8_uV0_K93RSq37aPjsnCXRLwUUb3azkRVe6B9EUW1ihthQ-KfRaU1iq2rY1m5UqNzf0NqDXCrN5SF-GVxOhKXJTsN4-PABfJBjqxg6dGUGeIa2JhFcA",
|
||||
Expiry: metav1.NewTime(time1.Add(2 * time.Minute)),
|
||||
},
|
||||
}
|
||||
|
||||
// Start a test server that returns 500 errors
|
||||
errorServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "some discovery error", http.StatusInternalServerError)
|
||||
}))
|
||||
t.Cleanup(errorServer.Close)
|
||||
|
||||
// Start a test server that returns a real keyset and answers refresh requests.
|
||||
providerMux := http.NewServeMux()
|
||||
successServer := httptest.NewServer(providerMux)
|
||||
t.Cleanup(successServer.Close)
|
||||
providerMux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("content-type", "application/json")
|
||||
type providerJSON struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthURL string `json:"authorization_endpoint"`
|
||||
TokenURL string `json:"token_endpoint"`
|
||||
JWKSURL string `json:"jwks_uri"`
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(&providerJSON{
|
||||
Issuer: successServer.URL,
|
||||
AuthURL: successServer.URL + "/authorize",
|
||||
TokenURL: successServer.URL + "/token",
|
||||
JWKSURL: successServer.URL + "/keys",
|
||||
})
|
||||
})
|
||||
providerMux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "unexpected method", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("client_id") != "test-client-id" {
|
||||
http.Error(w, "expected client_id 'test-client-id'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Form.Get("grant_type") != "refresh_token" {
|
||||
http.Error(w, "expected refresh_token grant type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var response struct {
|
||||
oauth2.Token
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
response.AccessToken = testToken.AccessToken.Token
|
||||
response.ExpiresIn = int64(time.Until(testToken.AccessToken.Expiry.Time).Seconds())
|
||||
response.RefreshToken = testToken.RefreshToken.Token
|
||||
response.IDToken = testToken.IDToken.Token
|
||||
|
||||
if r.Form.Get("refresh_token") == "test-refresh-token-returning-invalid-id-token" {
|
||||
response.IDToken = "not a valid JWT"
|
||||
} else if r.Form.Get("refresh_token") != "test-refresh-token" {
|
||||
http.Error(w, "expected refresh_token to be 'test-refresh-token'", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("content-type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(&response))
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opt func(t *testing.T) Option
|
||||
issuer string
|
||||
clientID string
|
||||
wantErr string
|
||||
wantToken *Token
|
||||
}{
|
||||
{
|
||||
name: "option error",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
return fmt.Errorf("some option error")
|
||||
}
|
||||
},
|
||||
wantErr: "some option error",
|
||||
},
|
||||
{
|
||||
name: "error generating state",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.generateState = func() (state.State, error) { return "", fmt.Errorf("some error generating state") }
|
||||
return nil
|
||||
}
|
||||
},
|
||||
wantErr: "some error generating state",
|
||||
},
|
||||
{
|
||||
name: "error generating nonce",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.generateNonce = func() (nonce.Nonce, error) { return "", fmt.Errorf("some error generating nonce") }
|
||||
return nil
|
||||
}
|
||||
},
|
||||
wantErr: "some error generating nonce",
|
||||
},
|
||||
{
|
||||
name: "error generating PKCE",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.generatePKCE = func() (pkce.Code, error) { return "", fmt.Errorf("some error generating PKCE") }
|
||||
return nil
|
||||
}
|
||||
},
|
||||
wantErr: "some error generating PKCE",
|
||||
},
|
||||
{
|
||||
name: "session cache hit but token expired",
|
||||
issuer: "test-issuer",
|
||||
clientID: "test-client-id",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
cache := &mockSessionCache{t: t, getReturnsToken: &Token{
|
||||
IDToken: &IDToken{
|
||||
Token: "test-id-token",
|
||||
Expiry: metav1.NewTime(time.Now()), // less than Now() + minIDTokenValidity
|
||||
},
|
||||
}}
|
||||
t.Cleanup(func() {
|
||||
require.Equal(t, []SessionCacheKey{{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"test-scope"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
}}, cache.sawGetKeys)
|
||||
require.Empty(t, cache.sawPutTokens)
|
||||
})
|
||||
return WithSessionCache(cache)(h)
|
||||
}
|
||||
},
|
||||
wantErr: `could not perform OIDC discovery for "test-issuer": Get "test-issuer/.well-known/openid-configuration": unsupported protocol scheme ""`,
|
||||
},
|
||||
{
|
||||
name: "session cache hit with valid token",
|
||||
issuer: "test-issuer",
|
||||
clientID: "test-client-id",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
cache := &mockSessionCache{t: t, getReturnsToken: &testToken}
|
||||
t.Cleanup(func() {
|
||||
require.Equal(t, []SessionCacheKey{{
|
||||
Issuer: "test-issuer",
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"test-scope"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
}}, cache.sawGetKeys)
|
||||
require.Empty(t, cache.sawPutTokens)
|
||||
})
|
||||
return WithSessionCache(cache)(h)
|
||||
}
|
||||
},
|
||||
wantToken: &testToken,
|
||||
},
|
||||
{
|
||||
name: "discovery failure",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error { return nil }
|
||||
},
|
||||
issuer: errorServer.URL,
|
||||
wantErr: fmt.Sprintf("could not perform OIDC discovery for %q: 500 Internal Server Error: some discovery error\n", errorServer.URL),
|
||||
},
|
||||
{
|
||||
name: "session cache hit with refreshable token",
|
||||
issuer: successServer.URL,
|
||||
clientID: "test-client-id",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
cache := &mockSessionCache{t: t, getReturnsToken: &Token{
|
||||
IDToken: &IDToken{
|
||||
Token: "expired-test-id-token",
|
||||
Expiry: metav1.Now(), // less than Now() + minIDTokenValidity
|
||||
},
|
||||
RefreshToken: &RefreshToken{Token: "test-refresh-token"},
|
||||
}}
|
||||
t.Cleanup(func() {
|
||||
cacheKey := SessionCacheKey{
|
||||
Issuer: successServer.URL,
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"test-scope"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
}
|
||||
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys)
|
||||
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawPutKeys)
|
||||
require.Len(t, cache.sawPutTokens, 1)
|
||||
require.Equal(t, testToken.IDToken.Token, cache.sawPutTokens[0].IDToken.Token)
|
||||
})
|
||||
h.cache = cache
|
||||
|
||||
h.oidcDiscover = func(ctx context.Context, iss string) (discoveryI, error) {
|
||||
provider, err := oidc.NewProvider(ctx, iss)
|
||||
require.NoError(t, err)
|
||||
return &mockDiscovery{provider: provider}, nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
wantToken: &testToken,
|
||||
},
|
||||
{
|
||||
name: "session cache hit but refresh returns invalid token",
|
||||
issuer: successServer.URL,
|
||||
clientID: "test-client-id",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
cache := &mockSessionCache{t: t, getReturnsToken: &Token{
|
||||
IDToken: &IDToken{
|
||||
Token: "expired-test-id-token",
|
||||
Expiry: metav1.Now(), // less than Now() + minIDTokenValidity
|
||||
},
|
||||
RefreshToken: &RefreshToken{Token: "test-refresh-token-returning-invalid-id-token"},
|
||||
}}
|
||||
t.Cleanup(func() {
|
||||
require.Empty(t, cache.sawPutKeys)
|
||||
require.Empty(t, cache.sawPutTokens)
|
||||
})
|
||||
h.cache = cache
|
||||
|
||||
h.oidcDiscover = func(ctx context.Context, iss string) (discoveryI, error) {
|
||||
provider, err := oidc.NewProvider(ctx, iss)
|
||||
require.NoError(t, err)
|
||||
return &mockDiscovery{provider: provider}, nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
},
|
||||
wantErr: "received invalid ID token: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts",
|
||||
},
|
||||
{
|
||||
name: "session cache hit but refresh fails",
|
||||
issuer: successServer.URL,
|
||||
clientID: "not-the-test-client-id",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
cache := &mockSessionCache{t: t, getReturnsToken: &Token{
|
||||
IDToken: &IDToken{
|
||||
Token: "expired-test-id-token",
|
||||
Expiry: metav1.Now(), // less than Now() + minIDTokenValidity
|
||||
},
|
||||
RefreshToken: &RefreshToken{Token: "test-refresh-token"},
|
||||
}}
|
||||
t.Cleanup(func() {
|
||||
require.Empty(t, cache.sawPutKeys)
|
||||
require.Empty(t, cache.sawPutTokens)
|
||||
})
|
||||
h.cache = cache
|
||||
|
||||
h.oidcDiscover = func(ctx context.Context, iss string) (discoveryI, error) {
|
||||
provider, err := oidc.NewProvider(ctx, iss)
|
||||
require.NoError(t, err)
|
||||
return &mockDiscovery{provider: provider}, nil
|
||||
}
|
||||
|
||||
h.listenAddr = "invalid-listen-address"
|
||||
|
||||
return nil
|
||||
}
|
||||
},
|
||||
// Expect this to fall through to the authorization code flow, so it fails here.
|
||||
wantErr: "could not open callback listener: listen tcp: address invalid-listen-address: missing port in address",
|
||||
},
|
||||
{
|
||||
name: "listen failure",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.listenAddr = "invalid-listen-address"
|
||||
return nil
|
||||
}
|
||||
},
|
||||
issuer: successServer.URL,
|
||||
wantErr: "could not open callback listener: listen tcp: address invalid-listen-address: missing port in address",
|
||||
},
|
||||
{
|
||||
name: "browser open failure",
|
||||
opt: func(t *testing.T) Option {
|
||||
return WithBrowserOpen(func(url string) error {
|
||||
return fmt.Errorf("some browser open error")
|
||||
})
|
||||
},
|
||||
issuer: successServer.URL,
|
||||
wantErr: "could not open browser: some browser open error",
|
||||
},
|
||||
{
|
||||
name: "timeout waiting for callback",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
ctx, cancel := context.WithCancel(h.ctx)
|
||||
h.ctx = ctx
|
||||
|
||||
h.openURL = func(_ string) error {
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
issuer: successServer.URL,
|
||||
wantErr: "timed out waiting for token callback: context canceled",
|
||||
},
|
||||
{
|
||||
name: "callback returns error",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.openURL = func(_ string) error {
|
||||
go func() {
|
||||
h.callbacks <- callbackResult{err: fmt.Errorf("some callback error")}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
issuer: successServer.URL,
|
||||
wantErr: "error handling callback: some callback error",
|
||||
},
|
||||
{
|
||||
name: "callback returns success",
|
||||
clientID: "test-client-id",
|
||||
opt: func(t *testing.T) Option {
|
||||
return func(h *handlerState) error {
|
||||
h.generateState = func() (state.State, error) { return "test-state", nil }
|
||||
h.generatePKCE = func() (pkce.Code, error) { return "test-pkce", nil }
|
||||
h.generateNonce = func() (nonce.Nonce, error) { return "test-nonce", nil }
|
||||
|
||||
cache := &mockSessionCache{t: t, getReturnsToken: nil}
|
||||
cacheKey := SessionCacheKey{
|
||||
Issuer: successServer.URL,
|
||||
ClientID: "test-client-id",
|
||||
Scopes: []string{"test-scope"},
|
||||
RedirectURI: "http://localhost:0/callback",
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawGetKeys)
|
||||
require.Equal(t, []SessionCacheKey{cacheKey}, cache.sawPutKeys)
|
||||
require.Equal(t, []*Token{&testToken}, cache.sawPutTokens)
|
||||
})
|
||||
require.NoError(t, WithSessionCache(cache)(h))
|
||||
require.NoError(t, WithClient(&http.Client{Timeout: 10 * time.Second})(h))
|
||||
|
||||
h.openURL = func(actualURL string) error {
|
||||
parsedActualURL, err := url.Parse(actualURL)
|
||||
require.NoError(t, err)
|
||||
actualParams := parsedActualURL.Query()
|
||||
|
||||
require.Contains(t, actualParams.Get("redirect_uri"), "http://127.0.0.1:")
|
||||
actualParams.Del("redirect_uri")
|
||||
|
||||
require.Equal(t, url.Values{
|
||||
// This is the PKCE challenge which is calculated as base64(sha256("test-pkce")). For example:
|
||||
// $ echo -n test-pkce | shasum -a 256 | cut -d" " -f1 | xxd -r -p | base64 | cut -d"=" -f1
|
||||
// VVaezYqum7reIhoavCHD1n2d+piN3r/mywoYj7fCR7g
|
||||
"code_challenge": []string{"VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g"},
|
||||
"code_challenge_method": []string{"S256"},
|
||||
"response_type": []string{"code"},
|
||||
"scope": []string{"test-scope"},
|
||||
"nonce": []string{"test-nonce"},
|
||||
"state": []string{"test-state"},
|
||||
"access_type": []string{"offline"},
|
||||
"client_id": []string{"test-client-id"},
|
||||
}, actualParams)
|
||||
|
||||
parsedActualURL.RawQuery = ""
|
||||
require.Equal(t, successServer.URL+"/authorize", parsedActualURL.String())
|
||||
|
||||
go func() {
|
||||
h.callbacks <- callbackResult{token: &testToken}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
},
|
||||
issuer: successServer.URL,
|
||||
wantToken: &testToken,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tok, err := Login(tt.issuer, tt.clientID,
|
||||
WithContext(context.Background()),
|
||||
WithListenPort(0),
|
||||
WithScopes([]string{"test-scope"}),
|
||||
tt.opt(t),
|
||||
)
|
||||
if tt.wantErr != "" {
|
||||
require.EqualError(t, err, tt.wantErr)
|
||||
require.Nil(t, tok)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.wantToken == nil {
|
||||
require.Nil(t, tok)
|
||||
return
|
||||
}
|
||||
require.NotNil(t, tok)
|
||||
|
||||
if want := tt.wantToken.AccessToken; want != nil {
|
||||
require.NotNil(t, tok.AccessToken)
|
||||
require.Equal(t, want.Token, tok.AccessToken.Token)
|
||||
require.Equal(t, want.Type, tok.AccessToken.Type)
|
||||
testutil.RequireTimeInDelta(t, want.Expiry.Time, tok.AccessToken.Expiry.Time, 5*time.Second)
|
||||
} else {
|
||||
assert.Nil(t, tok.AccessToken)
|
||||
}
|
||||
require.Equal(t, tt.wantToken.RefreshToken, tok.RefreshToken)
|
||||
if want := tt.wantToken.IDToken; want != nil {
|
||||
require.NotNil(t, tok.IDToken)
|
||||
require.Equal(t, want.Token, tok.IDToken.Token)
|
||||
testutil.RequireTimeInDelta(t, want.Expiry.Time, tok.IDToken.Expiry.Time, 5*time.Second)
|
||||
} else {
|
||||
assert.Nil(t, tok.IDToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAuthCodeCallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
query string
|
||||
returnIDTok string
|
||||
wantErr string
|
||||
wantHTTPStatus int
|
||||
}{
|
||||
{
|
||||
name: "wrong method",
|
||||
method: "POST",
|
||||
query: "",
|
||||
wantErr: "wanted GET",
|
||||
wantHTTPStatus: http.StatusMethodNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "invalid state",
|
||||
query: "state=invalid",
|
||||
wantErr: "missing or invalid state parameter",
|
||||
wantHTTPStatus: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "error code from provider",
|
||||
query: "state=test-state&error=some_error",
|
||||
wantErr: `login failed with code "some_error"`,
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid code",
|
||||
query: "state=test-state&code=invalid",
|
||||
wantErr: "could not complete code exchange: oauth2: cannot fetch token: 403 Forbidden\nResponse: invalid authorization code\n",
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "missing ID token",
|
||||
query: "state=test-state&code=valid",
|
||||
returnIDTok: "",
|
||||
wantErr: "received response missing ID token",
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid ID token",
|
||||
query: "state=test-state&code=valid",
|
||||
returnIDTok: "invalid-jwt",
|
||||
wantErr: "received invalid ID token: oidc: malformed jwt: square/go-jose: compact JWS format must have three parts",
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid access token hash",
|
||||
query: "state=test-state&code=valid",
|
||||
|
||||
// Test JWT generated with https://smallstep.com/docs/cli/crypto/jwt/:
|
||||
// step crypto keypair key.pub key.priv --kty RSA --no-password --insecure --force && echo '{"at_hash": "invalid-at-hash"}' | step crypto jwt sign --key key.priv --aud test-client-id --sub test-user --subtle --kid="test-kid" --jti="test-jti"
|
||||
returnIDTok: "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJhdF9oYXNoIjoiaW52YWxpZC1hdC1oYXNoIiwiYXVkIjoidGVzdC1jbGllbnQtaWQiLCJpYXQiOjE2MDIyODM3OTEsImp0aSI6InRlc3QtanRpIiwibmJmIjoxNjAyMjgzNzkxLCJzdWIiOiJ0ZXN0LXVzZXIifQ.jryXr4jiwcf79wBLaHpjdclEYHoUFGhvTu95QyA6Hnk9NQ0x1vsWYurtj7a8uKydNPryC_HNZi9QTAE_tRIJjycseog3695-5y4B4EZlqL-a94rdOtffuF2O_lnPbKvoja9EKNrp0kLBCftFRHhLAEwuP0N9E5padZwPpIGK0yE_JqljnYgCySvzsQu7tasR38yaULny13h3mtp2WRHPG5DrLyuBuF8Z01hSgRi5hGcVpgzTwBgV5-eMaSUCUo-ZDkqUsLQI6dVlaikCSKYZRb53HeexH0tB_R9PJJHY7mIr-rS76kkQEx9pLuVnheIH9Oc6zbdYWg-zWMijopA8Pg",
|
||||
|
||||
wantErr: "received invalid ID token: access token hash does not match value in ID token",
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
name: "invalid nonce",
|
||||
query: "state=test-state&code=valid",
|
||||
|
||||
// Test JWT generated with https://smallstep.com/docs/cli/crypto/jwt/:
|
||||
// step crypto keypair key.pub key.priv --kty RSA --no-password --insecure --force && echo '{"nonce": "invalid-nonce"}' | step crypto jwt sign --key key.priv --aud test-client-id --sub test-user --subtle --kid="test-kid" --jti="test-jti"
|
||||
returnIDTok: "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImlhdCI6MTYwMjI4Mzc0MSwianRpIjoidGVzdC1qdGkiLCJuYmYiOjE2MDIyODM3NDEsIm5vbmNlIjoiaW52YWxpZC1ub25jZSIsInN1YiI6InRlc3QtdXNlciJ9.PRpq-7j5djaIAkraL-8t8ad9Xm4hM8RW67gyD1VIe0BecWeBFxsTuh3SZVKM9zmcwTgjudsyn8kQOwipDa49IN4PV8FcJA_uUJZi2wiqGJUSTG2K5I89doV_7e0RM1ZYIDDW1G2heKJNW7MbKkX7iEPr7u4MyEzswcPcupbyDA-CQFeL95vgwawoqa6yO94ympTbozqiNfj6Xyw_nHtThQnstjWsJZ9s2mUgppZezZv4HZYTQ7c3e_bzwhWgCzh2CSDJn9_Ra_n_4GcVkpHbsHTP35dFsnf0vactPx6CAu6A1-Apk-BruCktpZ3B4Ercf1UnUOHdGqzQKJtqvB03xQ",
|
||||
|
||||
wantHTTPStatus: http.StatusBadRequest,
|
||||
wantErr: `received ID token with invalid nonce: invalid nonce (expected "test-nonce", got "invalid-nonce")`,
|
||||
},
|
||||
{
|
||||
name: "valid",
|
||||
query: "state=test-state&code=valid",
|
||||
|
||||
// Test JWT generated with https://smallstep.com/docs/cli/crypto/jwt/:
|
||||
// step crypto keypair key.pub key.priv --kty RSA --no-password --insecure --force && echo '{"nonce": "test-nonce"}' | step crypto jwt sign --key key.priv --aud test-client-id --sub test-user --subtle --kid="test-kid" --jti="test-jti"
|
||||
returnIDTok: "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJhdWQiOiJ0ZXN0LWNsaWVudC1pZCIsImlhdCI6MTYwMjUzMTU2NywianRpIjoidGVzdC1qdGkiLCJuYmYiOjE2MDI1MzE1NjcsIm5vbmNlIjoidGVzdC1ub25jZSIsInN1YiI6InRlc3QtdXNlciJ9.LbOA31iwJZBM4ayY5Oud-HArLXbmtAIhZv_LazDqbzA2Iw87RxoBemfiPUJeAesdnO1LKSjBwbltZwtjvbLWHp1R5tqrSMr_hl2OyZv1cpEX-9QaTcQILJ5qR00riRLz34ZCQFyF-FfQpP1r4dNqFrxHuiBwKuPE7zogc83ZYJgAQM5Fao9rIRY9JStL_3pURa9JnnSHFlkLvFYv3TKEUyvnW4pWvYZcsGI7mys43vuSjpG7ZSrW3vCxovuIpXYqAhamZL_XexWUsXvi3ej9HNlhnhOFhN4fuPSc0PWDWaN0CLWmoo8gvOdQWo5A4GD4bNGBzjYOd-pYqsDfseRt1Q",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tokenServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
require.NoError(t, r.ParseForm())
|
||||
require.Equal(t, "test-client-id", r.Form.Get("client_id"))
|
||||
require.Equal(t, "test-pkce", r.Form.Get("code_verifier"))
|
||||
require.Equal(t, "authorization_code", r.Form.Get("grant_type"))
|
||||
require.NotEmpty(t, r.Form.Get("code"))
|
||||
if r.Form.Get("code") != "valid" {
|
||||
http.Error(w, "invalid authorization code", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
var response struct {
|
||||
oauth2.Token
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
}
|
||||
response.AccessToken = "test-access-token"
|
||||
response.Expiry = time.Now().Add(time.Hour)
|
||||
response.IDToken = tt.returnIDTok
|
||||
w.Header().Set("content-type", "application/json")
|
||||
require.NoError(t, json.NewEncoder(w).Encode(&response))
|
||||
}))
|
||||
t.Cleanup(tokenServer.Close)
|
||||
|
||||
h := &handlerState{
|
||||
callbacks: make(chan callbackResult, 1),
|
||||
state: state.State("test-state"),
|
||||
pkce: pkce.Code("test-pkce"),
|
||||
nonce: nonce.Nonce("test-nonce"),
|
||||
oauth2Config: &oauth2.Config{
|
||||
ClientID: "test-client-id",
|
||||
RedirectURL: "http://localhost:12345/callback",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
TokenURL: tokenServer.URL,
|
||||
AuthStyle: oauth2.AuthStyleInParams,
|
||||
},
|
||||
},
|
||||
idTokenVerifier: mockVerifier(),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp := httptest.NewRecorder()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", "/test-callback", nil)
|
||||
require.NoError(t, err)
|
||||
req.URL.RawQuery = tt.query
|
||||
if tt.method != "" {
|
||||
req.Method = tt.method
|
||||
}
|
||||
|
||||
err = h.handleAuthCodeCallback(resp, req)
|
||||
if tt.wantErr != "" {
|
||||
require.EqualError(t, err, tt.wantErr)
|
||||
if tt.wantHTTPStatus != 0 {
|
||||
rec := httptest.NewRecorder()
|
||||
err.(httperr.Responder).Respond(rec)
|
||||
require.Equal(t, tt.wantHTTPStatus, rec.Code)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(1 * time.Second):
|
||||
require.Fail(t, "timed out waiting to receive from callbacks channel")
|
||||
case result := <-h.callbacks:
|
||||
if tt.wantErr != "" {
|
||||
require.EqualError(t, result.err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, result.err)
|
||||
require.NotNil(t, result.token)
|
||||
require.Equal(t, result.token.IDToken.Token, tt.returnIDTok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// mockVerifier returns an *oidc.IDTokenVerifier that validates any correctly serialized JWT without doing much else.
|
||||
func mockVerifier() *oidc.IDTokenVerifier {
|
||||
mockKeySet := mockkeyset.NewMockKeySet(gomock.NewController(nil))
|
||||
mockKeySet.EXPECT().VerifySignature(gomock.Any(), gomock.Any()).
|
||||
AnyTimes().
|
||||
DoAndReturn(func(ctx context.Context, jwt string) ([]byte, error) {
|
||||
jws, err := jose.ParseSigned(jwt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return jws.UnsafePayloadWithoutVerification(), nil
|
||||
})
|
||||
|
||||
return oidc.NewVerifier("", mockKeySet, &oidc.Config{
|
||||
SkipIssuerCheck: true,
|
||||
SkipExpiryCheck: true,
|
||||
SkipClientIDCheck: true,
|
||||
})
|
||||
}
|
||||
|
||||
type mockDiscovery struct{ provider *oidc.Provider }
|
||||
|
||||
func (m *mockDiscovery) Endpoint() oauth2.Endpoint { return m.provider.Endpoint() }
|
||||
|
||||
func (m *mockDiscovery) Verifier(config *oidc.Config) *oidc.IDTokenVerifier { return mockVerifier() }
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package nonce implements
|
||||
package nonce
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Generate generates a new random OIDC nonce parameter of an appropriate size.
|
||||
func Generate() (Nonce, error) { return generate(rand.Reader) }
|
||||
|
||||
func generate(rand io.Reader) (Nonce, error) {
|
||||
var buf [16]byte
|
||||
if _, err := io.ReadFull(rand, buf[:]); err != nil {
|
||||
return "", fmt.Errorf("could not generate random nonce: %w", err)
|
||||
}
|
||||
return Nonce(hex.EncodeToString(buf[:])), nil
|
||||
}
|
||||
|
||||
// Nonce implements some utilities for working with OIDC nonce parameters.
|
||||
type Nonce string
|
||||
|
||||
// String returns the string encoding of this state value.
|
||||
func (n *Nonce) String() string {
|
||||
return string(*n)
|
||||
}
|
||||
|
||||
// Param returns the OAuth2 auth code parameter for sending the nonce during the authorization request.
|
||||
func (n *Nonce) Param() oauth2.AuthCodeOption {
|
||||
return oidc.Nonce(string(*n))
|
||||
}
|
||||
|
||||
// Validate the returned ID token). Returns true iff the nonce matches or the returned JWT does not have a nonce.
|
||||
func (n *Nonce) Validate(token *oidc.IDToken) error {
|
||||
if subtle.ConstantTimeCompare([]byte(token.Nonce), []byte(*n)) != 1 {
|
||||
return InvalidNonceError{Expected: *n, Got: Nonce(token.Nonce)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidNonceError is returned by Validate when the observed nonce is invalid.
|
||||
type InvalidNonceError struct {
|
||||
Expected Nonce
|
||||
Got Nonce
|
||||
}
|
||||
|
||||
func (e InvalidNonceError) Error() string {
|
||||
return fmt.Sprintf("invalid nonce (expected %q, got %q)", e.Expected.String(), e.Got.String())
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package nonce
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/coreos/go-oidc"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestNonce(t *testing.T) {
|
||||
n, err := Generate()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, n, 32)
|
||||
require.Len(t, n.String(), 32)
|
||||
|
||||
cfg := oauth2.Config{}
|
||||
authCodeURL, err := url.Parse(cfg.AuthCodeURL("", n.Param()))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, n.String(), authCodeURL.Query().Get("nonce"))
|
||||
|
||||
require.Error(t, n.Validate(&oidc.IDToken{}))
|
||||
require.NoError(t, n.Validate(&oidc.IDToken{Nonce: string(n)}))
|
||||
|
||||
err = n.Validate(&oidc.IDToken{Nonce: string(n) + "x"})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.As(err, &InvalidNonceError{}))
|
||||
require.Contains(t, err.Error(), string(n)+"x")
|
||||
|
||||
var empty bytes.Buffer
|
||||
n, err = generate(&empty)
|
||||
require.EqualError(t, err, "could not generate random nonce: EOF")
|
||||
require.Empty(t, n)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pkce
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// Generate generates a new random PKCE code.
|
||||
func Generate() (Code, error) { return generate(rand.Reader) }
|
||||
|
||||
func generate(rand io.Reader) (Code, error) {
|
||||
// From https://tools.ietf.org/html/rfc7636#section-4.1:
|
||||
// code_verifier = high-entropy cryptographic random STRING using the
|
||||
// unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
|
||||
// from Section 2.3 of [RFC3986], with a minimum length of 43 characters
|
||||
// and a maximum length of 128 characters.
|
||||
var buf [32]byte
|
||||
if _, err := io.ReadFull(rand, buf[:]); err != nil {
|
||||
return "", fmt.Errorf("could not generate PKCE code: %w", err)
|
||||
}
|
||||
return Code(hex.EncodeToString(buf[:])), nil
|
||||
}
|
||||
|
||||
// Code implements the basic options required for RFC 7636: Proof Key for Code Exchange (PKCE).
|
||||
type Code string
|
||||
|
||||
// Challenge returns the OAuth2 auth code parameter for sending the PKCE code challenge.
|
||||
func (p *Code) Challenge() oauth2.AuthCodeOption {
|
||||
b := sha256.Sum256([]byte(*p))
|
||||
return oauth2.SetAuthURLParam("code_challenge", base64.RawURLEncoding.EncodeToString(b[:]))
|
||||
}
|
||||
|
||||
// Method returns the OAuth2 auth code parameter for sending the PKCE code challenge method.
|
||||
func (p *Code) Method() oauth2.AuthCodeOption {
|
||||
return oauth2.SetAuthURLParam("code_challenge_method", "S256")
|
||||
}
|
||||
|
||||
// Verifier returns the OAuth2 auth code parameter for sending the PKCE code verifier.
|
||||
func (p *Code) Verifier() oauth2.AuthCodeOption {
|
||||
return oauth2.SetAuthURLParam("code_verifier", string(*p))
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package pkce
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPKCE(t *testing.T) {
|
||||
p, err := Generate()
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := oauth2.Config{}
|
||||
authCodeURL, err := url.Parse(cfg.AuthCodeURL("", p.Challenge(), p.Method()))
|
||||
require.NoError(t, err)
|
||||
|
||||
// The code_challenge must be 256 bits (sha256) encoded as unpadded urlsafe base64.
|
||||
chal, err := base64.RawURLEncoding.DecodeString(authCodeURL.Query().Get("code_challenge"))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, chal, 32)
|
||||
|
||||
// The code_challenge_method must be a fixed value.
|
||||
require.Equal(t, "S256", authCodeURL.Query().Get("code_challenge_method"))
|
||||
|
||||
// The code_verifier param should be 64 hex characters.
|
||||
verifyURL, err := url.Parse(cfg.AuthCodeURL("", p.Verifier()))
|
||||
require.NoError(t, err)
|
||||
require.Regexp(t, `\A[0-9a-f]{64}\z`, verifyURL.Query().Get("code_verifier"))
|
||||
|
||||
var empty bytes.Buffer
|
||||
p, err = generate(&empty)
|
||||
require.EqualError(t, err, "could not generate PKCE code: EOF")
|
||||
require.Empty(t, p)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Generate generates a new random state parameter of an appropriate size.
|
||||
func Generate() (State, error) { return generate(rand.Reader) }
|
||||
|
||||
func generate(rand io.Reader) (State, error) {
|
||||
// From https://tools.ietf.org/html/rfc6749#section-10.12:
|
||||
// The binding value used for CSRF
|
||||
// protection MUST contain a non-guessable value (as described in
|
||||
// Section 10.10), and the user-agent's authenticated state (e.g.,
|
||||
// session cookie, HTML5 local storage) MUST be kept in a location
|
||||
// accessible only to the client and the user-agent (i.e., protected by
|
||||
// same-origin policy).
|
||||
var buf [16]byte
|
||||
if _, err := io.ReadFull(rand, buf[:]); err != nil {
|
||||
return "", fmt.Errorf("could not generate random state: %w", err)
|
||||
}
|
||||
return State(hex.EncodeToString(buf[:])), nil
|
||||
}
|
||||
|
||||
// State implements some utilities for working with OAuth2 state parameters.
|
||||
type State string
|
||||
|
||||
// String returns the string encoding of this state value.
|
||||
func (s *State) String() string {
|
||||
return string(*s)
|
||||
}
|
||||
|
||||
// Validate the returned state (from a callback parameter).
|
||||
func (s *State) Validate(returnedState string) error {
|
||||
if subtle.ConstantTimeCompare([]byte(returnedState), []byte(*s)) != 1 {
|
||||
return InvalidStateError{Expected: *s, Got: State(returnedState)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InvalidStateError is returned by Validate when the returned state is invalid.
|
||||
type InvalidStateError struct {
|
||||
Expected State
|
||||
Got State
|
||||
}
|
||||
|
||||
func (e InvalidStateError) Error() string {
|
||||
return fmt.Sprintf("invalid state (expected %q, got %q)", e.Expected.String(), e.Got.String())
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package state
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestState(t *testing.T) {
|
||||
s, err := Generate()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, s, 32)
|
||||
require.Len(t, s.String(), 32)
|
||||
require.NoError(t, s.Validate(string(s)))
|
||||
err = s.Validate(string(s) + "x")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.As(err, &InvalidStateError{}))
|
||||
require.Contains(t, err.Error(), string(s)+"x")
|
||||
|
||||
var empty bytes.Buffer
|
||||
s, err = generate(&empty)
|
||||
require.EqualError(t, err, "could not generate random state: EOF")
|
||||
require.Empty(t, s)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package oidcclient
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// AccessToken is an OAuth2 access token.
|
||||
type AccessToken struct {
|
||||
// Token is the token that authorizes and authenticates the requests.
|
||||
Token string `json:"token"`
|
||||
|
||||
// Type is the type of token.
|
||||
Type string `json:"type,omitempty"`
|
||||
|
||||
// Expiry is the optional expiration time of the access token.
|
||||
Expiry metav1.Time `json:"expiryTimestamp,omitempty"`
|
||||
}
|
||||
|
||||
// RefreshToken is an OAuth2 refresh token.
|
||||
type RefreshToken struct {
|
||||
// Token is a token that's used by the application (as opposed to the user) to refresh the access token if it expires.
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// IDToken is an OpenID Connect ID token.
|
||||
type IDToken struct {
|
||||
// Token is an OpenID Connect ID token.
|
||||
Token string `json:"token"`
|
||||
|
||||
// Expiry is the optional expiration time of the ID token.
|
||||
Expiry metav1.Time `json:"expiryTimestamp,omitempty"`
|
||||
}
|
||||
|
||||
// Token contains the elements of an OIDC session.
|
||||
type Token struct {
|
||||
// AccessToken is the token that authorizes and authenticates the requests.
|
||||
AccessToken *AccessToken `json:"access,omitempty"`
|
||||
|
||||
// RefreshToken is a token that's used by the application
|
||||
// (as opposed to the user) to refresh the access token
|
||||
// if it expires.
|
||||
RefreshToken *RefreshToken `json:"refresh,omitempty"`
|
||||
|
||||
// IDToken is an OpenID Connect ID token.
|
||||
IDToken *IDToken `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// SessionCacheKey contains the data used to select a valid session cache entry.
|
||||
type SessionCacheKey struct {
|
||||
Issuer string `json:"issuer"`
|
||||
ClientID string `json:"clientID"`
|
||||
Scopes []string `json:"scopes"`
|
||||
RedirectURI string `json:"redirect_uri"`
|
||||
}
|
||||
|
||||
type SessionCache interface {
|
||||
GetToken(SessionCacheKey) *Token
|
||||
PutToken(SessionCacheKey, *Token)
|
||||
}
|
||||
Reference in New Issue
Block a user