From e0ecdc004b6aa062def800dff1e785a7c5ff5bdc Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 14 Jul 2022 09:51:11 -0700 Subject: [PATCH 1/8] Allow dynamic clients to be used in downstream OIDC flows This is only a first commit towards making this feature work. - Hook dynamic clients into fosite by returning them from the storage interface (after finding and validating them) - In the auth endpoint, prevent the use of the username and password headers for dynamic clients to force them to use the browser-based login flows for all the upstream types - Add happy path integration tests in supervisor_login_test.go - Add lots of comments (and some small refactors) in supervisor_login_test.go to make it much easier to understand - Add lots of unit tests for the auth endpoint regarding dynamic clients (more unit tests to be added for other endpoints in follow-up commits) - Enhance crud.go to make lifetime=0 mean never garbage collect, since we want client secret storage Secrets to last forever - Move the OIDCClient validation code to a package where it can be shared between the controller and the fosite storage interface - Make shared test helpers for tests that need to create OIDC client secret storage Secrets - Create a public const for "pinniped-cli" now that we are using that string in several places in the production code --- .../oidcclientwatcher/oidc_client_watcher.go | 211 +------ .../oidc_client_watcher_test.go | 105 +--- internal/crud/crud.go | 11 +- internal/crud/crud_test.go | 69 ++- internal/oidc/auth/auth_handler.go | 22 +- internal/oidc/auth/auth_handler_test.go | 535 +++++++++++++++++- internal/oidc/callback/callback_handler.go | 4 +- .../oidc/callback/callback_handler_test.go | 4 +- .../oidc/clientregistry/clientregistry.go | 158 +++++- .../clientregistry/clientregistry_test.go | 242 +++++++- internal/oidc/kube_storage.go | 6 +- internal/oidc/login/post_login_handler.go | 2 + .../oidc/login/post_login_handler_test.go | 4 +- internal/oidc/nullstorage.go | 13 +- .../oidcclientvalidator.go | 235 ++++++++ internal/oidc/provider/manager/manager.go | 20 +- .../oidc/provider/manager/manager_test.go | 7 +- internal/oidc/token/token_handler_test.go | 4 +- internal/oidc/token_exchange.go | 6 +- .../oidcclientsecretstorage.go | 27 +- .../oidcclientsecretstorage_test.go | 27 + internal/supervisor/server/server.go | 1 + internal/testutil/oidcclientsecretstorage.go | 51 ++ test/integration/supervisor_login_test.go | 241 ++++++-- .../supervisor_oidc_client_test.go | 22 +- test/integration/supervisor_warnings_test.go | 16 +- test/testlib/activedirectory.go | 14 +- test/testlib/client.go | 96 +++- 28 files changed, 1692 insertions(+), 461 deletions(-) create mode 100644 internal/oidc/oidcclientvalidator/oidcclientvalidator.go create mode 100644 internal/testutil/oidcclientsecretstorage.go diff --git a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go index eb6ab992..12123731 100644 --- a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go +++ b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go @@ -8,9 +8,6 @@ import ( "fmt" "strings" - "github.com/coreos/go-oidc/v3/oidc" - "golang.org/x/crypto/bcrypt" - v1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -23,37 +20,14 @@ import ( pinnipedcontroller "go.pinniped.dev/internal/controller" "go.pinniped.dev/internal/controller/conditionsutil" "go.pinniped.dev/internal/controllerlib" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/oidcclientsecretstorage" "go.pinniped.dev/internal/plog" ) const ( - clientSecretExists = "ClientSecretExists" - allowedGrantTypesValid = "AllowedGrantTypesValid" - allowedScopesValid = "AllowedScopesValid" - - reasonSuccess = "Success" - reasonMissingRequiredValue = "MissingRequiredValue" - reasonNoClientSecretFound = "NoClientSecretFound" - reasonInvalidClientSecretFound = "InvalidClientSecretFound" - - authorizationCodeGrantTypeName = "authorization_code" - refreshTokenGrantTypeName = "refresh_token" - tokenExchangeGrantTypeName = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint:gosec // this is not a credential - - openidScopeName = oidc.ScopeOpenID - offlineAccessScopeName = oidc.ScopeOfflineAccess - requestAudienceScopeName = "pinniped:request-audience" - usernameScopeName = "username" - groupsScopeName = "groups" - - allowedGrantTypesFieldName = "allowedGrantTypes" - allowedScopesFieldName = "allowedScopes" - secretTypeToObserve = "storage.pinniped.dev/oidc-client-secret" //nolint:gosec // this is not a credential oidcClientPrefixToObserve = "client.oauth.pinniped.dev-" //nolint:gosec // this is not a credential - - minimumRequiredBcryptCost = 15 ) type oidcClientWatcherController struct { @@ -133,9 +107,9 @@ func (c *oidcClientWatcherController) Sync(ctx controllerlib.Context) error { secret = nil } - conditions, totalClientSecrets := validateOIDCClient(oidcClient, secret) + _, conditions, clientSecrets := oidcclientvalidator.Validate(oidcClient, secret) - if err := c.updateStatus(ctx.Context, oidcClient, conditions, totalClientSecrets); err != nil { + if err := c.updateStatus(ctx.Context, oidcClient, conditions, len(clientSecrets)); err != nil { return fmt.Errorf("cannot update OIDCClient '%s/%s': %w", oidcClient.Namespace, oidcClient.Name, err) } @@ -150,185 +124,6 @@ func (c *oidcClientWatcherController) Sync(ctx controllerlib.Context) error { return nil } -// validateOIDCClient validates the OIDCClient and its corresponding client secret storage Secret. -// When the corresponding client secret storage Secret was not found, pass nil to this function to -// get the validation error for that case. It returns a slice of conditions along with the number -// of client secrets found. -func validateOIDCClient(oidcClient *v1alpha1.OIDCClient, secret *v1.Secret) ([]*v1alpha1.Condition, int) { - c, totalClientSecrets := validateSecret(secret, make([]*v1alpha1.Condition, 0, 3)) - c = validateAllowedGrantTypes(oidcClient, c) - c = validateAllowedScopes(oidcClient, c) - return c, totalClientSecrets -} - -// validateAllowedScopes checks if allowedScopes is valid on the OIDCClient. -func validateAllowedScopes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition { - m := make([]string, 0, 4) - - if !allowedScopesContains(oidcClient, openidScopeName) { - m = append(m, fmt.Sprintf("%q must always be included in %q", openidScopeName, allowedScopesFieldName)) - } - if allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) && !allowedScopesContains(oidcClient, offlineAccessScopeName) { - m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q", - offlineAccessScopeName, allowedScopesFieldName, refreshTokenGrantTypeName, allowedGrantTypesFieldName)) - } - if allowedScopesContains(oidcClient, requestAudienceScopeName) && - (!allowedScopesContains(oidcClient, usernameScopeName) || !allowedScopesContains(oidcClient, groupsScopeName)) { - m = append(m, fmt.Sprintf("%q and %q must be included in %q when %q is included in %q", - usernameScopeName, groupsScopeName, allowedScopesFieldName, requestAudienceScopeName, allowedScopesFieldName)) - } - if allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) && !allowedScopesContains(oidcClient, requestAudienceScopeName) { - m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q", - requestAudienceScopeName, allowedScopesFieldName, tokenExchangeGrantTypeName, allowedGrantTypesFieldName)) - } - - if len(m) == 0 { - conditions = append(conditions, &v1alpha1.Condition{ - Type: allowedScopesValid, - Status: v1alpha1.ConditionTrue, - Reason: reasonSuccess, - Message: fmt.Sprintf("%q is valid", allowedScopesFieldName), - }) - } else { - conditions = append(conditions, &v1alpha1.Condition{ - Type: allowedScopesValid, - Status: v1alpha1.ConditionFalse, - Reason: reasonMissingRequiredValue, - Message: strings.Join(m, "; "), - }) - } - - return conditions -} - -// validateAllowedGrantTypes checks if allowedGrantTypes is valid on the OIDCClient. -func validateAllowedGrantTypes(oidcClient *v1alpha1.OIDCClient, conditions []*v1alpha1.Condition) []*v1alpha1.Condition { - m := make([]string, 0, 3) - - if !allowedGrantTypesContains(oidcClient, authorizationCodeGrantTypeName) { - m = append(m, fmt.Sprintf("%q must always be included in %q", - authorizationCodeGrantTypeName, allowedGrantTypesFieldName)) - } - if allowedScopesContains(oidcClient, offlineAccessScopeName) && !allowedGrantTypesContains(oidcClient, refreshTokenGrantTypeName) { - m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q", - refreshTokenGrantTypeName, allowedGrantTypesFieldName, offlineAccessScopeName, allowedScopesFieldName)) - } - if allowedScopesContains(oidcClient, requestAudienceScopeName) && !allowedGrantTypesContains(oidcClient, tokenExchangeGrantTypeName) { - m = append(m, fmt.Sprintf("%q must be included in %q when %q is included in %q", - tokenExchangeGrantTypeName, allowedGrantTypesFieldName, requestAudienceScopeName, allowedScopesFieldName)) - } - - if len(m) == 0 { - conditions = append(conditions, &v1alpha1.Condition{ - Type: allowedGrantTypesValid, - Status: v1alpha1.ConditionTrue, - Reason: reasonSuccess, - Message: fmt.Sprintf("%q is valid", allowedGrantTypesFieldName), - }) - } else { - conditions = append(conditions, &v1alpha1.Condition{ - Type: allowedGrantTypesValid, - Status: v1alpha1.ConditionFalse, - Reason: reasonMissingRequiredValue, - Message: strings.Join(m, "; "), - }) - } - - return conditions -} - -// validateSecret checks if the client secret storage Secret is valid and contains at least one client secret. -// It returns the updated conditions slice along with the number of client secrets found. -func validateSecret(secret *v1.Secret, conditions []*v1alpha1.Condition) ([]*v1alpha1.Condition, int) { - if secret == nil { - // Invalid: no storage Secret found. - conditions = append(conditions, &v1alpha1.Condition{ - Type: clientSecretExists, - Status: v1alpha1.ConditionFalse, - Reason: reasonNoClientSecretFound, - Message: "no client secret found (no Secret storage found)", - }) - return conditions, 0 - } - - storedClientSecret, err := oidcclientsecretstorage.ReadFromSecret(secret) - if err != nil { - // Invalid: storage Secret exists but its data could not be parsed. - conditions = append(conditions, &v1alpha1.Condition{ - Type: clientSecretExists, - Status: v1alpha1.ConditionFalse, - Reason: reasonNoClientSecretFound, - Message: fmt.Sprintf("error reading client secret storage: %s", err.Error()), - }) - return conditions, 0 - } - - // Successfully read the stored client secrets, so check if there are any stored in the list. - storedClientSecretsCount := len(storedClientSecret.SecretHashes) - if storedClientSecretsCount == 0 { - // Invalid: no client secrets stored. - conditions = append(conditions, &v1alpha1.Condition{ - Type: clientSecretExists, - Status: v1alpha1.ConditionFalse, - Reason: reasonNoClientSecretFound, - Message: "no client secret found (empty list in storage)", - }) - return conditions, 0 - } - - // Check each hashed password's format and bcrypt cost. - bcryptErrs := make([]string, 0, storedClientSecretsCount) - for i, p := range storedClientSecret.SecretHashes { - cost, err := bcrypt.Cost([]byte(p)) - if err != nil { - bcryptErrs = append(bcryptErrs, fmt.Sprintf( - "hashed client secret at index %d: %s", - i, err.Error())) - } else if cost < minimumRequiredBcryptCost { - bcryptErrs = append(bcryptErrs, fmt.Sprintf( - "hashed client secret at index %d: bcrypt cost %d is below the required minimum of %d", - i, cost, minimumRequiredBcryptCost)) - } - } - if len(bcryptErrs) > 0 { - // Invalid: some stored client secrets were not valid. - conditions = append(conditions, &v1alpha1.Condition{ - Type: clientSecretExists, - Status: v1alpha1.ConditionFalse, - Reason: reasonInvalidClientSecretFound, - Message: strings.Join(bcryptErrs, "; "), - }) - return conditions, storedClientSecretsCount - } - - // Valid: has at least one client secret stored for this OIDC client, and all stored client secrets are valid. - conditions = append(conditions, &v1alpha1.Condition{ - Type: clientSecretExists, - Status: v1alpha1.ConditionTrue, - Reason: reasonSuccess, - Message: fmt.Sprintf("%d client secret(s) found", storedClientSecretsCount), - }) - return conditions, storedClientSecretsCount -} - -func allowedGrantTypesContains(haystack *v1alpha1.OIDCClient, needle string) bool { - for _, hay := range haystack.Spec.AllowedGrantTypes { - if hay == v1alpha1.GrantType(needle) { - return true - } - } - return false -} - -func allowedScopesContains(haystack *v1alpha1.OIDCClient, needle string) bool { - for _, hay := range haystack.Spec.AllowedScopes { - if hay == v1alpha1.Scope(needle) { - return true - } - } - return false -} - func (c *oidcClientWatcherController) updateStatus( ctx context.Context, upstream *v1alpha1.OIDCClient, diff --git a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go index 92a0d358..b1d147fe 100644 --- a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go +++ b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go @@ -5,9 +5,7 @@ package oidcclientwatcher import ( "context" - "encoding/base32" "fmt" - "strings" "testing" "time" @@ -257,51 +255,6 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { } } - secretNameForUID := func(uid string) string { - // See GetName() in OIDCClientSecretStorage for how the production code determines the Secret name. - // This test helper is intended to choose the same name. - return "pinniped-storage-oidc-client-secret-" + - strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(uid))) - } - - secretStringDataWithZeroClientSecrets := map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"1","hashes":[]}`), - "pinniped-storage-version": []byte("1"), - } - - secretStringDataWithOneClientSecret := map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"1","hashes":["` + testBcryptSecret1 + `"]}`), - "pinniped-storage-version": []byte("1"), - } - - secretStringDataWithTwoClientSecrets := map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"1","hashes":["` + testBcryptSecret1 + `","` + testBcryptSecret2 + `"]}`), - "pinniped-storage-version": []byte("1"), - } - - secretStringDataWithSomeInvalidClientSecrets := map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"1","hashes":["` + - testBcryptSecret1 + `","` + testInvalidBcryptSecretCostTooLow + `","` + testInvalidBcryptSecretInvalidFormat + `"]}`), - "pinniped-storage-version": []byte("1"), - } - - secretStringDataWithWrongVersion := map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"wrong-version","hashes":[]}`), - "pinniped-storage-version": []byte("1"), - } - - storageSecretForUIDWithData := func(uid string, data map[string][]byte) *corev1.Secret { - return &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: testNamespace, - Name: secretNameForUID(uid), - Labels: map[string]string{"storage.pinniped.dev/type": "oidc-client-secret"}, - }, - Type: "storage.pinniped.dev/oidc-client-secret", - Data: data, - } - } - tests := []struct { name string inputObjects []runtime.Object @@ -338,7 +291,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }, }, }, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{ { @@ -367,7 +320,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithTwoClientSecrets)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1, testBcryptSecret2})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -400,7 +353,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { TotalClientSecrets: 1, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 0, // no updates wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -443,7 +396,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithWrongVersion)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUIDWithWrongVersion(t, testNamespace, testUID)}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -466,7 +419,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithZeroClientSecrets)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -490,7 +443,10 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithSomeInvalidClientSecrets)}, + inputSecrets: []runtime.Object{ + testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, + []string{testBcryptSecret1, testInvalidBcryptSecretCostTooLow, testInvalidBcryptSecretInvalidFormat}), + }, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -500,10 +456,11 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { happyAllowedGrantTypesCondition(now, 1234), happyAllowedScopesCondition(now, 1234), sadInvalidClientSecretsCondition(now, 1234, - "hashed client secret at index 1: bcrypt cost 14 is below the required minimum of 15; "+ + "3 stored client secrets found, but some were invalid, so none will be used: "+ + "hashed client secret at index 1: bcrypt cost 14 is below the required minimum of 15; "+ "hashed client secret at index 2: crypto/bcrypt: hashedSecret too short to be a bcrypted password"), }, - TotalClientSecrets: 3, + TotalClientSecrets: 0, }, }}, }, @@ -522,7 +479,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { Spec: configv1alpha1.OIDCClientSpec{}, }, }, - inputSecrets: []runtime.Object{storageSecretForUIDWithData("uid1", secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, "uid1", []string{testBcryptSecret1})}, wantAPIActions: 2, // one update for each OIDCClient wantResultingOIDCClients: []configv1alpha1.OIDCClient{ { @@ -570,7 +527,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { TotalClientSecrets: 1, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 4567, UID: testUID}, @@ -596,7 +553,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }, }}, wantAPIActions: 1, // one update - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Status: configv1alpha1.OIDCClientStatus{ @@ -620,7 +577,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }, }}, wantAPIActions: 1, // one update - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Status: configv1alpha1.OIDCClientStatus{ @@ -649,7 +606,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }, }}, wantAPIActions: 1, // one update - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Status: configv1alpha1.OIDCClientStatus{ @@ -676,7 +633,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "username", "groups"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -700,7 +657,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -724,7 +681,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -748,7 +705,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "groups"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -772,7 +729,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "username"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -796,7 +753,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -820,7 +777,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -844,7 +801,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -868,7 +825,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -892,7 +849,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "groups"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -916,7 +873,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username", "groups"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -940,7 +897,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "username"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -964,7 +921,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "username"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -988,7 +945,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "username", "groups"}, }, }}, - inputSecrets: []runtime.Object{storageSecretForUIDWithData(testUID, secretStringDataWithOneClientSecret)}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, diff --git a/internal/crud/crud.go b/internal/crud/crud.go index 29ad6b65..2d33959a 100644 --- a/internal/crud/crud.go +++ b/internal/crud/crud.go @@ -193,14 +193,19 @@ func (s *secretsStorage) toSecret(signature, resourceVersion string, data JSON, labelsToAdd[labelName] = labelValue } + var annotations map[string]string + if s.lifetime > 0 { + annotations = map[string]string{ + SecretLifetimeAnnotationKey: s.clock().Add(s.lifetime).UTC().Format(SecretLifetimeAnnotationDateFormat), + } + } + return &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: s.GetName(signature), ResourceVersion: resourceVersion, Labels: labelsToAdd, - Annotations: map[string]string{ - SecretLifetimeAnnotationKey: s.clock().Add(s.lifetime).UTC().Format(SecretLifetimeAnnotationDateFormat), - }, + Annotations: annotations, OwnerReferences: nil, }, Data: map[string][]byte{ diff --git a/internal/crud/crud_test.go b/internal/crud/crud_test.go index 61720a0f..25ffdfad 100644 --- a/internal/crud/crud_test.go +++ b/internal/crud/crud_test.go @@ -62,6 +62,7 @@ func TestStorage(t *testing.T) { name string resource string mocks func(*testing.T, mocker) + lifetime func() time.Duration run func(*testing.T, Storage, *clocktesting.FakeClock) error wantActions []coretesting.Action wantSecrets []corev1.Secret @@ -1014,7 +1015,69 @@ func TestStorage(t *testing.T) { }, wantErr: "", }, + { + name: "create and get with infinite lifetime when lifetime is specified as zero", + resource: "access-tokens", + mocks: nil, + lifetime: func() time.Duration { return 0 }, // 0 == infinity + run: func(t *testing.T, storage Storage, fakeClock *clocktesting.FakeClock) 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, nil) + 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: "", + // No garbage collection annotation was added. + Labels: map[string]string{ + "storage.pinniped.dev/type": "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: "", + // No garbage collection annotation was added. + Labels: map[string]string{ + "storage.pinniped.dev/type": "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: "", + }, } + for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { @@ -1024,9 +1087,13 @@ func TestStorage(t *testing.T) { if tt.mocks != nil { tt.mocks(t, client) } + useLifetime := lifetime + if tt.lifetime != nil { + useLifetime = tt.lifetime() + } secrets := client.CoreV1().Secrets(namespace) fakeClock := clocktesting.NewFakeClock(fakeNow) - storage := New(tt.resource, secrets, fakeClock.Now, lifetime) + storage := New(tt.resource, secrets, fakeClock.Now, useLifetime) err := tt.run(t, storage, fakeClock) diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index adbbec7c..370f8baa 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -20,6 +20,7 @@ import ( "go.pinniped.dev/internal/httputil/httperr" "go.pinniped.dev/internal/httputil/securityheader" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/clientregistry" "go.pinniped.dev/internal/oidc/csrftoken" "go.pinniped.dev/internal/oidc/downstreamsession" "go.pinniped.dev/internal/oidc/login" @@ -126,6 +127,10 @@ func handleAuthRequestForLDAPUpstreamCLIFlow( return nil } + if !requireStaticClientForUsernameAndPasswordHeaders(w, oauthHelper, authorizeRequester) { + return nil + } + username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) if !hadUsernamePasswordValues { return nil @@ -199,6 +204,10 @@ func handleAuthRequestForOIDCUpstreamPasswordGrant( return nil } + if !requireStaticClientForUsernameAndPasswordHeaders(w, oauthHelper, authorizeRequester) { + return nil + } + username, password, hadUsernamePasswordValues := requireNonEmptyUsernameAndPasswordHeaders(r, w, oauthHelper, authorizeRequester) if !hadUsernamePasswordValues { return nil @@ -312,6 +321,15 @@ func handleAuthRequestForOIDCUpstreamBrowserFlow( return nil } +func requireStaticClientForUsernameAndPasswordHeaders(w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) bool { + isStaticClient := authorizeRequester.GetClient().GetID() == clientregistry.PinnipedCLIClientID + if !isStaticClient { + oidc.WriteAuthorizeError(w, oauthHelper, authorizeRequester, + fosite.ErrAccessDenied.WithHintf("This client is not allowed to submit username or password headers to this endpoint."), true) + } + return isStaticClient +} + func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider, authorizeRequester fosite.AuthorizeRequester) (string, string, bool) { username := r.Header.Get(supervisoroidc.AuthorizeUsernameHeaderName) password := r.Header.Get(supervisoroidc.AuthorizePasswordHeaderName) @@ -330,10 +348,12 @@ func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fos return nil, false } - // Automatically grant the openid, offline_access, and pinniped:request-audience scopes, but only if they were requested. + // Automatically grant the openid, offline_access, pinniped:request-audience, and groups scopes, but only if they were requested. // Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations. // There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope // at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite. + // This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned + // an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here. downstreamsession.GrantScopesIfRequested(authorizeRequester, []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, oidc.RequestAudienceScope, oidc.DownstreamGroupsScope}) return authorizeRequester, true diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 8847d8c4..2cc98471 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -24,8 +24,12 @@ import ( "k8s.io/apiserver/pkg/authentication/user" "k8s.io/client-go/kubernetes/fake" v1 "k8s.io/client-go/kubernetes/typed/core/v1" + kubetesting "k8s.io/client-go/testing" "k8s.io/utils/pointer" + configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" + supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" + "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" "go.pinniped.dev/internal/authenticators" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/oidc" @@ -67,11 +71,16 @@ func TestAuthorizationEndpoint(t *testing.T) { downstreamPKCEChallenge = "some-challenge" downstreamPKCEChallengeMethod = "S256" happyState = "8b-state" - downstreamClientID = "pinniped-cli" upstreamLDAPURL = "ldaps://some-ldap-host:123?base=ou%3Dusers%2Cdc%3Dpinniped%2Cdc%3Ddev" htmlContentType = "text/html; charset=utf-8" jsonContentType = "application/json; charset=utf-8" formContentType = "application/x-www-form-urlencoded" + + pinnipedCLIClientID = "pinniped-cli" + dynamicClientID = "client.oauth.pinniped.dev-test-name" + dynamicClientUID = "fake-client-uid" + //nolint:gosec // this is not a credential + dynamicClientHashedSecret = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password1" at cost 15 ) require.Len(t, happyState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") @@ -177,6 +186,12 @@ func TestAuthorizationEndpoint(t *testing.T) { "state": happyState, } + fositeAccessDeniedWithUsernamePasswordHeadersDisallowedHintErrorQuery = map[string]string{ + "error": "access_denied", + "error_description": "The resource owner or authorization server denied the request. This client is not allowed to submit username or password headers to this endpoint.", + "state": happyState, + } + fositeAccessDeniedWithInvalidEmailVerifiedHintErrorQuery = map[string]string{ "error": "access_denied", "error_description": "The resource owner or authorization server denied the request. Reason: email_verified claim in upstream ID token has invalid format.", @@ -219,16 +234,18 @@ func TestAuthorizationEndpoint(t *testing.T) { jwksProviderIsUnused := jwks.NewDynamicJWKSProvider() timeoutsConfiguration := oidc.DefaultOIDCTimeoutsConfiguration() - createOauthHelperWithRealStorage := func(secretsClient v1.SecretInterface) (fosite.OAuth2Provider, *oidc.KubeStorage) { + createOauthHelperWithRealStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *oidc.KubeStorage) { // Configure fosite the same way that the production code would when using Kube storage. // Inject this into our test subject at the last second so we get a fresh storage for every test. - kubeOauthStore := oidc.NewKubeStorage(secretsClient, timeoutsConfiguration) + kubeOauthStore := oidc.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration) return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), kubeOauthStore } - // Configure fosite the same way that the production code would, using NullStorage to turn off storage. - nullOauthStore := oidc.NullStorage{} - oauthHelperWithNullStorage := oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration) + createOauthHelperWithNullStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *oidc.NullStorage) { + // Configure fosite the same way that the production code would, using NullStorage to turn off storage. + nullOauthStore := oidc.NewNullStorage(secretsClient, oidcClientsClient) + return oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), nullOauthStore + } upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth") require.NoError(t, err) @@ -381,7 +398,7 @@ func TestAuthorizationEndpoint(t *testing.T) { happyGetRequestQueryMap := map[string]string{ "response_type": "code", "scope": strings.Join(happyDownstreamScopesRequested, " "), - "client_id": downstreamClientID, + "client_id": pinnipedCLIClientID, "state": happyState, "nonce": downstreamNonce, "code_challenge": downstreamPKCEChallenge, @@ -494,6 +511,26 @@ func TestAuthorizationEndpoint(t *testing.T) { }, } + fullyCapableDynamicClient := &configv1alpha1.OIDCClient{ + ObjectMeta: metav1.ObjectMeta{Namespace: "some-namespace", Name: dynamicClientID, Generation: 1, UID: dynamicClientUID}, + Spec: configv1alpha1.OIDCClientSpec{ + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + AllowedRedirectURIs: []configv1alpha1.RedirectURI{downstreamRedirectURI}, + }, + } + + allDynamicClientScopes := "openid offline_access pinniped:request-audience username groups" + + storageSecretWithOneClientSecretForDynamicClient := testutil.OIDCClientSecretStorageSecretForUID(t, + "some-namespace", dynamicClientUID, []string{dynamicClientHashedSecret}, + ) + + addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + require.NoError(t, supervisorClient.Tracker().Add(fullyCapableDynamicClient)) + require.NoError(t, kubeClient.Tracker().Add(storageSecretWithOneClientSecretForDynamicClient)) + } + // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it happyAuthcodeDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyState @@ -517,6 +554,7 @@ func TestAuthorizationEndpoint(t *testing.T) { csrfCookie string customUsernameHeader *string // nil means do not send header, empty means send header with empty value customPasswordHeader *string // nil means do not send header, empty means send header with empty value + kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) wantStatus int wantContentType string @@ -540,6 +578,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantDownstreamPKCEChallenge string wantDownstreamPKCEChallengeMethod string wantDownstreamNonce string + wantDownstreamClientID string // defaults to wanting "pinniped-cli" when not set wantUnnecessaryStoredRecords int wantPasswordGrantCall *expectedPasswordGrant wantDownstreamCustomSessionData *psession.CustomSessionData @@ -562,6 +601,24 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "OIDC upstream browser flow happy path using GET without a CSRF cookie using a dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", oidcUpstreamName, "oidc"), nil), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, { name: "LDAP upstream browser flow happy path using GET without a CSRF cookie", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -579,6 +636,24 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "LDAP upstream browser flow happy path using GET without a CSRF cookie using a dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", ldapUpstreamName, "ldap")}), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, { name: "Active Directory upstream browser flow happy path using GET without a CSRF cookie", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), @@ -596,6 +671,24 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "Active Directory upstream browser flow happy path using GET without a CSRF cookie using a dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", activeDirectoryUpstreamName, "activedirectory")}), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, { name: "OIDC upstream password grant happy path using GET", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -730,6 +823,26 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(nil, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, }, + { + name: "OIDC upstream browser flow happy path using POST with a dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodPost, + path: "/some/path", + contentType: formContentType, + body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})), + wantStatus: http.StatusSeeOther, + wantContentType: "", + wantBodyString: "", + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", oidcUpstreamName, "oidc"), nil), + wantUpstreamStateParamInLocationHeader: true, + }, { name: "LDAP upstream browser flow happy path using POST", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), @@ -749,6 +862,26 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", ldapUpstreamName, "ldap")}), wantUpstreamStateParamInLocationHeader: true, }, + { + name: "LDAP upstream browser flow happy path using POST with a dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodPost, + path: "/some/path", + contentType: formContentType, + body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})), + wantStatus: http.StatusSeeOther, + wantContentType: "", + wantBodyString: "", + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", ldapUpstreamName, "ldap")}), + wantUpstreamStateParamInLocationHeader: true, + }, { name: "Active Directory upstream browser flow happy path using POST", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), @@ -768,6 +901,26 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(nil, "", activeDirectoryUpstreamName, "activedirectory")}), wantUpstreamStateParamInLocationHeader: true, }, + { + name: "Active Directory upstream browser flow happy path using POST with a dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodPost, + path: "/some/path", + contentType: formContentType, + body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})), + wantStatus: http.StatusSeeOther, + wantContentType: "", + wantBodyString: "", + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", activeDirectoryUpstreamName, "activedirectory")}), + wantUpstreamStateParamInLocationHeader: true, + }, { name: "OIDC upstream password grant happy path using POST", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -945,6 +1098,32 @@ func TestAuthorizationEndpoint(t *testing.T) { wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, + { + name: "OIDC upstream browser flow happy path using dynamic client when downstream redirect uri matches what is configured for client except for the port number", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{ + "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client + "client_id": dynamicClientID, + "scope": allDynamicClientScopes, + }), + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantCSRFValueInCookieHeader: happyCSRF, + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ + "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client + "client_id": dynamicClientID, + "scope": allDynamicClientScopes, + }, "", oidcUpstreamName, "oidc"), nil), + wantUpstreamStateParamInLocationHeader: true, + wantBodyStringWithLocationInHref: true, + }, { name: "OIDC upstream password grant happy path when downstream redirect uri matches what is configured for client except for the port number", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -1342,6 +1521,45 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithPasswordGrantDisallowedHintErrorQuery), wantBodyString: "", }, + { + name: "dynamic clients are not allowed to use OIDC password grant because we don't want them to handle user credentials", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), + customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), + wantStatus: http.StatusFound, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithUsernamePasswordHeadersDisallowedHintErrorQuery), + wantBodyString: "", + }, + { + name: "dynamic clients are not allowed to use LDAP CLI-flow authentication because we don't want them to handle user credentials", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithUsernamePasswordHeadersDisallowedHintErrorQuery), + wantBodyString: "", + }, + { + name: "dynamic clients are not allowed to use Active Directory CLI-flow authentication because we don't want them to handle user credentials", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + customUsernameHeader: pointer.StringPtr(happyLDAPUsername), + customPasswordHeader: pointer.StringPtr(happyLDAPPassword), + wantStatus: http.StatusFound, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeAccessDeniedWithUsernamePasswordHeadersDisallowedHintErrorQuery), + wantBodyString: "", + }, { name: "downstream redirect uri does not match what is configured for client when using OIDC upstream browser flow", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), @@ -1358,6 +1576,25 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: jsonContentType, wantBodyJSON: fositeInvalidRedirectURIErrorBody, }, + { + name: "downstream redirect uri does not match what is configured for client when using OIDC upstream browser flow with a dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{ + "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-dynamic-client", + "client_id": dynamicClientID, + "scope": allDynamicClientScopes, + }), + wantStatus: http.StatusBadRequest, + wantContentType: jsonContentType, + wantBodyJSON: fositeInvalidRedirectURIErrorBody, + }, { name: "downstream redirect uri does not match what is configured for client when using OIDC upstream password grant", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -1455,6 +1692,26 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, + { + name: "response type is unsupported when using OIDC upstream browser flow with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{ + "response_type": "unsupported", + "client_id": dynamicClientID, + "scope": allDynamicClientScopes, + }), + wantStatus: http.StatusSeeOther, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), + wantBodyString: "", + }, { name: "response type is unsupported when using OIDC upstream password grant", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -1489,6 +1746,21 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, + { + name: "response type is unsupported when using LDAP browser upstream with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{ + "response_type": "unsupported", + "client_id": dynamicClientID, + "scope": allDynamicClientScopes, + }), + wantStatus: http.StatusSeeOther, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), + wantBodyString: "", + }, { name: "response type is unsupported when using active directory cli upstream", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), @@ -1511,6 +1783,21 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), wantBodyString: "", }, + { + name: "response type is unsupported when using active directory browser upstream with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{ + "response_type": "unsupported", + "client_id": dynamicClientID, + "scope": allDynamicClientScopes, + }), + wantStatus: http.StatusSeeOther, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeUnsupportedResponseTypeErrorQuery), + wantBodyString: "", + }, { name: "downstream scopes do not match what is configured for client using OIDC upstream browser flow", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), @@ -1526,6 +1813,22 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), wantBodyString: "", }, + { + name: "downstream scopes do not match what is configured for client using OIDC upstream browser flow with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + generateCSRF: happyCSRFGenerator, + generatePKCE: happyPKCEGenerator, + generateNonce: happyNonceGenerator, + stateEncoder: happyStateEncoder, + cookieEncoder: happyCookieEncoder, + method: http.MethodGet, + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": "openid tuna"}), + wantStatus: http.StatusSeeOther, + wantContentType: jsonContentType, + wantLocationHeader: urlWithQuery(downstreamRedirectURI, fositeInvalidScopeErrorQuery), + wantBodyString: "", + }, { name: "downstream scopes do not match what is configured for client using OIDC upstream password grant", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), @@ -1552,6 +1855,21 @@ func TestAuthorizationEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBodyRegex: ` 0 { + // Invalid: some stored client secrets were not valid. + conditions = append(conditions, &v1alpha1.Condition{ + Type: clientSecretExists, + Status: v1alpha1.ConditionFalse, + Reason: reasonInvalidClientSecretFound, + Message: fmt.Sprintf("%d stored client secrets found, but some were invalid, so none will be used: %s", + storedClientSecretsCount, strings.Join(bcryptErrs, "; ")), + }) + return conditions, emptyList + } + + // Valid: has at least one client secret stored for this OIDC client, and all stored client secrets are valid. + conditions = append(conditions, &v1alpha1.Condition{ + Type: clientSecretExists, + Status: v1alpha1.ConditionTrue, + Reason: reasonSuccess, + Message: fmt.Sprintf("%d client secret(s) found", storedClientSecretsCount), + }) + return conditions, storedClientSecret.SecretHashes +} + +func allowedGrantTypesContains(haystack *v1alpha1.OIDCClient, needle string) bool { + for _, hay := range haystack.Spec.AllowedGrantTypes { + if hay == v1alpha1.GrantType(needle) { + return true + } + } + return false +} + +func allowedScopesContains(haystack *v1alpha1.OIDCClient, needle string) bool { + for _, hay := range haystack.Spec.AllowedScopes { + if hay == v1alpha1.Scope(needle) { + return true + } + } + return false +} diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index 2833efa2..83a91d07 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -10,6 +10,7 @@ import ( corev1client "k8s.io/client-go/kubernetes/typed/core/v1" + "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/auth" "go.pinniped.dev/internal/oidc/callback" @@ -39,6 +40,7 @@ type Manager struct { upstreamIDPs oidc.UpstreamIdentityProvidersLister // in-memory cache of upstream IDPs secretCache *secret.Cache // in-memory cache of cryptographic material secretsClient corev1client.SecretInterface + oidcClientsClient v1alpha1.OIDCClientInterface } // NewManager returns an empty Manager. @@ -51,6 +53,7 @@ func NewManager( upstreamIDPs oidc.UpstreamIdentityProvidersLister, secretCache *secret.Cache, secretsClient corev1client.SecretInterface, + oidcClientsClient v1alpha1.OIDCClientInterface, ) *Manager { return &Manager{ providerHandlers: make(map[string]http.Handler), @@ -59,6 +62,7 @@ func NewManager( upstreamIDPs: upstreamIDPs, secretCache: secretCache, secretsClient: secretsClient, + oidcClientsClient: oidcClientsClient, } } @@ -93,10 +97,22 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs // Use NullStorage for the authorize endpoint because we do not actually want to store anything until // the upstream callback endpoint is called later. - oauthHelperWithNullStorage := oidc.FositeOauth2Helper(oidc.NullStorage{}, issuer, tokenHMACKeyGetter, nil, timeoutsConfiguration) + oauthHelperWithNullStorage := oidc.FositeOauth2Helper( + oidc.NewNullStorage(m.secretsClient, m.oidcClientsClient), + issuer, + tokenHMACKeyGetter, + nil, + timeoutsConfiguration, + ) // For all the other endpoints, make another oauth helper with exactly the same settings except use real storage. - oauthHelperWithKubeStorage := oidc.FositeOauth2Helper(oidc.NewKubeStorage(m.secretsClient, timeoutsConfiguration), issuer, tokenHMACKeyGetter, m.dynamicJWKSProvider, timeoutsConfiguration) + oauthHelperWithKubeStorage := oidc.FositeOauth2Helper( + oidc.NewKubeStorage(m.secretsClient, m.oidcClientsClient, timeoutsConfiguration), + issuer, + tokenHMACKeyGetter, + m.dynamicJWKSProvider, + timeoutsConfiguration, + ) var upstreamStateEncoder = dynamiccodec.New( timeoutsConfiguration.UpstreamStateParamLifespan, diff --git a/internal/oidc/provider/manager/manager_test.go b/internal/oidc/provider/manager/manager_test.go index 1f18dcf7..272387e9 100644 --- a/internal/oidc/provider/manager/manager_test.go +++ b/internal/oidc/provider/manager/manager_test.go @@ -15,18 +15,18 @@ import ( "strings" "testing" - "go.pinniped.dev/internal/secret" - "github.com/sclevine/spec" "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2" "k8s.io/client-go/kubernetes/fake" + supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/discovery" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/secret" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -271,6 +271,7 @@ func TestManager(t *testing.T) { kubeClient = fake.NewSimpleClientset() secretsClient := kubeClient.CoreV1().Secrets("some-namespace") + oidcClientsClient := supervisorfake.NewSimpleClientset().ConfigV1alpha1().OIDCClients("some-namespace") cache := secret.Cache{} cache.SetCSRFCookieEncoderHashKey([]byte("fake-csrf-hash-secret")) @@ -283,7 +284,7 @@ func TestManager(t *testing.T) { cache.SetStateEncoderHashKey(issuer2, []byte("some-state-encoder-hash-key-2")) cache.SetStateEncoderBlockKey(issuer2, []byte("16-bytes-STATE02")) - subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient) + subject = NewManager(nextHandler, dynamicJWKSProvider, idpLister, &cache, secretsClient, oidcClientsClient) }) when("given no providers via SetProviders()", func() { diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index 0fc1143d..5bdd3688 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -37,6 +37,7 @@ import ( "k8s.io/client-go/kubernetes/fake" v1 "k8s.io/client-go/kubernetes/typed/core/v1" + supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/fositestorage/accesstoken" "go.pinniped.dev/internal/fositestorage/authorizationcode" @@ -3068,10 +3069,11 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs, idps p client := fake.NewSimpleClientset() secrets = client.CoreV1().Secrets("some-namespace") + oidcClientsClient := supervisorfake.NewSimpleClientset().ConfigV1alpha1().OIDCClients("some-namespace") var oauthHelper fosite.OAuth2Provider - oauthStore = oidc.NewKubeStorage(secrets, oidc.DefaultOIDCTimeoutsConfiguration()) + oauthStore = oidc.NewKubeStorage(secrets, oidcClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) if test.makeOathHelper != nil { oauthHelper, authCode, jwtSigningKey = test.makeOathHelper(t, authRequest, oauthStore, test.customSessionData) } else { diff --git a/internal/oidc/token_exchange.go b/internal/oidc/token_exchange.go index a7a7812b..4c2f5500 100644 --- a/internal/oidc/token_exchange.go +++ b/internal/oidc/token_exchange.go @@ -14,6 +14,8 @@ import ( "github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/openid" "github.com/pkg/errors" + + "go.pinniped.dev/internal/oidc/clientregistry" ) const ( @@ -142,8 +144,8 @@ func (t *TokenExchangeHandler) validateParams(params url.Values) (*stsParams, er if strings.Contains(result.requestedAudience, ".pinniped.dev") { return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot contain '.pinniped.dev'") } - if result.requestedAudience == "pinniped-cli" { - return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot equal 'pinniped-cli'") + if result.requestedAudience == clientregistry.PinnipedCLIClientID { + return nil, fosite.ErrInvalidRequest.WithHintf("requested audience cannot equal '%s'", clientregistry.PinnipedCLIClientID) } return &result, nil diff --git a/internal/oidcclientsecretstorage/oidcclientsecretstorage.go b/internal/oidcclientsecretstorage/oidcclientsecretstorage.go index 257e674c..7bec307e 100644 --- a/internal/oidcclientsecretstorage/oidcclientsecretstorage.go +++ b/internal/oidcclientsecretstorage/oidcclientsecretstorage.go @@ -4,11 +4,14 @@ package oidcclientsecretstorage import ( + "context" "encoding/base64" "fmt" "time" - v1 "k8s.io/api/core/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" corev1client "k8s.io/client-go/kubernetes/typed/core/v1" @@ -26,6 +29,7 @@ const ( type OIDCClientSecretStorage struct { storage crud.Storage + secrets corev1client.SecretInterface } // StoredClientSecret defines the format of the content of a client's secrets when stored in a Secret @@ -39,12 +43,27 @@ type StoredClientSecret struct { } func New(secrets corev1client.SecretInterface, clock func() time.Time) *OIDCClientSecretStorage { - // TODO make lifetime = 0 mean that it does not get annotated with any garbage collection annotation - return &OIDCClientSecretStorage{storage: crud.New(TypeLabelValue, secrets, clock, 0)} + return &OIDCClientSecretStorage{ + storage: crud.New(TypeLabelValue, secrets, clock, 0), + secrets: secrets, + } } // TODO expose other methods as needed for get, create, update, etc. +// GetStorageSecret gets the corev1.Secret which is used to store the client secrets for the given client. +// Returns nil,nil when the corev1.Secret was not found, as this is not an error for a client to not have any secrets yet. +func (s *OIDCClientSecretStorage) GetStorageSecret(ctx context.Context, oidcClientUID types.UID) (*corev1.Secret, error) { + secret, err := s.secrets.Get(ctx, s.GetName(oidcClientUID), metav1.GetOptions{}) + if errors.IsNotFound(err) { + return nil, nil + } + if err != nil { + return nil, err + } + return secret, nil +} + // GetName returns the name of the Secret which would be used to store data for the given signature. func (s *OIDCClientSecretStorage) GetName(oidcClientUID types.UID) string { // Avoid having s.storage.GetName() base64 decode something that wasn't ever encoded by encoding it here. @@ -53,7 +72,7 @@ func (s *OIDCClientSecretStorage) GetName(oidcClientUID types.UID) string { } // ReadFromSecret reads the contents of a Secret as a StoredClientSecret. -func ReadFromSecret(secret *v1.Secret) (*StoredClientSecret, error) { +func ReadFromSecret(secret *corev1.Secret) (*StoredClientSecret, error) { storedClientSecret := &StoredClientSecret{} err := crud.FromSecret(TypeLabelValue, secret, storedClientSecret) if err != nil { diff --git a/internal/oidcclientsecretstorage/oidcclientsecretstorage_test.go b/internal/oidcclientsecretstorage/oidcclientsecretstorage_test.go index ac81565a..09ff908c 100644 --- a/internal/oidcclientsecretstorage/oidcclientsecretstorage_test.go +++ b/internal/oidcclientsecretstorage/oidcclientsecretstorage_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.pinniped.dev/internal/testutil" ) func TestGetName(t *testing.T) { @@ -106,6 +108,31 @@ func TestReadFromSecret(t *testing.T) { }, wantErr: "secret storage data has incorrect version", }, + { + name: "OIDCClientSecretStorageSecretForUID() test helper generates readable format, to ensure that test helpers are kept up to date", + secret: testutil.OIDCClientSecretStorageSecretForUID(t, + "some-namespace", "some-uid", []string{"first-hash", "second-hash"}, + ), + wantStored: &StoredClientSecret{ + Version: "1", + SecretHashes: []string{"first-hash", "second-hash"}, + }, + }, + { + name: "OIDCClientSecretStorageSecretWithoutName() test helper generates readable format, to ensure that test helpers are kept up to date", + secret: testutil.OIDCClientSecretStorageSecretWithoutName(t, + "some-namespace", []string{"first-hash", "second-hash"}, + ), + wantStored: &StoredClientSecret{ + Version: "1", + SecretHashes: []string{"first-hash", "second-hash"}, + }, + }, + { + name: "OIDCClientSecretStorageSecretForUIDWithWrongVersion() test helper generates readable format, to ensure that test helpers are kept up to date", + secret: testutil.OIDCClientSecretStorageSecretForUIDWithWrongVersion(t, "some-namespace", "some-uid"), + wantErr: "OIDC client secret storage data has wrong version: OIDC client secret storage has version wrong-version instead of 1", + }, } for _, tt := range tests { diff --git a/internal/supervisor/server/server.go b/internal/supervisor/server/server.go index 677165ee..ac71376a 100644 --- a/internal/supervisor/server/server.go +++ b/internal/supervisor/server/server.go @@ -439,6 +439,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis dynamicUpstreamIDPProvider, &secretCache, clientWithoutLeaderElection.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), // writes to kube storage are allowed for non-leaders + clientWithoutLeaderElection.PinnipedSupervisor.ConfigV1alpha1().OIDCClients(serverInstallationNamespace), ) // Get the "real" name of the client secret supervisor API group (i.e., the API group name with the diff --git a/internal/testutil/oidcclientsecretstorage.go b/internal/testutil/oidcclientsecretstorage.go new file mode 100644 index 00000000..b7904fc6 --- /dev/null +++ b/internal/testutil/oidcclientsecretstorage.go @@ -0,0 +1,51 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "encoding/base32" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func secretNameForUID(uid string) string { + // See GetName() in OIDCClientSecretStorage for how the production code determines the Secret name. + // This test helper is intended to choose the same name. + return "pinniped-storage-oidc-client-secret-" + + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString([]byte(uid))) +} + +func OIDCClientSecretStorageSecretWithoutName(t *testing.T, namespace string, hashes []string) *corev1.Secret { + hashesJSON, err := json.Marshal(hashes) + require.NoError(t, err) // this shouldn't really happen since we can always encode a slice of strings + + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Labels: map[string]string{"storage.pinniped.dev/type": "oidc-client-secret"}, + }, + Type: "storage.pinniped.dev/oidc-client-secret", + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"version":"1","hashes":` + string(hashesJSON) + `}`), + "pinniped-storage-version": []byte("1"), + }, + } +} + +func OIDCClientSecretStorageSecretForUID(t *testing.T, namespace string, oidcClientUID string, hashes []string) *corev1.Secret { + secret := OIDCClientSecretStorageSecretWithoutName(t, namespace, hashes) + secret.Name = secretNameForUID(oidcClientUID) + return secret +} + +func OIDCClientSecretStorageSecretForUIDWithWrongVersion(t *testing.T, namespace string, oidcClientUID string) *corev1.Secret { + secret := OIDCClientSecretStorageSecretForUID(t, namespace, oidcClientUID, []string{}) + secret.Data["pinniped-storage-data"] = []byte(`{"version":"wrong-version","hashes":[]}`) + return secret +} diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 1d43cdd0..af134fc1 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -156,29 +156,90 @@ func TestSupervisorLogin_Browser(t *testing.T) { return ldapIDP, secret } + // These tests attempt to exercise the entire login and refresh flow of the Supervisor for various cases. + // They do not use the Pinniped CLI as the client, which allows them to exercise the Supervisor as an + // OIDC provider in ways that the CLI might not use. Similar tests exist using the CLI in e2e_test.go. + // + // Each of these tests perform the following flow: + // 1. Create a FederationDomain with TLS configured and wait for its JWKS endpoint to be available. + // 2. Configure an IDP CR. + // 3. Call the authorization endpoint and log in as a specific user. + // Note that these tests do not use form_post response type (which is tested by e2e_test.go). + // 4. Listen on a local callback server for the authorization redirect, and assert that it was success or failure. + // 5. Call the token endpoint to exchange the authcode. + // 6. Call the token endpoint to perform the RFC8693 token exchange for the cluster-scoped ID token. + // 7. Potentially edit the refresh session data or IDP settings before the refresh. + // 8. Call the token endpoint to perform a refresh, and expect it to succeed. + // 9. Call the token endpoint again to perform another RFC8693 token exchange for the cluster-scoped ID token, + // this time using the recently refreshed tokens when submitting the request. + // 10. Potentially edit the refresh session data or IDP settings again, this time in such a way that the next + // refresh should fail. If done, then perform one more refresh and expect failure. tests := []struct { - name string - maybeSkip func(t *testing.T) - createTestUser func(t *testing.T) (string, string) - deleteTestUser func(t *testing.T, username string) - requestAuthorization func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) - createIDP func(t *testing.T) string - requestTokenExchangeAud string - downstreamScopes []string - wantLocalhostCallbackToNeverHappen bool - wantDownstreamIDTokenSubjectToMatch string - wantDownstreamIDTokenUsernameToMatch func(username string) string - wantDownstreamIDTokenGroups []string - wantErrorDescription string - wantErrorType string - wantTokenExchangeResponse func(t *testing.T, status int, body string) + name string - // Either revoke the user's session on the upstream provider, or manipulate the user's session + // This required function might choose to skip the test case, for example if the LDAP server is not + // available for an LDAP test. + maybeSkip func(t *testing.T) + + // This required function should configure an IDP CR. It should also wait for it to be ready and schedule + // its cleanup. Return the name of the IDP CR. + createIDP func(t *testing.T) string + + // Optionally create an OIDCClient CR for the test to use. Return the client ID and client secret for the + // test to use. When not set, the test will default to using the "pinniped-cli" static client with no secret. + // When a client secret is returned, it will be used for authcode exchange, refresh requests, and RFC8693 + // token exchanges for cluster-scoped tokens (client secrets are not needed in authorization requests). + createOIDCClient func(t *testing.T, callbackURL string) (string, string) + + // Optionally return the username and password for the test to use when logging in. This username/password + // will be passed to requestAuthorization(), or empty strings will be passed to indicate that the defaults + // should be used. If there is any cleanup required, then this function should also schedule that cleanup. + testUser func(t *testing.T) (string, string) + + // This required function should call the authorization endpoint using the given URL and also perform whatever + // interactions are needed to log in as the user. + requestAuthorization func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, username, password string, httpClient *http.Client) + + // This string will be used as the requested audience in the RFC8693 token exchange for + // the cluster-scoped ID token. When it is not specified, a default string will be used. + requestTokenExchangeAud string + + // The scopes to request from the authorization endpoint. Defaults will be used when not specified. + downstreamScopes []string + + // When we want the localhost callback to have never happened, then the flow will stop there. The login was + // unable to finish so there is nothing to assert about what should have happened with the callback, and there + // won't be any error sent to the callback either. This would happen, for example, when the user fails to log + // in at the LDAP/AD login page, because then they would be redirected back to that page again, instead of + // getting a callback success/error redirect. + wantLocalhostCallbackToNeverHappen bool + + // The expected ID token subject claim value as a regexp, for the original ID token and the refreshed ID token. + wantDownstreamIDTokenSubjectToMatch string + // The expected ID token username claim value as a regexp, for the original ID token and the refreshed ID token. + wantDownstreamIDTokenUsernameToMatch func(username string) string + // The expected ID token groups claim value, for the original ID token and the refreshed ID token. + wantDownstreamIDTokenGroups []string + + // Want the authorization endpoint to redirect to the callback with this error type. + // The rest of the flow will be skipped since the initial authorization failed. + wantErrorType string + // Want the authorization endpoint to redirect to the callback with this error description. + // Should be used with wantErrorType. + wantErrorDescription string + + // Optionally make all required assertions about the response of the RFC8693 token exchange for + // the cluster-scoped ID token, given the http response status and response body from the token endpoint. + // When this is not specified then the appropriate default assertions for a successful exchange are made. + // Even if this expects failures, the rest of the flow will continue. + wantTokenExchangeResponse func(t *testing.T, status int, body string) + + // Optionally edit the refresh session data between the initial login and the first refresh, + // which is still expected to succeed after these edits. + editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string + // Optionally either revoke the user's session on the upstream provider, or manipulate the user's session // data in such a way that it should cause the next upstream refresh attempt to fail. breakRefreshSessionData func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) - // Edit the refresh session data between the initial login and the refresh, which is expected to - // succeed. - editRefreshSessionDataWithoutBreaking func(t *testing.T, sessionData *psession.PinnipedSession, idpName, username string) []string }{ { name: "oidc with default username and groups claim settings", @@ -389,7 +450,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { idp, _ := createLDAPIdentityProvider(t, nil) return idp.Name }, - createTestUser: func(t *testing.T) (string, string) { + testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login @@ -414,7 +475,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { idp, _ := createLDAPIdentityProvider(t, nil) return idp.Name }, - createTestUser: func(t *testing.T) (string, string) { + testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login "this is the wrong password" // password to present to server during login @@ -429,7 +490,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { idp, _ := createLDAPIdentityProvider(t, nil) return idp.Name }, - createTestUser: func(t *testing.T) (string, string) { + testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test return "this is the wrong username", // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login @@ -444,7 +505,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { idp, _ := createLDAPIdentityProvider(t, nil) return idp.Name }, - createTestUser: func(t *testing.T) (string, string) { + testUser: func(t *testing.T) (string, string) { // return the username and password of the existing user that we want to use for this test return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login @@ -964,12 +1025,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { idp, _ := createActiveDirectoryIdentityProvider(t, nil) return idp.Name }, - createTestUser: func(t *testing.T) (string, string) { + testUser: func(t *testing.T) (string, string) { return testlib.CreateFreshADTestUser(t, env) }, - deleteTestUser: func(t *testing.T, username string) { - testlib.DeleteTestADUser(t, env, username) - }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, @@ -997,12 +1055,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { idp, _ := createActiveDirectoryIdentityProvider(t, nil) return idp.Name }, - createTestUser: func(t *testing.T) (string, string) { + testUser: func(t *testing.T) (string, string) { return testlib.CreateFreshADTestUser(t, env) }, - deleteTestUser: func(t *testing.T, username string) { - testlib.DeleteTestADUser(t, env, username) - }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, @@ -1030,12 +1085,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { idp, _ := createActiveDirectoryIdentityProvider(t, nil) return idp.Name }, - createTestUser: func(t *testing.T) (string, string) { + testUser: func(t *testing.T) (string, string) { return testlib.CreateFreshADTestUser(t, env) }, - deleteTestUser: func(t *testing.T, username string) { - testlib.DeleteTestADUser(t, env, username) - }, requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, testUserName, testUserPassword string, httpClient *http.Client) { requestAuthorizationUsingCLIPasswordFlow(t, downstreamAuthorizeURL, @@ -1226,7 +1278,62 @@ func TestSupervisorLogin_Browser(t *testing.T) { body) }, }, + { + name: "oidc upstream with downstream dynamic client happy path", + maybeSkip: skipNever, + createIDP: func(t *testing.T) string { + return testlib.CreateTestOIDCIdentityProvider(t, basicOIDCIdentityProviderSpec(), idpv1alpha1.PhaseReady).Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "groups"}, + }, configv1alpha1.PhaseReady) + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, + // the ID token Subject should include the upstream user ID after the upstream issuer name + wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+", + // the ID token Username should include the upstream user ID after the upstream issuer name + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Issuer+"?sub=") + ".+" }, + }, + { + name: "ldap upstream with downstream dynamic client happy path", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "groups"}, + }, configv1alpha1.PhaseReady) + }, + requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { + requestAuthorizationUsingCLIPasswordFlow(t, + downstreamAuthorizeURL, + env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login + httpClient, + false, + ) + }, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( + "ldaps://"+env.SupervisorUpstreamLDAP.Host+ + "?base="+url.QueryEscape(env.SupervisorUpstreamLDAP.UserSearchBase)+ + "&sub="+base64.RawURLEncoding.EncodeToString([]byte(env.SupervisorUpstreamLDAP.TestUserUniqueIDAttributeValue)), + ) + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamLDAP.TestUserMailAttributeValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, + }, } + for _, test := range tests { tt := test t.Run(tt.name, func(t *testing.T) { @@ -1237,8 +1344,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { tt.requestAuthorization, tt.editRefreshSessionDataWithoutBreaking, tt.breakRefreshSessionData, - tt.createTestUser, - tt.deleteTestUser, + tt.testUser, + tt.createOIDCClient, tt.downstreamScopes, tt.requestTokenExchangeAud, tt.wantLocalhostCallbackToNeverHappen, @@ -1377,8 +1484,8 @@ func testSupervisorLogin( requestAuthorization func(t *testing.T, downstreamIssuer string, downstreamAuthorizeURL string, downstreamCallbackURL string, username string, password string, httpClient *http.Client), editRefreshSessionDataWithoutBreaking func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string) []string, breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string), - createTestUser func(t *testing.T) (string, string), - deleteTestUser func(t *testing.T, username string), + testUser func(t *testing.T) (string, string), + createOIDCClient func(t *testing.T, callbackURL string) (string, string), downstreamScopes []string, requestTokenExchangeAud string, wantLocalhostCallbackToNeverHappen bool, @@ -1475,12 +1582,20 @@ func testSupervisorLogin( // Create upstream IDP and wait for it to become ready. idpName := createIDP(t) + // Start a callback server on localhost. + localCallbackServer := startLocalCallbackServer(t) + + // Optionally create an OIDCClient. Default to using the hardcoded public client that the Supervisor supports. + clientID, clientSecret := "pinniped-cli", "" //nolint:gosec // empty credential is not a hardcoded credential + if createOIDCClient != nil { + clientID, clientSecret = createOIDCClient(t, localCallbackServer.URL) + } + + // Optionally override which user to use for the test, or choose zero values to mean use the default for + // the test's IDP. username, password := "", "" - if createTestUser != nil { - username, password = createTestUser(t) - if deleteTestUser != nil { - defer deleteTestUser(t, username) - } + if testUser != nil { + username, password = testUser(t) } // Perform OIDC discovery for our downstream. @@ -1491,23 +1606,27 @@ func testSupervisorLogin( requireEventually.NoError(err) }, 30*time.Second, 200*time.Millisecond) - // Start a callback server on localhost. - localCallbackServer := startLocalCallbackServer(t) - if downstreamScopes == nil { downstreamScopes = []string{"openid", "pinniped:request-audience", "offline_access", "groups"} } - // Form the OAuth2 configuration corresponding to our CLI client. + // Create the OAuth2 configuration. // Note that this is not using response_type=form_post, so the Supervisor will redirect to the callback endpoint // directly, without using the Javascript form_post HTML page to POST back to the callback endpoint. The e2e // tests which use the Pinniped CLI are testing the form_post part of the flow, so that is covered elsewhere. + // When ClientSecret is set here, it will be used for all token endpoint requests, but not for the authorization + // request, where it is not needed. + endpoint := discovery.Endpoint() + if clientSecret != "" { + // We only support basic auth for dynamic clients, so use basic auth in these tests. + endpoint.AuthStyle = oauth2.AuthStyleInHeader + } downstreamOAuth2Config := oauth2.Config{ - // This is the hardcoded public client that the supervisor supports. - ClientID: "pinniped-cli", - Endpoint: discovery.Endpoint(), - RedirectURL: localCallbackServer.URL, - Scopes: downstreamScopes, + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: endpoint, + RedirectURL: localCallbackServer.URL, + Scopes: downstreamScopes, } // Build a valid downstream authorize URL for the supervisor. @@ -1573,9 +1692,9 @@ func testSupervisorLogin( signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken) // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. - kubeClient := testlib.NewKubernetesClientset(t) - supervisorSecretsClient := kubeClient.CoreV1().Secrets(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, oidc.DefaultOIDCTimeoutsConfiguration()) + supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) + supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) require.NoError(t, err) @@ -1618,9 +1737,9 @@ func testSupervisorLogin( signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken) // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. - kubeClient := testlib.NewKubernetesClientset(t) - supervisorSecretsClient := kubeClient.CoreV1().Secrets(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, oidc.DefaultOIDCTimeoutsConfiguration()) + supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) + supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) require.NoError(t, err) @@ -1922,6 +2041,10 @@ func doTokenExchange( req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.Endpoint.TokenURL, reqBody) require.NoError(t, err) req.Header.Set("content-type", "application/x-www-form-urlencoded") + if config.ClientSecret != "" { + // We only support basic auth for dynamic clients, so use basic auth in these tests. + req.SetBasicAuth(config.ClientID, config.ClientSecret) + } resp, err := httpClient.Do(req) require.NoError(t, err) diff --git a/test/integration/supervisor_oidc_client_test.go b/test/integration/supervisor_oidc_client_test.go index cf059fd3..4ec9fc55 100644 --- a/test/integration/supervisor_oidc_client_test.go +++ b/test/integration/supervisor_oidc_client_test.go @@ -528,16 +528,7 @@ func TestOIDCClientControllerValidations_Parallel(t *testing.T) { AllowedScopes: []supervisorconfigv1alpha1.Scope{"openid"}, }, }, - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"storage.pinniped.dev/type": "oidc-client-secret"}, - }, - Type: "storage.pinniped.dev/oidc-client-secret", - Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"1","hashes":[]}`), - "pinniped-storage-version": []byte("1"), - }, - }, + secret: testutil.OIDCClientSecretStorageSecretWithoutName(t, env.SupervisorNamespace, []string{}), wantPhase: "Error", wantConditions: []supervisorconfigv1alpha1.Condition{ { @@ -572,16 +563,7 @@ func TestOIDCClientControllerValidations_Parallel(t *testing.T) { AllowedScopes: []supervisorconfigv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, }, }, - secret: &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Labels: map[string]string{"storage.pinniped.dev/type": "oidc-client-secret"}, - }, - Type: "storage.pinniped.dev/oidc-client-secret", - Data: map[string][]byte{ - "pinniped-storage-data": []byte(`{"version":"1","hashes":["$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m"]}`), - "pinniped-storage-version": []byte("1"), - }, - }, + secret: testutil.OIDCClientSecretStorageSecretWithoutName(t, env.SupervisorNamespace, []string{"$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m"}), wantPhase: "Ready", wantConditions: []supervisorconfigv1alpha1.Condition{ { diff --git a/test/integration/supervisor_warnings_test.go b/test/integration/supervisor_warnings_test.go index 3fdfffb9..e3ea9485 100644 --- a/test/integration/supervisor_warnings_test.go +++ b/test/integration/supervisor_warnings_test.go @@ -186,9 +186,10 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // using the refresh token signature contained in the cache, get the refresh token session // out of kube secret storage. - kubeClient := testlib.NewKubernetesClientset(t).CoreV1() + supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) + supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) refreshTokenSignature := strings.Split(token.RefreshToken.Token, ".")[1] - oauthStore := oidc.NewKubeStorage(kubeClient.Secrets(env.SupervisorNamespace), oidc.DefaultOIDCTimeoutsConfiguration()) storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, refreshTokenSignature, nil) require.NoError(t, err) @@ -246,9 +247,6 @@ func TestSupervisorWarnings_Browser(t *testing.T) { testlib.SkipTestWhenActiveDirectoryIsUnavailable(t, env) expectedUsername, password := testlib.CreateFreshADTestUser(t, env) - t.Cleanup(func() { - testlib.DeleteTestADUser(t, env, expectedUsername) - }) sAMAccountName := expectedUsername + "@" + env.SupervisorUpstreamActiveDirectory.Domain setupClusterForEndToEndActiveDirectoryTest(t, sAMAccountName, env) @@ -308,9 +306,6 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // create an active directory group, and add our user to it. groupName := testlib.CreateFreshADTestGroup(t, env) - t.Cleanup(func() { - testlib.DeleteTestADUser(t, env, groupName) - }) testlib.AddTestUserToGroup(t, env, groupName, expectedUsername) // remove the credential cache, which includes the cached cert, so it won't be reused and the refresh flow will be triggered. @@ -499,9 +494,10 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // using the refresh token signature contained in the cache, get the refresh token session // out of kube secret storage. - kubeClient := testlib.NewKubernetesClientset(t).CoreV1() + supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) + supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) refreshTokenSignature := strings.Split(token.RefreshToken.Token, ".")[1] - oauthStore := oidc.NewKubeStorage(kubeClient.Secrets(env.SupervisorNamespace), oidc.DefaultOIDCTimeoutsConfiguration()) storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, refreshTokenSignature, nil) require.NoError(t, err) diff --git a/test/testlib/activedirectory.go b/test/testlib/activedirectory.go index b4440a99..25580059 100644 --- a/test/testlib/activedirectory.go +++ b/test/testlib/activedirectory.go @@ -42,6 +42,11 @@ func CreateFreshADTestUser(t *testing.T, env *TestEnv) (string, string) { err = conn.Add(a) require.NoError(t, err) + // Now that it has been created, schedule it for cleanup. + t.Cleanup(func() { + deleteTestADUser(t, env, testUserName) + }) + // modify password and enable account testUserPassword := createRandomASCIIString(t, 20) enc := unicode.UTF16(unicode.LittleEndian, unicode.IgnoreBOM).NewEncoder() @@ -83,6 +88,11 @@ func CreateFreshADTestGroup(t *testing.T, env *TestEnv) string { err = conn.Add(a) require.NoError(t, err) + // Now that it has been created, schedule it for cleanup. + t.Cleanup(func() { + deleteTestADUser(t, env, testGroupName) + }) + time.Sleep(20 * time.Second) // intrasite domain controller replication can take up to 15 seconds, so wait to ensure the change has propogated. return testGroupName } @@ -164,8 +174,8 @@ func ChangeADTestUserPassword(t *testing.T, env *TestEnv, testUserName string) { // don't bother to return the new password... we won't be using it, just checking that it's changed. } -// DeleteTestADUser deletes the test user created for this test. -func DeleteTestADUser(t *testing.T, env *TestEnv, testUserName string) { +// deleteTestADUser deletes the test user created for this test. +func deleteTestADUser(t *testing.T, env *TestEnv, testUserName string) { t.Helper() conn := dialTLS(t, env) // bind diff --git a/test/testlib/client.go b/test/testlib/client.go index b395d6fe..2c514f7d 100644 --- a/test/testlib/client.go +++ b/test/testlib/client.go @@ -16,9 +16,11 @@ import ( "time" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" authorizationv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" @@ -26,8 +28,6 @@ import ( "k8s.io/client-go/tools/clientcmd" aggregatorclient "k8s.io/kube-aggregator/pkg/client/clientset_generated/clientset" - apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/typed/apiextensions/v1" - auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" "go.pinniped.dev/generated/latest/apis/concierge/login/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" @@ -36,6 +36,7 @@ import ( supervisorclientset "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned" "go.pinniped.dev/internal/groupsuffix" "go.pinniped.dev/internal/kubeclient" + "go.pinniped.dev/internal/oidcclientsecretstorage" // Import to initialize client auth plugins - the kubeconfig that we use for // testing may use gcloud, az, oidc, etc. @@ -378,6 +379,89 @@ func CreateClientCredsSecret(t *testing.T, clientID string, clientSecret string) ) } +func CreateOIDCClient(t *testing.T, spec configv1alpha1.OIDCClientSpec, expectedPhase configv1alpha1.OIDCClientPhase) (string, string) { + t.Helper() + env := IntegrationEnv(t) + client := NewSupervisorClientset(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + oidcClientClient := client.ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) + + // Create the OIDCClient using GenerateName to get a random name. + created, err := oidcClientClient.Create(ctx, &configv1alpha1.OIDCClient{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "client.oauth.pinniped.dev-test-", // use the required name prefix + Labels: map[string]string{"pinniped.dev/test": ""}, + Annotations: map[string]string{"pinniped.dev/testName": t.Name()}, + }, + Spec: spec, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + // Always clean this up after this point. + t.Cleanup(func() { + t.Logf("cleaning up test OIDCClient %s/%s", created.Namespace, created.Name) + err := oidcClientClient.Delete(context.Background(), created.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + t.Logf("created test OIDCClient %s", created.Name) + + // Create a client secret for the new OIDCClient. + clientSecret := createOIDCClientSecret(t, created) + + // Wait for the OIDCClient to enter the expected phase (or time out). + var result *configv1alpha1.OIDCClient + RequireEventuallyf(t, func(requireEventually *require.Assertions) { + var err error + result, err = oidcClientClient.Get(ctx, created.Name, metav1.GetOptions{}) + requireEventually.NoErrorf(err, "error while getting OIDCClient %s/%s", created.Namespace, created.Name) + requireEventually.Equal(expectedPhase, result.Status.Phase) + }, 60*time.Second, 1*time.Second, "expected the OIDCClient to go into phase %s, OIDCClient was: %s", expectedPhase, Sdump(result)) + + return created.Name, clientSecret +} + +func createOIDCClientSecret(t *testing.T, forOIDCClient *configv1alpha1.OIDCClient) string { + // TODO Replace this with a call to the real Supervisor API for creating client secrets after that gets implemented. + // For now, just manually create a Secret with the right format so the tests can work. + t.Helper() + env := IntegrationEnv(t) + kubeClient := NewKubernetesClientset(t) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + var buf [32]byte + _, err := io.ReadFull(rand.Reader, buf[:]) + require.NoError(t, err) + randomSecret := hex.EncodeToString(buf[:]) + hashedRandomSecret, err := bcrypt.GenerateFromPassword([]byte(randomSecret), 15) + require.NoError(t, err) + + created, err := kubeClient.CoreV1().Secrets(env.SupervisorNamespace).Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: oidcclientsecretstorage.New(nil, nil).GetName(forOIDCClient.UID), // use the required name + Labels: map[string]string{"storage.pinniped.dev/type": "oidc-client-secret", "pinniped.dev/test": ""}, + Annotations: map[string]string{"pinniped.dev/testName": t.Name()}, + }, + Type: "storage.pinniped.dev/oidc-client-secret", + Data: map[string][]byte{ + "pinniped-storage-data": []byte(`{"version":"1","hashes":["` + string(hashedRandomSecret) + `"]}`), + "pinniped-storage-version": []byte("1"), + }, + }, metav1.CreateOptions{}) + require.NoError(t, err) + + t.Cleanup(func() { + t.Logf("cleaning up test Secret %s/%s", created.Namespace, created.Name) + err := kubeClient.CoreV1().Secrets(env.SupervisorNamespace).Delete(context.Background(), created.Name, metav1.DeleteOptions{}) + require.NoError(t, err) + }) + + t.Logf("created test Secret %s", created.Name) + return randomSecret +} + func CreateTestOIDCIdentityProvider(t *testing.T, spec idpv1alpha1.OIDCIdentityProviderSpec, expectedPhase idpv1alpha1.OIDCIdentityProviderPhase) *idpv1alpha1.OIDCIdentityProvider { t.Helper() env := IntegrationEnv(t) @@ -385,9 +469,9 @@ func CreateTestOIDCIdentityProvider(t *testing.T, spec idpv1alpha1.OIDCIdentityP ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - // Create the OIDCIdentityProvider using GenerateName to get a random name. upstreams := client.IDPV1alpha1().OIDCIdentityProviders(env.SupervisorNamespace) + // Create the OIDCIdentityProvider using GenerateName to get a random name. created, err := upstreams.Create(ctx, &idpv1alpha1.OIDCIdentityProvider{ ObjectMeta: testObjectMeta(t, "upstream-oidc-idp"), Spec: spec, @@ -420,9 +504,9 @@ func CreateTestLDAPIdentityProvider(t *testing.T, spec idpv1alpha1.LDAPIdentityP ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - // Create the LDAPIdentityProvider using GenerateName to get a random name. upstreams := client.IDPV1alpha1().LDAPIdentityProviders(env.SupervisorNamespace) + // Create the LDAPIdentityProvider using GenerateName to get a random name. created, err := upstreams.Create(ctx, &idpv1alpha1.LDAPIdentityProvider{ ObjectMeta: testObjectMeta(t, "upstream-ldap-idp"), Spec: spec, @@ -461,9 +545,9 @@ func CreateTestActiveDirectoryIdentityProvider(t *testing.T, spec idpv1alpha1.Ac ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - // Create the ActiveDirectoryIdentityProvider using GenerateName to get a random name. upstreams := client.IDPV1alpha1().ActiveDirectoryIdentityProviders(env.SupervisorNamespace) + // Create the ActiveDirectoryIdentityProvider using GenerateName to get a random name. created, err := upstreams.Create(ctx, &idpv1alpha1.ActiveDirectoryIdentityProvider{ ObjectMeta: testObjectMeta(t, "upstream-ad-idp"), Spec: spec, @@ -501,9 +585,9 @@ func CreateTestClusterRoleBinding(t *testing.T, subject rbacv1.Subject, roleRef ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - // Create the ClusterRoleBinding using GenerateName to get a random name. clusterRoles := client.RbacV1().ClusterRoleBindings() + // Create the ClusterRoleBinding using GenerateName to get a random name. created, err := clusterRoles.Create(ctx, &rbacv1.ClusterRoleBinding{ ObjectMeta: testObjectMeta(t, "cluster-role"), Subjects: []rbacv1.Subject{subject}, From f5f55176af9e45492fa1042014d352293993b1a7 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 14 Jul 2022 18:50:23 -0700 Subject: [PATCH 2/8] Enhance integration tests for OIDCClients in supervisor_login_test.go --- test/integration/supervisor_login_test.go | 53 ++++++++++++++++++----- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index af134fc1..fa9c74b3 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -1288,7 +1288,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, - AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "groups"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, }, configv1alpha1.PhaseReady) }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, @@ -1308,18 +1308,15 @@ func TestSupervisorLogin_Browser(t *testing.T) { return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, - AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "groups"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, }, configv1alpha1.PhaseReady) }, - requestAuthorization: func(t *testing.T, _, downstreamAuthorizeURL, _, _, _ string, httpClient *http.Client) { - requestAuthorizationUsingCLIPasswordFlow(t, - downstreamAuthorizeURL, - env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login - env.SupervisorUpstreamLDAP.TestUserPassword, // password to present to server during login - httpClient, - false, - ) + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( "ldaps://"+env.SupervisorUpstreamLDAP.Host+ @@ -1332,6 +1329,42 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamLDAP.TestUserDirectGroupsDNs, }, + { + name: "active directory with all default options with downstream dynamic client happy path", + maybeSkip: skipActiveDirectoryTests, + createIDP: func(t *testing.T) string { + idp, _ := createActiveDirectoryIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + return testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + }, configv1alpha1.PhaseReady) + }, + requestAuthorization: func(t *testing.T, downstreamIssuer, downstreamAuthorizeURL, downstreamCallbackURL, _, _ string, httpClient *http.Client) { + requestAuthorizationUsingBrowserAuthcodeFlowLDAP(t, + downstreamIssuer, + downstreamAuthorizeURL, + downstreamCallbackURL, + env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue, // username to present to server during login + env.SupervisorUpstreamActiveDirectory.TestUserPassword, // password to present to server during login + httpClient, + ) + }, + // the ID token Subject should be the Host URL plus the value pulled from the requested UserSearch.Attributes.UID attribute + wantDownstreamIDTokenSubjectToMatch: "^" + regexp.QuoteMeta( + "ldaps://"+env.SupervisorUpstreamActiveDirectory.Host+ + "?base="+url.QueryEscape(env.SupervisorUpstreamActiveDirectory.DefaultNamingContextSearchBase)+ + "&sub="+env.SupervisorUpstreamActiveDirectory.TestUserUniqueIDAttributeValue, + ) + "$", + // the ID token Username should have been pulled from the requested UserSearch.Attributes.Username attribute + wantDownstreamIDTokenUsernameToMatch: func(_ string) string { + return "^" + regexp.QuoteMeta(env.SupervisorUpstreamActiveDirectory.TestUserPrincipalNameValue) + "$" + }, + wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, + }, } for _, test := range tests { From 34509e74305e7779e5064c1e70f83888f77ca5d6 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 20 Jul 2022 13:55:56 -0700 Subject: [PATCH 3/8] Add more unit tests for dynamic clients and enhance token exchange - Enhance the token exchange to check that the same client is used compared to the client used during the original authorization and token requests, and also check that the client has the token-exchange grant type allowed in its configuration. - Reduce the minimum required bcrypt cost for OIDCClient secrets because 15 is too slow for real-life use, especially considering that every login and every refresh flow will require two client auths. - In unit tests, use bcrypt hashes with a cost of 4, because bcrypt slows down by 13x when run with the race detector, and we run our tests with the race detector enabled, causing the tests to be unacceptably slow. The production code uses a higher minimum cost. - Centralize all pre-computed bcrypt hashes used by unit tests to a single place. Also extract some other useful test helpers for unit tests related to OIDCClients. - Add tons of unit tests for the token endpoint related to dynamic clients for authcode exchanges, token exchanges, and refreshes. --- .../oidcclientwatcher/oidc_client_watcher.go | 2 +- .../oidc_client_watcher_test.go | 57 +- internal/oidc/auth/auth_handler_test.go | 93 +-- .../oidc/callback/callback_handler_test.go | 28 +- .../oidc/clientregistry/clientregistry.go | 5 +- .../clientregistry/clientregistry_test.go | 20 +- internal/oidc/kube_storage.go | 9 +- .../oidc/login/post_login_handler_test.go | 22 +- internal/oidc/nullstorage.go | 8 +- .../oidcclientvalidator.go | 14 +- internal/oidc/provider/manager/manager.go | 5 +- internal/oidc/token/token_handler_test.go | 763 ++++++++++++++++-- internal/oidc/token_exchange.go | 22 +- internal/testutil/assertions.go | 18 + internal/testutil/oidcclient.go | 67 ++ internal/testutil/oidcclient_test.go | 61 ++ test/integration/supervisor_login_test.go | 5 +- .../supervisor_oidc_client_test.go | 2 +- test/integration/supervisor_warnings_test.go | 5 +- 19 files changed, 1007 insertions(+), 199 deletions(-) create mode 100644 internal/testutil/oidcclient.go create mode 100644 internal/testutil/oidcclient_test.go diff --git a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go index 12123731..041e5c94 100644 --- a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go +++ b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher.go @@ -107,7 +107,7 @@ func (c *oidcClientWatcherController) Sync(ctx controllerlib.Context) error { secret = nil } - _, conditions, clientSecrets := oidcclientvalidator.Validate(oidcClient, secret) + _, conditions, clientSecrets := oidcclientvalidator.Validate(oidcClient, secret, oidcclientvalidator.DefaultMinBcryptCost) if err := c.updateStatus(ctx.Context, oidcClient, conditions, len(clientSecrets)); err != nil { return fmt.Errorf("cannot update OIDCClient '%s/%s': %w", oidcClient.Namespace, oidcClient.Name, err) diff --git a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go index b1d147fe..05ea4fd8 100644 --- a/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go +++ b/internal/controller/supervisorconfig/oidcclientwatcher/oidc_client_watcher_test.go @@ -164,15 +164,6 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { testName = "client.oauth.pinniped.dev-test-name" testNamespace = "test-namespace" testUID = "test-uid-123" - - //nolint:gosec // this is not a credential - testBcryptSecret1 = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password1" at cost 15 - //nolint:gosec // this is not a credential - testBcryptSecret2 = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password2" at cost 15 - //nolint:gosec // this is not a credential - testInvalidBcryptSecretCostTooLow = "$2y$14$njwk1cItiRy6cb6u9aiJLuhtJG83zM9111t.xU6MxvnqqYbkXxzwy" // bcrypt of "password1" at cost 14 - //nolint:gosec // this is not a credential - testInvalidBcryptSecretInvalidFormat = "$2y$14$njwk1cItiRy6cb6u9aiJLuhtJG83zM9111t.xU6MxvnqqYbkXxz" // not enough characters in hash value ) now := metav1.NewTime(time.Now().UTC()) @@ -291,7 +282,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }, }, }, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{ { @@ -320,7 +311,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1, testBcryptSecret2})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost, testutil.HashedPassword2AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -353,7 +344,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { TotalClientSecrets: 1, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 0, // no updates wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -445,7 +436,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }}, inputSecrets: []runtime.Object{ testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, - []string{testBcryptSecret1, testInvalidBcryptSecretCostTooLow, testInvalidBcryptSecretInvalidFormat}), + []string{testutil.HashedPassword1AtSupervisorMinCost, testutil.HashedPassword1JustBelowSupervisorMinCost, testutil.HashedPassword1InvalidFormat}), }, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ @@ -457,7 +448,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { happyAllowedScopesCondition(now, 1234), sadInvalidClientSecretsCondition(now, 1234, "3 stored client secrets found, but some were invalid, so none will be used: "+ - "hashed client secret at index 1: bcrypt cost 14 is below the required minimum of 15; "+ + "hashed client secret at index 1: bcrypt cost 11 is below the required minimum of 12; "+ "hashed client secret at index 2: crypto/bcrypt: hashedSecret too short to be a bcrypted password"), }, TotalClientSecrets: 0, @@ -479,7 +470,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { Spec: configv1alpha1.OIDCClientSpec{}, }, }, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, "uid1", []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, "uid1", []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 2, // one update for each OIDCClient wantResultingOIDCClients: []configv1alpha1.OIDCClient{ { @@ -527,7 +518,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { TotalClientSecrets: 1, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 4567, UID: testUID}, @@ -553,7 +544,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }, }}, wantAPIActions: 1, // one update - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Status: configv1alpha1.OIDCClientStatus{ @@ -577,7 +568,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }, }}, wantAPIActions: 1, // one update - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Status: configv1alpha1.OIDCClientStatus{ @@ -606,7 +597,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { }, }}, wantAPIActions: 1, // one update - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, Status: configv1alpha1.OIDCClientStatus{ @@ -633,7 +624,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "username", "groups"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -657,7 +648,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -681,7 +672,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -705,7 +696,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "groups"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -729,7 +720,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "pinniped:request-audience", "username"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -753,7 +744,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -777,7 +768,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -801,7 +792,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -825,7 +816,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -849,7 +840,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "groups"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -873,7 +864,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username", "groups"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -897,7 +888,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "username"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -921,7 +912,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "username"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, @@ -945,7 +936,7 @@ func TestOIDCClientWatcherControllerSync(t *testing.T) { AllowedScopes: []configv1alpha1.Scope{"openid", "username", "groups"}, }, }}, - inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testBcryptSecret1})}, + inputSecrets: []runtime.Object{testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost})}, wantAPIActions: 1, // one update wantResultingOIDCClients: []configv1alpha1.OIDCClient{{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: testName, Generation: 1234, UID: testUID}, diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 2cc98471..768ab10f 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -19,6 +19,7 @@ import ( "github.com/gorilla/securecookie" "github.com/ory/fosite" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apiserver/pkg/authentication/user" @@ -27,7 +28,6 @@ import ( kubetesting "k8s.io/client-go/testing" "k8s.io/utils/pointer" - configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/typed/config/v1alpha1" "go.pinniped.dev/internal/authenticators" @@ -79,8 +79,6 @@ func TestAuthorizationEndpoint(t *testing.T) { pinnipedCLIClientID = "pinniped-cli" dynamicClientID = "client.oauth.pinniped.dev-test-name" dynamicClientUID = "fake-client-uid" - //nolint:gosec // this is not a credential - dynamicClientHashedSecret = "$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m" // bcrypt of "password1" at cost 15 ) require.Len(t, happyState, 8, "we expect fosite to allow 8 byte state params, so we want to test that boundary case") @@ -237,13 +235,15 @@ func TestAuthorizationEndpoint(t *testing.T) { createOauthHelperWithRealStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *oidc.KubeStorage) { // Configure fosite the same way that the production code would when using Kube storage. // Inject this into our test subject at the last second so we get a fresh storage for every test. - kubeOauthStore := oidc.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration) + // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. + kubeOauthStore := oidc.NewKubeStorage(secretsClient, oidcClientsClient, timeoutsConfiguration, bcrypt.MinCost) return oidc.FositeOauth2Helper(kubeOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), kubeOauthStore } createOauthHelperWithNullStorage := func(secretsClient v1.SecretInterface, oidcClientsClient v1alpha1.OIDCClientInterface) (fosite.OAuth2Provider, *oidc.NullStorage) { // Configure fosite the same way that the production code would, using NullStorage to turn off storage. - nullOauthStore := oidc.NewNullStorage(secretsClient, oidcClientsClient) + // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. + nullOauthStore := oidc.NewNullStorage(secretsClient, oidcClientsClient, bcrypt.MinCost) return oidc.FositeOauth2Helper(nullOauthStore, downstreamIssuer, hmacSecretFunc, jwksProviderIsUnused, timeoutsConfiguration), nullOauthStore } @@ -511,24 +511,11 @@ func TestAuthorizationEndpoint(t *testing.T) { }, } - fullyCapableDynamicClient := &configv1alpha1.OIDCClient{ - ObjectMeta: metav1.ObjectMeta{Namespace: "some-namespace", Name: dynamicClientID, Generation: 1, UID: dynamicClientUID}, - Spec: configv1alpha1.OIDCClientSpec{ - AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, - AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, - AllowedRedirectURIs: []configv1alpha1.RedirectURI{downstreamRedirectURI}, - }, - } - - allDynamicClientScopes := "openid offline_access pinniped:request-audience username groups" - - storageSecretWithOneClientSecretForDynamicClient := testutil.OIDCClientSecretStorageSecretForUID(t, - "some-namespace", dynamicClientUID, []string{dynamicClientHashedSecret}, - ) - addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { - require.NoError(t, supervisorClient.Tracker().Add(fullyCapableDynamicClient)) - require.NoError(t, kubeClient.Tracker().Add(storageSecretWithOneClientSecretForDynamicClient)) + oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, + "some-namespace", dynamicClientID, dynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) } // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it @@ -611,11 +598,11 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", oidcUpstreamName, "oidc"), nil), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -646,11 +633,11 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", ldapUpstreamName, "ldap")}), + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", ldapUpstreamName, "ldap")}), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -681,11 +668,11 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", activeDirectoryUpstreamName, "activedirectory")}), + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", activeDirectoryUpstreamName, "activedirectory")}), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, }, @@ -835,12 +822,12 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})), + body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", oidcUpstreamName, "oidc"), nil), + wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, }, { @@ -874,12 +861,12 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})), + body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", ldapUpstreamName, "ldap")}), + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", ldapUpstreamName, "ldap")}), wantUpstreamStateParamInLocationHeader: true, }, { @@ -913,12 +900,12 @@ func TestAuthorizationEndpoint(t *testing.T) { method: http.MethodPost, path: "/some/path", contentType: formContentType, - body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes})), + body: encodeQuery(modifiedHappyGetRequestQueryMap(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep})), wantStatus: http.StatusSeeOther, wantContentType: "", wantBodyString: "", wantCSRFValueInCookieHeader: happyCSRF, - wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}, "", activeDirectoryUpstreamName, "activedirectory")}), + wantLocationHeader: urlWithQuery(downstreamIssuer+"/login", map[string]string{"state": expectedUpstreamStateParam(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}, "", activeDirectoryUpstreamName, "activedirectory")}), wantUpstreamStateParamInLocationHeader: true, }, { @@ -1111,7 +1098,7 @@ func TestAuthorizationEndpoint(t *testing.T) { path: modifiedHappyGetRequestPath(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client "client_id": dynamicClientID, - "scope": allDynamicClientScopes, + "scope": testutil.AllDynamicClientScopesSpaceSep, }), wantStatus: http.StatusSeeOther, wantContentType: htmlContentType, @@ -1119,7 +1106,7 @@ func TestAuthorizationEndpoint(t *testing.T) { wantLocationHeader: expectedRedirectLocationForUpstreamOIDC(expectedUpstreamStateParam(map[string]string{ "redirect_uri": downstreamRedirectURIWithDifferentPort, // not the same port number that is registered for the client "client_id": dynamicClientID, - "scope": allDynamicClientScopes, + "scope": testutil.AllDynamicClientScopesSpaceSep, }, "", oidcUpstreamName, "oidc"), nil), wantUpstreamStateParamInLocationHeader: true, wantBodyStringWithLocationInHref: true, @@ -1526,7 +1513,7 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(passwordGrantUpstreamOIDCIdentityProviderBuilder().Build()), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), customUsernameHeader: pointer.StringPtr(oidcUpstreamUsername), customPasswordHeader: pointer.StringPtr(oidcUpstreamPassword), wantStatus: http.StatusFound, @@ -1539,7 +1526,7 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1552,7 +1539,7 @@ func TestAuthorizationEndpoint(t *testing.T) { idps: oidctestutil.NewUpstreamIDPListerBuilder().WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": allDynamicClientScopes}), + path: modifiedHappyGetRequestPath(map[string]string{"client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), customUsernameHeader: pointer.StringPtr(happyLDAPUsername), customPasswordHeader: pointer.StringPtr(happyLDAPPassword), wantStatus: http.StatusFound, @@ -1589,7 +1576,7 @@ func TestAuthorizationEndpoint(t *testing.T) { path: modifiedHappyGetRequestPath(map[string]string{ "redirect_uri": "http://127.0.0.1/does-not-match-what-is-configured-for-dynamic-client", "client_id": dynamicClientID, - "scope": allDynamicClientScopes, + "scope": testutil.AllDynamicClientScopesSpaceSep, }), wantStatus: http.StatusBadRequest, wantContentType: jsonContentType, @@ -1705,7 +1692,7 @@ func TestAuthorizationEndpoint(t *testing.T) { path: modifiedHappyGetRequestPath(map[string]string{ "response_type": "unsupported", "client_id": dynamicClientID, - "scope": allDynamicClientScopes, + "scope": testutil.AllDynamicClientScopesSpaceSep, }), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, @@ -1754,7 +1741,7 @@ func TestAuthorizationEndpoint(t *testing.T) { path: modifiedHappyGetRequestPath(map[string]string{ "response_type": "unsupported", "client_id": dynamicClientID, - "scope": allDynamicClientScopes, + "scope": testutil.AllDynamicClientScopesSpaceSep, }), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, @@ -1791,7 +1778,7 @@ func TestAuthorizationEndpoint(t *testing.T) { path: modifiedHappyGetRequestPath(map[string]string{ "response_type": "unsupported", "client_id": dynamicClientID, - "scope": allDynamicClientScopes, + "scope": testutil.AllDynamicClientScopesSpaceSep, }), wantStatus: http.StatusSeeOther, wantContentType: jsonContentType, @@ -1865,7 +1852,7 @@ func TestAuthorizationEndpoint(t *testing.T) { stateEncoder: happyStateEncoder, cookieEncoder: happyCookieEncoder, method: http.MethodGet, - path: modifiedHappyGetRequestPath(map[string]string{"response_mode": "form_post", "client_id": dynamicClientID, "scope": allDynamicClientScopes}), + path: modifiedHappyGetRequestPath(map[string]string{"response_mode": "form_post", "client_id": dynamicClientID, "scope": testutil.AllDynamicClientScopesSpaceSep}), wantStatus: http.StatusOK, // this is weird, but fosite uses a form_post response to tell the client that it is not allowed to use form_post responses wantContentType: htmlContentType, wantBodyRegex: ` 0 { diff --git a/internal/oidc/provider/manager/manager.go b/internal/oidc/provider/manager/manager.go index 83a91d07..47d25981 100644 --- a/internal/oidc/provider/manager/manager.go +++ b/internal/oidc/provider/manager/manager.go @@ -20,6 +20,7 @@ import ( "go.pinniped.dev/internal/oidc/idpdiscovery" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/login" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/oidc/provider" "go.pinniped.dev/internal/oidc/token" "go.pinniped.dev/internal/plog" @@ -98,7 +99,7 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs // Use NullStorage for the authorize endpoint because we do not actually want to store anything until // the upstream callback endpoint is called later. oauthHelperWithNullStorage := oidc.FositeOauth2Helper( - oidc.NewNullStorage(m.secretsClient, m.oidcClientsClient), + oidc.NewNullStorage(m.secretsClient, m.oidcClientsClient, oidcclientvalidator.DefaultMinBcryptCost), issuer, tokenHMACKeyGetter, nil, @@ -107,7 +108,7 @@ func (m *Manager) SetProviders(federationDomains ...*provider.FederationDomainIs // For all the other endpoints, make another oauth helper with exactly the same settings except use real storage. oauthHelperWithKubeStorage := oidc.FositeOauth2Helper( - oidc.NewKubeStorage(m.secretsClient, m.oidcClientsClient, timeoutsConfiguration), + oidc.NewKubeStorage(m.secretsClient, m.oidcClientsClient, timeoutsConfiguration, oidcclientvalidator.DefaultMinBcryptCost), issuer, tokenHMACKeyGetter, m.dynamicJWKSProvider, diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index 5bdd3688..b22e8ad4 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -29,14 +29,17 @@ import ( "github.com/ory/fosite/token/jwt" "github.com/pkg/errors" "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "gopkg.in/square/go-jose.v2" josejwt "gopkg.in/square/go-jose.v2/jwt" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/fake" v1 "k8s.io/client-go/kubernetes/typed/core/v1" + configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/fositestorage/accesstoken" @@ -50,6 +53,7 @@ import ( "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/oidc/jwks" "go.pinniped.dev/internal/oidc/provider" + "go.pinniped.dev/internal/oidcclientsecretstorage" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/internal/testutil/oidctestutil" @@ -59,20 +63,23 @@ import ( const ( goodIssuer = "https://some-issuer.com" goodUpstreamSubject = "some-subject" - goodClient = "pinniped-cli" goodRedirectURI = "http://127.0.0.1/callback" goodPKCECodeVerifier = "some-pkce-verifier-that-must-be-at-least-43-characters-to-meet-entropy-requirements" goodNonce = "some-nonce-value-with-enough-bytes-to-exceed-min-allowed" goodSubject = "https://issuer?sub=some-subject" goodUsername = "some-username" + pinnipedCLIClientID = "pinniped-cli" + dynamicClientID = "client.oauth.pinniped.dev-test-name" + dynamicClientUID = "fake-client-uid" + hmacSecret = "this needs to be at least 32 characters to meet entropy requirements" authCodeExpirationSeconds = 10 * 60 // Current, we set our auth code expiration to 10 minutes accessTokenExpirationSeconds = 2 * 60 // Currently, we set our access token expiration to 2 minutes idTokenExpirationSeconds = 2 * 60 // Currently, we set our ID token expiration to 2 minutes - timeComparisonFudgeSeconds = 15 + timeComparisonFudge = 15 * time.Second ) var ( @@ -156,6 +163,20 @@ var ( } `) + fositeClientIDMismatchDuringAuthcodeExchangeErrorBody = here.Doc(` + { + "error": "invalid_grant", + "error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client. The OAuth 2.0 Client ID from this request does not match the one from the authorize request." + } + `) + + fositeClientIDMismatchDuringRefreshErrorBody = here.Doc(` + { + "error": "invalid_grant", + "error_description": "The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client. The OAuth 2.0 Client ID from this request does not match the ID during the initial token issuance." + } + `) + fositeInvalidRedirectURIErrorBody = here.Doc(` { "error": "invalid_grant", @@ -198,11 +219,25 @@ var ( } `) + fositeClientAuthFailedErrorBody = here.Doc(` + { + "error": "invalid_client", + "error_description": "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method)." + } + `) + + fositeClientAuthMustBeBasicAuthErrorBody = here.Doc(` + { + "error": "invalid_client", + "error_description": "Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The OAuth 2.0 Client supports client authentication method 'client_secret_basic', but method 'client_secret_post' was requested. You must configure the OAuth 2.0 client's 'token_endpoint_auth_method' value to accept 'client_secret_post'." + } + `) + happyAuthRequest = &http.Request{ Form: url.Values{ "response_type": {"code"}, "scope": {"openid profile email groups"}, - "client_id": {goodClient}, + "client_id": {pinnipedCLIClientID}, "state": {"some-state-value-with-enough-bytes-to-exceed-min-allowed"}, "nonce": {goodNonce}, "code_challenge": {testutil.SHA256(goodPKCECodeVerifier)}, @@ -219,7 +254,7 @@ var ( "subject_token": {subjectToken}, "subject_token_type": {"urn:ietf:params:oauth:token-type:access_token"}, "requested_token_type": {"urn:ietf:params:oauth:token-type:jwt"}, - "client_id": {goodClient}, + "client_id": {pinnipedCLIClientID}, }, } } @@ -239,6 +274,7 @@ type tokenEndpointResponseExpectedValues struct { wantStatus int wantSuccessBodyFields []string wantErrorResponseBody string + wantClientID string wantRequestedScopes []string wantGrantedScopes []string wantGroups []string @@ -260,10 +296,32 @@ type authcodeExchangeInputs struct { want tokenEndpointResponseExpectedValues } +func addFullyCapableDynamicClientAndSecretToKubeResources(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, + "some-namespace", + dynamicClientID, + dynamicClientUID, + goodRedirectURI, + []string{testutil.HashedPassword1AtGoMinCost, testutil.HashedPassword2AtGoMinCost}, + ) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) +} + +func modifyAuthcodeTokenRequestWithDynamicClientAuth(r *http.Request, authCode string) { + r.Body = happyAuthcodeRequestBody(authCode).WithClientID("").ReadCloser() // No client_id in body. + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) // Use basic auth header instead. +} + +func addDynamicClientIDToFormPostBody(r *http.Request) { + r.Form.Set("client_id", dynamicClientID) +} + func TestTokenEndpointAuthcodeExchange(t *testing.T) { tests := []struct { name string authcodeExchange authcodeExchangeInputs + kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) }{ // happy path { @@ -272,6 +330,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid profile email groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token wantRequestedScopes: []string{"openid", "profile", "email", "groups"}, wantGrantedScopes: []string{"openid", "groups"}, @@ -279,25 +338,84 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { }, }, }, + { + name: "request is valid and tokens are issued for dynamic client", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid pinniped:request-audience groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token + wantRequestedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantGroups: goodGroups, + }, + }, + }, { name: "openid scope was not requested from authorize endpoint", authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "profile email") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens wantRequestedScopes: []string{"profile", "email"}, wantGrantedScopes: []string{}, - wantGroups: goodGroups, + wantGroups: nil, }, }, }, { - name: "offline_access and openid scopes were requested and granted from authorize endpoint", + name: "openid scope was not requested from authorize endpoint for dynamic client", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "pinniped:request-audience groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens + wantRequestedScopes: []string{"pinniped:request-audience", "groups"}, + wantGrantedScopes: []string{"pinniped:request-audience", "groups"}, + wantGroups: nil, + }, + }, + }, + { + name: "offline_access and openid scopes were requested and granted from authorize endpoint (no groups)", authcodeExchange: authcodeExchangeInputs{ modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens + wantRequestedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access"}, + wantGroups: nil, + }, + }, + }, + { + name: "offline_access and openid scopes were requested and granted from authorize endpoint for dynamic client (no groups)", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens wantRequestedScopes: []string{"openid", "offline_access"}, wantGrantedScopes: []string{"openid", "offline_access"}, @@ -311,10 +429,30 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token wantRequestedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"}, - wantGroups: goodGroups, + wantGroups: nil, + }, + }, + }, + { + name: "offline_access (without openid scope) was requested and granted from authorize endpoint for dynamic client", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "offline_access") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token + wantRequestedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access"}, + wantGroups: nil, }, }, }, @@ -324,6 +462,7 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid profile email groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token wantRequestedScopes: []string{"openid", "profile", "email", "groups"}, wantGrantedScopes: []string{"openid", "groups"}, @@ -331,6 +470,28 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { }, }, }, + { + name: "dynamic client uses a secondary client secret (one of the other client secrets after the first one in the list)", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid pinniped:request-audience groups") + }, + modifyTokenRequest: func(r *http.Request, authCode string) { + r.Body = happyAuthcodeRequestBody(authCode).WithClientID("").ReadCloser() + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword2) // use the second client secret that was configured on the client + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token + wantRequestedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantGroups: goodGroups, + }, + }, + }, // sad path { @@ -373,6 +534,57 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { }, }, }, + { + name: "dynamic client uses wrong client secret", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid pinniped:request-audience groups") + }, + modifyTokenRequest: func(r *http.Request, authCode string) { + r.Body = happyAuthcodeRequestBody(authCode).WithClientID("").ReadCloser() + r.SetBasicAuth(dynamicClientID, "wrong client secret") + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: fositeClientAuthFailedErrorBody, + }, + }, + }, + { + name: "dynamic client uses wrong auth method (must use basic auth)", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid pinniped:request-audience groups") + }, + modifyTokenRequest: func(r *http.Request, authCode string) { + // Add client auth to the form, when it should be in basic auth headers. + r.Body = happyAuthcodeRequestBody(authCode).WithClientID(dynamicClientID).WithClientSecret(testutil.PlaintextPassword1).ReadCloser() + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: fositeClientAuthMustBeBasicAuthErrorBody, + }, + }, + }, + { + name: "tries to change client ID between authorization request and token request", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + // Test uses pinniped-cli client_id by default here. + r.Form.Set("scope", "openid pinniped:request-audience") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeClientIDMismatchDuringAuthcodeExchangeErrorBody, + }, + }, + }, { name: "content type is invalid", authcodeExchange: authcodeExchangeInputs{ @@ -417,18 +629,6 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { }, }, }, - { - name: "grant type is not authorization_code", - authcodeExchange: authcodeExchangeInputs{ - modifyTokenRequest: func(r *http.Request, authCode string) { - r.Body = happyAuthcodeRequestBody(authCode).WithGrantType("bogus").ReadCloser() - }, - want: tokenEndpointResponseExpectedValues{ - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidRequestErrorBody, - }, - }, - }, { name: "client id is missing in request", authcodeExchange: authcodeExchangeInputs{ @@ -568,7 +768,8 @@ func TestTokenEndpointAuthcodeExchange(t *testing.T) { t.Parallel() // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. - exchangeAuthcodeForTokens(t, test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build()) + exchangeAuthcodeForTokens(t, + test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build(), test.kubeResources) }) } } @@ -577,6 +778,7 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { tests := []struct { name string authcodeExchange authcodeExchangeInputs + kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) }{ { name: "authcode exchange succeeds once and then fails when the same authcode is used again", @@ -584,6 +786,7 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access profile email groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "profile", "email", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -600,7 +803,7 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { // First call - should be successful. // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. subject, rsp, authCode, _, secrets, oauthStore := exchangeAuthcodeForTokens(t, - test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build()) + test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build(), test.kubeResources) var parsedResponseBody map[string]interface{} require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) @@ -611,6 +814,7 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyAuthcodeRequestBody(authCode).ReadCloser()) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") reusedAuthcodeResponse := httptest.NewRecorder() + approxRequestTime := time.Now() subject.ServeHTTP(reusedAuthcodeResponse, req) t.Logf("second response: %#v", reusedAuthcodeResponse) t.Logf("second response body: %q", reusedAuthcodeResponse.Body.String()) @@ -619,7 +823,7 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { require.JSONEq(t, fositeReusedAuthCodeErrorBody, reusedAuthcodeResponse.Body.String()) // This was previously invalidated by the first request, so it remains invalidated - requireInvalidAuthCodeStorage(t, authCode, oauthStore, secrets) + requireInvalidAuthCodeStorage(t, authCode, oauthStore, secrets, approxRequestTime) // Has now invalidated the access token that was previously handed out by the first request requireInvalidAccessTokenStorage(t, parsedResponseBody, oauthStore) // This was previously invalidated by the first request, so it remains invalidated @@ -628,7 +832,9 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { // Note that customSessionData is only relevant to refresh grant, so we leave it as nil for this // authcode exchange test, even though in practice it would actually be in the session. requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, - test.authcodeExchange.want.wantRequestedScopes, test.authcodeExchange.want.wantGrantedScopes, test.authcodeExchange.want.wantGroups, nil) + test.authcodeExchange.want.wantClientID, test.authcodeExchange.want.wantRequestedScopes, + test.authcodeExchange.want.wantGrantedScopes, test.authcodeExchange.want.wantGroups, nil, + approxRequestTime) // Check that the access token and refresh token storage were both deleted, and the number of other storage objects did not change. testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -636,7 +842,8 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 0) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, 0) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0) - testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2) + // Assert the number of all secrets, excluding any OIDCClient's storage secret, since those are not related to session storage. + testutil.RequireNumberOfSecretsExcludingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: oidcclientsecretstorage.TypeLabelValue}, 2) }) } } @@ -644,6 +851,16 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn:ietf:params:oauth:grant-type:token-exchange" successfulAuthCodeExchange := tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantGrantedScopes: []string{"openid", "pinniped:request-audience", "groups"}, + wantGroups: goodGroups, + } + + successfulAuthCodeExchangeUsingDynamicClient := tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "pinniped:request-audience", "groups"}, wantGrantedScopes: []string{"openid", "pinniped:request-audience", "groups"}, @@ -657,13 +874,24 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn want: successfulAuthCodeExchange, } + doValidAuthCodeExchangeUsingDynamicClient := authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + addDynamicClientIDToFormPostBody(authRequest) + authRequest.Form.Set("scope", "openid pinniped:request-audience groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: successfulAuthCodeExchangeUsingDynamicClient, + } + tests := []struct { name string - authcodeExchange authcodeExchangeInputs - modifyRequestParams func(t *testing.T, params url.Values) - modifyStorage func(t *testing.T, storage *oidc.KubeStorage, pendingRequest *http.Request) - requestedAudience string + authcodeExchange authcodeExchangeInputs + modifyRequestParams func(t *testing.T, params url.Values) + modifyRequestHeaders func(r *http.Request) + modifyStorage func(t *testing.T, storage *oidc.KubeStorage, pendingRequest *http.Request) + requestedAudience string + kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) wantStatus int wantResponseBodyContains string @@ -674,6 +902,116 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn requestedAudience: "some-workload-cluster", wantStatus: http.StatusOK, }, + { + name: "happy path with dynamic client", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: doValidAuthCodeExchangeUsingDynamicClient, + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("client_id") // client auth for dynamic clients must be in basic auth header + }, + modifyRequestHeaders: func(r *http.Request) { + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusOK, + }, + { + name: "dynamic client lacks the required urn:ietf:params:oauth:grant-type:token-exchange grant type", + kubeResources: func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + namespace, clientID, clientUID, redirectURI := "some-namespace", dynamicClientID, dynamicClientUID, goodRedirectURI + oidcClient := &configv1alpha1.OIDCClient{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: clientID, Generation: 1, UID: types.UID(clientUID)}, + Spec: configv1alpha1.OIDCClientSpec{ + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "refresh_token"}, // does not have the grant type + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "username", "groups"}, // would be invalid if it also asked for pinniped:request-audience since it lacks the grant type + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(redirectURI)}, + }, + } + secret := testutil.OIDCClientSecretStorageSecretForUID(t, namespace, clientUID, []string{testutil.HashedPassword1AtGoMinCost, testutil.HashedPassword2AtGoMinCost}) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + }, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + addDynamicClientIDToFormPostBody(authRequest) + authRequest.Form.Set("scope", "openid groups") // don't request pinniped:request-audience scope + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "groups"}, // don't want pinniped:request-audience scope + wantGrantedScopes: []string{"openid", "groups"}, // don't want pinniped:request-audience scope + wantGroups: goodGroups, + }, + }, + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("client_id") // client auth for dynamic clients must be in basic auth header + }, + modifyRequestHeaders: func(r *http.Request) { + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusBadRequest, + wantResponseBodyContains: `The client is not authorized to request a token using this method. The OAuth 2.0 Client is not allowed to use token exchange grant 'urn:ietf:params:oauth:grant-type:token-exchange'.`, + }, + { + name: "dynamic client did not ask for the pinniped:request-audience scope in the original authorization request, so the access token submitted during token exchange lacks the scope", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + addDynamicClientIDToFormPostBody(authRequest) + authRequest.Form.Set("scope", "openid groups") // don't request pinniped:request-audience scope + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "groups"}, // don't want pinniped:request-audience scope + wantGrantedScopes: []string{"openid", "groups"}, // don't want pinniped:request-audience scope + wantGroups: goodGroups, + }, + }, + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("client_id") // client auth for dynamic clients must be in basic auth header + }, + modifyRequestHeaders: func(r *http.Request) { + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusForbidden, + wantResponseBodyContains: `The resource owner or authorization server denied the request. missing the 'pinniped:request-audience' scope`, + }, + { + name: "dynamic client did not ask for the openid scope in the original authorization request, so the access token submitted during token exchange lacks the scope", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + addDynamicClientIDToFormPostBody(authRequest) + authRequest.Form.Set("scope", "pinniped:request-audience groups") // don't request openid scope + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"access_token", "token_type", "expires_in", "scope"}, // no id token + wantRequestedScopes: []string{"pinniped:request-audience", "groups"}, // don't want openid scope + wantGrantedScopes: []string{"pinniped:request-audience", "groups"}, // don't want openid scope + wantGroups: nil, + }, + }, + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("client_id") // client auth for dynamic clients must be in basic auth header + }, + modifyRequestHeaders: func(r *http.Request) { + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusForbidden, + wantResponseBodyContains: `The resource owner or authorization server denied the request. missing the 'openid' scope`, + }, { name: "missing audience", authcodeExchange: doValidAuthCodeExchange, @@ -752,6 +1090,60 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn wantStatus: http.StatusBadRequest, wantResponseBodyContains: `Invalid token format`, }, + { + name: "bad client ID", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "some-workload-cluster", + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Set("client_id", "some-bogus-value") + }, + wantStatus: http.StatusUnauthorized, + wantResponseBodyContains: `Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method).`, + }, + { + name: "dynamic client uses wrong client secret", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: doValidAuthCodeExchangeUsingDynamicClient, + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("client_id") // client auth for dynamic clients must be in basic auth header + }, + modifyRequestHeaders: func(r *http.Request) { + r.SetBasicAuth(dynamicClientID, "bad client secret") + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusUnauthorized, + wantResponseBodyContains: `Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method).`, + }, + { + name: "dynamic client uses wrong auth method (must use basic auth)", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: doValidAuthCodeExchangeUsingDynamicClient, + modifyRequestParams: func(t *testing.T, params url.Values) { + // Dynamic clients do not support this method of auth. + params.Set("client_id", dynamicClientID) + params.Set("client_secret", testutil.PlaintextPassword1) + }, + modifyRequestHeaders: func(r *http.Request) { + // would usually set the basic auth header here, but we don't for this test case + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusUnauthorized, + wantResponseBodyContains: `Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method). The OAuth 2.0 Client supports client authentication method 'client_secret_basic', but method 'client_secret_post' was requested. You must configure the OAuth 2.0 client's 'token_endpoint_auth_method' value to accept 'client_secret_post'.`, + }, + { + name: "different client used between authorize/authcode calls and the call to token exchange", + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: doValidAuthCodeExchange, // use pinniped-cli for authorize and authcode exchange + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("client_id") // client auth for dynamic clients must be in basic auth header + }, + modifyRequestHeaders: func(r *http.Request) { + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) // use dynamic client for token exchange + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusBadRequest, + wantResponseBodyContains: `The provided authorization grant (e.g., authorization code, resource owner credentials) or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client. The OAuth 2.0 Client ID from this request does not match the one from the authorize request.`, + }, { name: "valid access token, but deleted from storage", authcodeExchange: doValidAuthCodeExchange, @@ -772,6 +1164,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "groups"}, wantGrantedScopes: []string{"openid", "groups"}, @@ -790,10 +1183,11 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"pinniped:request-audience", "groups"}, wantGrantedScopes: []string{"pinniped:request-audience", "groups"}, - wantGroups: goodGroups, + wantGroups: nil, }, }, requestedAudience: "some-workload-cluster", @@ -808,6 +1202,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"access_token", "token_type", "expires_in", "scope", "id_token"}, wantRequestedScopes: []string{"openid", "pinniped:request-audience"}, wantGrantedScopes: []string{"openid", "pinniped:request-audience"}, @@ -839,7 +1234,7 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn // Authcode exchange doesn't use the upstream provider cache, so just pass an empty cache. subject, rsp, _, _, secrets, storage := exchangeAuthcodeForTokens(t, - test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build()) + test.authcodeExchange, oidctestutil.NewUpstreamIDPListerBuilder().Build(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]interface{} require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -855,6 +1250,10 @@ func TestTokenEndpointTokenExchange(t *testing.T) { // tests for grant_type "urn req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rsp = httptest.NewRecorder() + if test.modifyRequestHeaders != nil { + test.modifyRequestHeaders(req) + } + // Measure the secrets in storage after the auth code flow. existingSecrets, err := secrets.List(context.Background(), metav1.ListOptions{}) require.NoError(t, err) @@ -1062,6 +1461,7 @@ func TestRefreshGrant(t *testing.T) { happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess := func(wantCustomSessionDataStored *psession.CustomSessionData) tokenEndpointResponseExpectedValues { want := tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1071,6 +1471,16 @@ func TestRefreshGrant(t *testing.T) { return want } + withWantDynamicClientID := func(w tokenEndpointResponseExpectedValues) tokenEndpointResponseExpectedValues { + w.wantClientID = dynamicClientID + return w + } + + modifyRefreshTokenRequestWithDynamicClientAuth := func(tokenRequest *http.Request, refreshToken string, accessToken string) { + tokenRequest.Body = happyRefreshRequestBody(refreshToken).WithClientID("").ReadCloser() // No client_id in body. + tokenRequest.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) // Use basic auth header instead. + } + happyRefreshTokenResponseForOpenIDAndOfflineAccess := func(wantCustomSessionDataStored *psession.CustomSessionData, expectToValidateToken *oauth2.Token) tokenEndpointResponseExpectedValues { // Should always have some custom session data stored. The other expectations happens to be the // same as the same values as the authcode exchange case. @@ -1134,6 +1544,7 @@ func TestRefreshGrant(t *testing.T) { tests := []struct { name string idps *oidctestutil.UpstreamIDPListerBuilder + kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) authcodeExchange authcodeExchangeInputs refreshRequest refreshRequestInputs modifyRefreshTokenStorage func(t *testing.T, oauthStore *oidc.KubeStorage, refreshToken string) @@ -1160,6 +1571,34 @@ func TestRefreshGrant(t *testing.T) { ), }, }, + { + name: "happy path refresh grant with openid scope granted (id token returned) using dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Claims: map[string]interface{}{ + "sub": goodUpstreamSubject, + }, + }, + }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData())), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(happyRefreshTokenResponseForOpenIDAndOfflineAccess( + upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + refreshedUpstreamTokensWithIDAndRefreshTokens(), + )), + }, + }, { name: "refresh grant with unchanged username claim", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( @@ -1208,6 +1647,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "id_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1239,6 +1679,7 @@ func TestRefreshGrant(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"}, @@ -1248,6 +1689,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"}, @@ -1273,6 +1715,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1302,6 +1745,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1331,6 +1775,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1360,6 +1805,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1389,6 +1835,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1417,6 +1864,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1444,6 +1892,7 @@ func TestRefreshGrant(t *testing.T) { refreshRequest: refreshRequestInputs{ want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1466,6 +1915,7 @@ func TestRefreshGrant(t *testing.T) { customSessionData: happyLDAPCustomSessionData, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access"}, wantGrantedScopes: []string{"openid", "offline_access"}, @@ -1479,6 +1929,7 @@ func TestRefreshGrant(t *testing.T) { }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access"}, wantGrantedScopes: []string{"openid", "offline_access"}, @@ -1504,6 +1955,7 @@ func TestRefreshGrant(t *testing.T) { customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access"}, wantGrantedScopes: []string{"openid", "offline_access"}, @@ -1517,6 +1969,54 @@ func TestRefreshGrant(t *testing.T) { }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, + wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access"}, + wantGroups: nil, + wantUpstreamRefreshCall: happyOIDCUpstreamRefreshCall(), + wantUpstreamOIDCValidateTokenCall: happyUpstreamValidateTokenCall(refreshedUpstreamTokensWithIDAndRefreshTokens(), true), + wantCustomSessionDataStored: upstreamOIDCCustomSessionDataWithNewRefreshToken(oidcUpstreamRefreshedRefreshToken), + }, + }, + }, + { + name: "oidc refresh grant when the upstream refresh when groups scope not requested on original request or refresh when using dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithGroupsClaim("my-groups-claim").WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Claims: map[string]interface{}{ + "sub": goodUpstreamSubject, + "my-groups-claim": []string{"new-group1", "new-group2", "new-group3"}, // refreshed claims includes updated groups + }, + }, + }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access"}, + wantCustomSessionDataStored: initialUpstreamOIDCRefreshTokenCustomSessionData(), + wantGroups: nil, + }, + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { + r.Body = happyRefreshRequestBody(refreshToken).WithClientID("").WithScope("openid offline_access").ReadCloser() + r.SetBasicAuth(dynamicClientID, testutil.PlaintextPassword1) // Use basic auth header instead. + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantClientID: dynamicClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access"}, wantGrantedScopes: []string{"openid", "offline_access"}, @@ -1542,6 +2042,7 @@ func TestRefreshGrant(t *testing.T) { customSessionData: happyLDAPCustomSessionData, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1555,6 +2056,7 @@ func TestRefreshGrant(t *testing.T) { }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "id_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "groups"}, @@ -1651,6 +2153,7 @@ func TestRefreshGrant(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access pinniped:request-audience groups") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "groups"}, @@ -1664,6 +2167,7 @@ func TestRefreshGrant(t *testing.T) { }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "groups"}, wantGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience", "groups"}, @@ -1707,6 +2211,7 @@ func TestRefreshGrant(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"}, @@ -1731,6 +2236,7 @@ func TestRefreshGrant(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"}, @@ -1755,6 +2261,7 @@ func TestRefreshGrant(t *testing.T) { modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "offline_access") }, want: tokenEndpointResponseExpectedValues{ wantStatus: http.StatusOK, + wantClientID: pinnipedCLIClientID, wantSuccessBodyFields: []string{"refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantRequestedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"}, @@ -1771,6 +2278,96 @@ func TestRefreshGrant(t *testing.T) { }, }, }, + { + name: "when the refresh request uses a different client than the one that was used to get the refresh token", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Claims: map[string]interface{}{ + "sub": goodUpstreamSubject, + }, + }, + }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + // Make the auth request and authcode exchange request using the pinniped-cli client. + customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { + r.Form.Set("scope", "openid offline_access groups") + }, + want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData()), + }, + refreshRequest: refreshRequestInputs{ + // Make the refresh request with the dynamic client. + modifyTokenRequest: modifyRefreshTokenRequestWithDynamicClientAuth, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeClientIDMismatchDuringRefreshErrorBody, + }, + }, + }, + { + name: "when the client auth fails on the refresh request using dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Claims: map[string]interface{}{ + "sub": goodUpstreamSubject, + }, + }, + }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData())), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: func(tokenRequest *http.Request, refreshToken string, accessToken string) { + tokenRequest.Body = happyRefreshRequestBody(refreshToken).WithClientID("").ReadCloser() + tokenRequest.SetBasicAuth(dynamicClientID, "wrong client secret") + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: fositeClientAuthFailedErrorBody, + }, + }, + }, + { + name: "dynamic client uses wrong auth method on the refresh request (must use basic auth)", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC( + upstreamOIDCIdentityProviderBuilder().WithValidatedAndMergedWithUserInfoTokens(&oidctypes.Token{ + IDToken: &oidctypes.IDToken{ + Claims: map[string]interface{}{ + "sub": goodUpstreamSubject, + }, + }, + }).WithRefreshedTokens(refreshedUpstreamTokensWithIDAndRefreshTokens()).Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + authcodeExchange: authcodeExchangeInputs{ + customSessionData: initialUpstreamOIDCRefreshTokenCustomSessionData(), + modifyAuthRequest: func(r *http.Request) { + addDynamicClientIDToFormPostBody(r) + r.Form.Set("scope", "openid offline_access groups") + }, + modifyTokenRequest: modifyAuthcodeTokenRequestWithDynamicClientAuth, + want: withWantDynamicClientID(happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(initialUpstreamOIDCRefreshTokenCustomSessionData())), + }, + refreshRequest: refreshRequestInputs{ + modifyTokenRequest: func(tokenRequest *http.Request, refreshToken string, accessToken string) { + // Add client auth to the form, when it should be in basic auth headers. + tokenRequest.Body = happyRefreshRequestBody(refreshToken).WithClientID(dynamicClientID).WithClientSecret(testutil.PlaintextPassword1).ReadCloser() + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: fositeClientAuthMustBeBasicAuthErrorBody, + }, + }, + }, { name: "when there is no custom session data found in the session storage during the refresh request", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()), @@ -2920,7 +3517,8 @@ func TestRefreshGrant(t *testing.T) { // First exchange the authcode for tokens, including a refresh token. // its actually fine to use this function even when simulating ldap (which uses a different flow) because it's // just populating a secret in storage. - subject, rsp, authCode, jwtSigningKey, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange, test.idps.Build()) + subject, rsp, authCode, jwtSigningKey, secrets, oauthStore := exchangeAuthcodeForTokens(t, + test.authcodeExchange, test.idps.Build(), test.kubeResources) var parsedAuthcodeExchangeResponseBody map[string]interface{} require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) @@ -2951,6 +3549,7 @@ func TestRefreshGrant(t *testing.T) { } refreshResponse := httptest.NewRecorder() + approxRequestTime := time.Now() subject.ServeHTTP(refreshResponse, req) t.Logf("second response: %#v", refreshResponse) t.Logf("second response body: %q", refreshResponse.Body.String()) @@ -2994,6 +3593,7 @@ func TestRefreshGrant(t *testing.T) { oauthStore, jwtSigningKey, secrets, + approxRequestTime, ) if test.refreshRequest.want.wantStatus == http.StatusOK { @@ -3054,7 +3654,12 @@ func requireClaimsAreEqual(t *testing.T, claimName string, claimsOfTokenA map[st require.Equal(t, claimsOfTokenA[claimName], claimsOfTokenB[claimName]) } -func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs, idps provider.DynamicUpstreamIDPProvider) ( +func exchangeAuthcodeForTokens( + t *testing.T, + test authcodeExchangeInputs, + idps provider.DynamicUpstreamIDPProvider, + kubeResources func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset), +) ( subject http.Handler, rsp *httptest.ResponseRecorder, authCode string, @@ -3067,13 +3672,18 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs, idps p test.modifyAuthRequest(authRequest) } - client := fake.NewSimpleClientset() - secrets = client.CoreV1().Secrets("some-namespace") - oidcClientsClient := supervisorfake.NewSimpleClientset().ConfigV1alpha1().OIDCClients("some-namespace") + kubeClient := fake.NewSimpleClientset() + supervisorClient := supervisorfake.NewSimpleClientset() + secrets = kubeClient.CoreV1().Secrets("some-namespace") + oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients("some-namespace") + + if kubeResources != nil { + kubeResources(t, supervisorClient, kubeClient) + } var oauthHelper fosite.OAuth2Provider - - oauthStore = oidc.NewKubeStorage(secrets, oidcClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) + // Use lower minimum required bcrypt cost than we would use in production to keep unit the tests fast. + oauthStore = oidc.NewKubeStorage(secrets, oidcClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), bcrypt.MinCost) if test.makeOathHelper != nil { oauthHelper, authCode, jwtSigningKey = test.makeOathHelper(t, authRequest, oauthStore, test.customSessionData) } else { @@ -3096,7 +3706,8 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs, idps p testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 1) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, expectedNumberOfIDSessionsStored) - testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2+expectedNumberOfIDSessionsStored) + // Assert the number of all secrets, excluding any OIDCClient's storage secret, since those are not related to session storage. + testutil.RequireNumberOfSecretsExcludingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: oidcclientsecretstorage.TypeLabelValue}, 2+expectedNumberOfIDSessionsStored) req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyAuthcodeRequestBody(authCode).ReadCloser()) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") @@ -3105,12 +3716,13 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs, idps p } rsp = httptest.NewRecorder() + approxRequestTime := time.Now() subject.ServeHTTP(rsp, req) t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) wantAtHashClaimInIDToken := false // due to a bug in fosite, the at_hash claim is not filled in during authcode exchange - wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unliked refreshed ID tokens) + wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unlike refreshed ID tokens) requireTokenEndpointBehavior(t, test.want, @@ -3123,6 +3735,7 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs, idps p oauthStore, jwtSigningKey, secrets, + approxRequestTime, ) return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore @@ -3140,6 +3753,7 @@ func requireTokenEndpointBehavior( oauthStore *oidc.KubeStorage, jwtSigningKey *ecdsa.PrivateKey, secrets v1.SecretInterface, + requestTime time.Time, ) { testutil.RequireEqualContentType(t, tokenEndpointResponse.Header().Get("Content-Type"), "application/json") require.Equal(t, test.wantStatus, tokenEndpointResponse.Code) @@ -3154,11 +3768,11 @@ func requireTokenEndpointBehavior( wantIDToken := contains(test.wantSuccessBodyFields, "id_token") wantRefreshToken := contains(test.wantSuccessBodyFields, "refresh_token") - requireInvalidAuthCodeStorage(t, authCode, oauthStore, secrets) - requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, test.wantGroups, test.wantCustomSessionDataStored, secrets) + requireInvalidAuthCodeStorage(t, authCode, oauthStore, secrets, requestTime) + requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, test.wantGroups, test.wantCustomSessionDataStored, secrets, requestTime) requireInvalidPKCEStorage(t, authCode, oauthStore) // Performing a refresh does not update the OIDC storage, so after a refresh it should still have the old custom session data and old groups from the initial login. - requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, oldGroups, oldCustomSessionData) + requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, oldGroups, oldCustomSessionData, requestTime) expectedNumberOfRefreshTokenSessionsStored := 0 if wantRefreshToken { @@ -3167,10 +3781,10 @@ func requireTokenEndpointBehavior( expectedNumberOfIDSessionsStored := 0 if wantIDToken { expectedNumberOfIDSessionsStored = 1 - requireValidIDToken(t, parsedResponseBody, jwtSigningKey, wantAtHashClaimInIDToken, wantNonceValueInIDToken, test.wantGroups, parsedResponseBody["access_token"].(string)) + requireValidIDToken(t, parsedResponseBody, jwtSigningKey, test.wantClientID, wantAtHashClaimInIDToken, wantNonceValueInIDToken, test.wantGroups, parsedResponseBody["access_token"].(string), requestTime) } if wantRefreshToken { - requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, test.wantGroups, test.wantCustomSessionDataStored, secrets) + requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantClientID, test.wantRequestedScopes, test.wantGrantedScopes, test.wantGroups, test.wantCustomSessionDataStored, secrets, requestTime) } testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -3178,7 +3792,8 @@ func requireTokenEndpointBehavior( testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, expectedNumberOfRefreshTokenSessionsStored) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, expectedNumberOfIDSessionsStored) - testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2+expectedNumberOfRefreshTokenSessionsStored+expectedNumberOfIDSessionsStored) + // Assert the number of all secrets, excluding any OIDCClient's storage secret, since those are not related to session storage. + testutil.RequireNumberOfSecretsExcludingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: oidcclientsecretstorage.TypeLabelValue}, 2+expectedNumberOfRefreshTokenSessionsStored+expectedNumberOfIDSessionsStored) } else { require.NotNil(t, test.wantErrorResponseBody, "problem with test table setup: wanted failure but did not specify failure response body") @@ -3204,7 +3819,7 @@ func happyAuthcodeRequestBody(happyAuthCode string) body { "code": {happyAuthCode}, "redirect_uri": {goodRedirectURI}, "code_verifier": {goodPKCECodeVerifier}, - "client_id": {goodClient}, + "client_id": {pinnipedCLIClientID}, } } @@ -3212,7 +3827,7 @@ func happyRefreshRequestBody(refreshToken string) body { return map[string][]string{ "grant_type": {"refresh_token"}, "scope": {"openid"}, - "client_id": {goodClient}, + "client_id": {pinnipedCLIClientID}, "refresh_token": {refreshToken}, } } @@ -3229,6 +3844,10 @@ func (b body) WithClientID(clientID string) body { return b.with("client_id", clientID) } +func (b body) WithClientSecret(clientSecret string) body { + return b.with("client_secret", clientSecret) +} + func (b body) WithAuthCode(code string) body { return b.with("code", code) } @@ -3395,6 +4014,7 @@ func requireInvalidAuthCodeStorage( code string, storage fositeoauth2.CoreStorage, secrets v1.SecretInterface, + requestTime time.Time, ) { t.Helper() @@ -3402,18 +4022,20 @@ func requireInvalidAuthCodeStorage( _, err := storage.GetAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, code), nil) require.True(t, errors.Is(err, fosite.ErrInvalidatedAuthorizeCode)) // make sure that its still around in storage so if someone tries to use it again we invalidate everything - requireGarbageCollectTimeInDelta(t, code, "authcode", secrets, time.Now().Add(9*time.Hour).Add(10*time.Minute), 30*time.Second) + requireGarbageCollectTimeInDelta(t, code, "authcode", secrets, requestTime.Add(9*time.Hour).Add(10*time.Minute), 30*time.Second) } func requireValidRefreshTokenStorage( t *testing.T, body map[string]interface{}, storage fositeoauth2.CoreStorage, + wantClientID string, wantRequestedScopes []string, wantGrantedScopes []string, wantGroups []string, wantCustomSessionData *psession.CustomSessionData, secrets v1.SecretInterface, + requestTime time.Time, ) { t.Helper() @@ -3434,25 +4056,29 @@ func requireValidRefreshTokenStorage( t, storedRequest, storedRequest.Sanitize([]string{}).GetRequestForm(), + wantClientID, wantRequestedScopes, wantGrantedScopes, true, wantGroups, wantCustomSessionData, + requestTime, ) - requireGarbageCollectTimeInDelta(t, refreshTokenString, "refresh-token", secrets, time.Now().Add(9*time.Hour).Add(2*time.Minute), 1*time.Minute) + requireGarbageCollectTimeInDelta(t, refreshTokenString, "refresh-token", secrets, requestTime.Add(9*time.Hour).Add(2*time.Minute), 1*time.Minute) } func requireValidAccessTokenStorage( t *testing.T, body map[string]interface{}, storage fositeoauth2.CoreStorage, + wantClientID string, wantRequestedScopes []string, wantGrantedScopes []string, wantGroups []string, wantCustomSessionData *psession.CustomSessionData, secrets v1.SecretInterface, + requestTime time.Time, ) { t.Helper() @@ -3492,14 +4118,16 @@ func requireValidAccessTokenStorage( t, storedRequest, storedRequest.Sanitize([]string{}).GetRequestForm(), + wantClientID, wantRequestedScopes, wantGrantedScopes, true, wantGroups, wantCustomSessionData, + requestTime, ) - requireGarbageCollectTimeInDelta(t, accessTokenString, "access-token", secrets, time.Now().Add(9*time.Hour).Add(2*time.Minute), 1*time.Minute) + requireGarbageCollectTimeInDelta(t, accessTokenString, "access-token", secrets, requestTime.Add(9*time.Hour).Add(2*time.Minute), 1*time.Minute) } func requireInvalidAccessTokenStorage( @@ -3536,10 +4164,12 @@ func requireValidOIDCStorage( body map[string]interface{}, code string, storage openid.OpenIDConnectRequestStorage, + wantClientID string, wantRequestedScopes []string, wantGrantedScopes []string, wantGroups []string, wantCustomSessionData *psession.CustomSessionData, + requestTime time.Time, ) { t.Helper() @@ -3559,11 +4189,13 @@ func requireValidOIDCStorage( t, storedRequest, storedRequest.Sanitize([]string{"nonce"}).GetRequestForm(), + wantClientID, wantRequestedScopes, wantGrantedScopes, false, wantGroups, wantCustomSessionData, + requestTime, ) } else { _, err := storage.GetOpenIDConnectSession(context.Background(), code, nil) @@ -3575,18 +4207,20 @@ func requireValidStoredRequest( t *testing.T, request fosite.Requester, wantRequestForm url.Values, + wantClientID string, wantRequestedScopes []string, wantGrantedScopes []string, wantAccessTokenExpiresAt bool, wantGroups []string, wantCustomSessionData *psession.CustomSessionData, + requestTime time.Time, ) { t.Helper() // Assert that the getters on the request return what we think they should. require.NotEmpty(t, request.GetID()) - testutil.RequireTimeInDelta(t, request.GetRequestedAt(), time.Now().UTC(), timeComparisonFudgeSeconds*time.Second) - require.Equal(t, goodClient, request.GetClient().GetID()) + testutil.RequireTimeInDelta(t, request.GetRequestedAt(), requestTime.UTC(), timeComparisonFudge) + require.Equal(t, wantClientID, request.GetClient().GetID()) require.Equal(t, fosite.Arguments(wantRequestedScopes), request.GetRequestedScopes()) require.Equal(t, fosite.Arguments(wantGrantedScopes), request.GetGrantedScopes()) require.Empty(t, request.GetRequestedAudience()) @@ -3636,6 +4270,9 @@ func requireValidStoredRequest( require.Empty(t, claims.AuthenticationContextClassReference) require.Empty(t, claims.AuthenticationMethodsReferences) require.Empty(t, claims.CodeHash) + } else if wantGroups != nil { + t.Fatal("test did not want the openid scope to be granted, but also wanted groups, " + + "which is a combination that doesn't make sense since you need an ID token to get groups") } // Assert that the session headers are what we think they should be. @@ -3647,9 +4284,9 @@ func requireValidStoredRequest( require.True(t, ok, "expected session to hold expiration time for auth code") testutil.RequireTimeInDelta( t, - time.Now().UTC().Add(authCodeExpirationSeconds*time.Second), + requestTime.UTC().Add(authCodeExpirationSeconds*time.Second), authCodeExpiresAt, - timeComparisonFudgeSeconds*time.Second, + timeComparisonFudge, ) // OpenID Connect sessions do not store access token expiration information. @@ -3658,9 +4295,9 @@ func requireValidStoredRequest( require.True(t, ok, "expected session to hold expiration time for access token") testutil.RequireTimeInDelta( t, - time.Now().UTC().Add(accessTokenExpirationSeconds*time.Second), + requestTime.UTC().Add(accessTokenExpirationSeconds*time.Second), accessTokenExpiresAt, - timeComparisonFudgeSeconds*time.Second, + timeComparisonFudge, ) } else { require.False(t, ok, "expected session to not hold expiration time for access token, but it did") @@ -3696,10 +4333,12 @@ func requireValidIDToken( t *testing.T, body map[string]interface{}, jwtSigningKey *ecdsa.PrivateKey, + wantClientID string, wantAtHashClaimInIDToken bool, wantNonceValueInIDToken bool, wantGroupsInIDToken []string, actualAccessToken string, + requestTime time.Time, ) { t.Helper() @@ -3709,7 +4348,7 @@ func requireValidIDToken( require.Truef(t, ok, "wanted id_token to be a string, but got %T", idToken) // The go-oidc library will validate the signature and the client claim in the ID token. - token := oidctestutil.VerifyECDSAIDToken(t, goodIssuer, goodClient, jwtSigningKey, idTokenString) + token := oidctestutil.VerifyECDSAIDToken(t, goodIssuer, wantClientID, jwtSigningKey, idTokenString) var claims struct { Subject string `json:"sub"` @@ -3752,7 +4391,7 @@ func requireValidIDToken( require.Equal(t, goodUsername, claims.Username) require.Equal(t, wantGroupsInIDToken, claims.Groups) require.Len(t, claims.Audience, 1) - require.Equal(t, goodClient, claims.Audience[0]) + require.Equal(t, wantClientID, claims.Audience[0]) require.Equal(t, goodIssuer, claims.Issuer) require.NotEmpty(t, claims.JTI) @@ -3766,10 +4405,10 @@ func requireValidIDToken( issuedAt := time.Unix(claims.IssuedAt, 0) requestedAt := time.Unix(claims.RequestedAt, 0) authTime := time.Unix(claims.AuthTime, 0) - testutil.RequireTimeInDelta(t, time.Now().UTC().Add(idTokenExpirationSeconds*time.Second), expiresAt, timeComparisonFudgeSeconds*time.Second) - testutil.RequireTimeInDelta(t, time.Now().UTC(), issuedAt, timeComparisonFudgeSeconds*time.Second) - testutil.RequireTimeInDelta(t, goodRequestedAtTime, requestedAt, timeComparisonFudgeSeconds*time.Second) - testutil.RequireTimeInDelta(t, goodAuthTime, authTime, timeComparisonFudgeSeconds*time.Second) + testutil.RequireTimeInDelta(t, requestTime.UTC().Add(idTokenExpirationSeconds*time.Second), expiresAt, timeComparisonFudge) + testutil.RequireTimeInDelta(t, requestTime.UTC(), issuedAt, timeComparisonFudge) + testutil.RequireTimeInDelta(t, goodRequestedAtTime, requestedAt, timeComparisonFudge) + testutil.RequireTimeInDelta(t, goodAuthTime, authTime, timeComparisonFudge) if wantAtHashClaimInIDToken { require.NotEmpty(t, actualAccessToken) diff --git a/internal/oidc/token_exchange.go b/internal/oidc/token_exchange.go index 4c2f5500..5ed83b5e 100644 --- a/internal/oidc/token_exchange.go +++ b/internal/oidc/token_exchange.go @@ -13,15 +13,17 @@ import ( "github.com/ory/fosite/compose" "github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/openid" + "github.com/ory/x/errorsx" "github.com/pkg/errors" "go.pinniped.dev/internal/oidc/clientregistry" ) const ( - tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" //nolint: gosec - tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint: gosec - pinnipedTokenExchangeScope = "pinniped:request-audience" //nolint: gosec + tokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange" //nolint: gosec + tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" //nolint: gosec + tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint: gosec + pinnipedTokenExchangeScope = "pinniped:request-audience" //nolint: gosec ) type stsParams struct { @@ -70,6 +72,18 @@ func (t *TokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context return errors.WithStack(err) } + // Check that the currently authenticated client and the client which was originally used to get the access token are the same. + if originalRequester.GetClient().GetID() != requester.GetClient().GetID() { + // This error message is copied from the similar check in fosite's flow_authorize_code_token.go. + return errorsx.WithStack(fosite.ErrInvalidGrant.WithHint("The OAuth 2.0 Client ID from this request does not match the one from the authorize request.")) + } + + // Check that the client is allowed to perform this grant type. + if !requester.GetClient().GetGrantTypes().Has(tokenExchangeGrantType) { + // This error message is trying to be similar to the analogous one in fosite's flow_authorize_code_token.go. + return errorsx.WithStack(fosite.ErrUnauthorizedClient.WithHintf("The OAuth 2.0 Client is not allowed to use token exchange grant \"%s\".", tokenExchangeGrantType)) + } + // Require that the incoming access token has the pinniped:request-audience and OpenID scopes. if !originalRequester.GetGrantedScopes().Has(pinnipedTokenExchangeScope) { return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", pinnipedTokenExchangeScope)) @@ -168,5 +182,5 @@ func (t *TokenExchangeHandler) CanSkipClientAuth(_ fosite.AccessRequester) bool } func (t *TokenExchangeHandler) CanHandleTokenEndpointRequest(requester fosite.AccessRequester) bool { - return requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange") + return requester.GetGrantTypes().ExactOne(tokenExchangeGrantType) } diff --git a/internal/testutil/assertions.go b/internal/testutil/assertions.go index ee7bc2ed..6117357f 100644 --- a/internal/testutil/assertions.go +++ b/internal/testutil/assertions.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/require" v12 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/selection" v1 "k8s.io/client-go/kubernetes/typed/core/v1" ) @@ -54,6 +55,23 @@ func RequireNumberOfSecretsMatchingLabelSelector(t *testing.T, secrets v1.Secret require.Len(t, storedAuthcodeSecrets.Items, expectedNumberOfSecrets) } +func RequireNumberOfSecretsExcludingLabelSelector(t *testing.T, secrets v1.SecretInterface, labelSet labels.Set, expectedNumberOfSecrets int) { + t.Helper() + + selector := labels.Everything() + for k, v := range labelSet { + requirement, err := labels.NewRequirement(k, selection.NotEquals, []string{v}) + require.NoError(t, err) + selector = selector.Add(*requirement) + } + + storedAuthcodeSecrets, err := secrets.List(context.Background(), v12.ListOptions{ + LabelSelector: selector.String(), + }) + require.NoError(t, err) + require.Len(t, storedAuthcodeSecrets.Items, expectedNumberOfSecrets) +} + func RequireSecurityHeadersWithFormPostPageCSPs(t *testing.T, response *httptest.ResponseRecorder) { // Loosely confirm that the unique CSPs needed for the form_post page were used. cspHeader := response.Header().Get("Content-Security-Policy") diff --git a/internal/testutil/oidcclient.go b/internal/testutil/oidcclient.go new file mode 100644 index 00000000..621aea2e --- /dev/null +++ b/internal/testutil/oidcclient.go @@ -0,0 +1,67 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" +) + +const ( + AllDynamicClientScopesSpaceSep = "openid offline_access pinniped:request-audience username groups" + + // PlaintextPassword1 is a fake client secret for use in unit tests, along with several flavors of the bcrypt + // hashed version of the password. Do not use for integration tests. + PlaintextPassword1 = "password1" + HashedPassword1AtGoMinCost = "$2a$04$JfX1ba/ctAt3AGk73E9Zz.Fdki5GiQtj.O/CnPbRRSKQWWfv1svoe" //nolint:gosec // this is not a credential + HashedPassword1JustBelowSupervisorMinCost = "$2a$11$w/incy7Z1/ljLYvv2XRg4.WrPgY9oR7phebcgr6rGA3u/5TG9MKOe" //nolint:gosec // this is not a credential + HashedPassword1AtSupervisorMinCost = "$2a$12$id4i/yFYxS99txKOFEeboea2kU6DyZY0Nh4ul0eR46sDuoFoNTRV." //nolint:gosec // this is not a credential + HashedPassword1InvalidFormat = "$2a$12$id4i/yFYxS99txKOFEeboea2kU6DyZY0Nh4ul0eR46sDuo" //nolint:gosec // this is not a credential + + // PlaintextPassword2 is a second fake client secret for use in unit tests, along with several flavors of the bcrypt + // hashed version of the password. Do not use for integration tests. + PlaintextPassword2 = "password2" + HashedPassword2AtGoMinCost = "$2a$04$VQ5z6kkgU8JPLGSGctg.s.iYyoac3Oisa/SIM3sDK5BxTrVbCkyNm" //nolint:gosec // this is not a credential + HashedPassword2AtSupervisorMinCost = "$2a$12$SdUqoJOn4/3yEQfJx616V.q.f76KaXD.ISgJT1oydqFdgfjJpBh6u" //nolint:gosec // this is not a credential +) + +// allDynamicClientScopes returns a slice of all scopes that are supported by the Supervisor for dynamic clients. +func allDynamicClientScopes() []configv1alpha1.Scope { + scopes := []configv1alpha1.Scope{} + for _, s := range strings.Split(AllDynamicClientScopesSpaceSep, " ") { + scopes = append(scopes, configv1alpha1.Scope(s)) + } + return scopes +} + +// fullyCapableOIDCClient returns an OIDC client which is allowed to use all grant types and all scopes that +// are supported by the Supervisor for dynamic clients. +func fullyCapableOIDCClient(namespace string, clientID string, clientUID string, redirectURI string) *configv1alpha1.OIDCClient { + return &configv1alpha1.OIDCClient{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: clientID, Generation: 1, UID: types.UID(clientUID)}, + Spec: configv1alpha1.OIDCClientSpec{ + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: allDynamicClientScopes(), + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(redirectURI)}, + }, + } +} + +func FullyCapableOIDCClientAndStorageSecret( + t *testing.T, + namespace string, + clientID string, + clientUID string, + redirectURI string, + hashes []string, +) (*configv1alpha1.OIDCClient, *corev1.Secret) { + return fullyCapableOIDCClient(namespace, clientID, clientUID, redirectURI), + OIDCClientSecretStorageSecretForUID(t, namespace, clientUID, hashes) +} diff --git a/internal/testutil/oidcclient_test.go b/internal/testutil/oidcclient_test.go new file mode 100644 index 00000000..cd892313 --- /dev/null +++ b/internal/testutil/oidcclient_test.go @@ -0,0 +1,61 @@ +// Copyright 2022 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "testing" + + "go.pinniped.dev/internal/oidc/oidcclientvalidator" + + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestBcryptConstants(t *testing.T) { + t.Parallel() + + // It would be helpful to know if upgrading golang changes these constants some day, so test them here for visibility during upgrades. + require.Equal(t, 4, bcrypt.MinCost, "golang has changed bcrypt.MinCost: please consider implications to the other tests") + require.Equal(t, 10, bcrypt.DefaultCost, "golang has changed bcrypt.DefaultCost: please consider implications to the production code and tests") +} + +func TestBcryptHashedPassword1TestHelpers(t *testing.T) { + t.Parallel() + + // Can use this to help generate or regenerate the test helper hash constants. + // t.Log(generateHash(t, PlaintextPassword1, 12)) + + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(HashedPassword1AtGoMinCost), []byte(PlaintextPassword1))) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(HashedPassword1JustBelowSupervisorMinCost), []byte(PlaintextPassword1))) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(HashedPassword1AtSupervisorMinCost), []byte(PlaintextPassword1))) + + requireCost(t, bcrypt.MinCost, HashedPassword1AtGoMinCost) + requireCost(t, oidcclientvalidator.DefaultMinBcryptCost-1, HashedPassword1JustBelowSupervisorMinCost) + requireCost(t, oidcclientvalidator.DefaultMinBcryptCost, HashedPassword1AtSupervisorMinCost) +} + +func TestBcryptHashedPassword2TestHelpers(t *testing.T) { + t.Parallel() + + // Can use this to help generate or regenerate the test helper hash constants. + // t.Log(generateHash(t, PlaintextPassword2, 12)) + + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(HashedPassword2AtGoMinCost), []byte(PlaintextPassword2))) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(HashedPassword2AtSupervisorMinCost), []byte(PlaintextPassword2))) + + requireCost(t, bcrypt.MinCost, HashedPassword2AtGoMinCost) + requireCost(t, oidcclientvalidator.DefaultMinBcryptCost, HashedPassword2AtSupervisorMinCost) +} + +func generateHash(t *testing.T, password string, cost int) string { //nolint:unused,deadcode // used in comments above + hash, err := bcrypt.GenerateFromPassword([]byte(password), cost) + require.NoError(t, err) + return string(hash) +} + +func requireCost(t *testing.T, wantCost int, hash string) { + cost, err := bcrypt.Cost([]byte(hash)) + require.NoError(t, err) + require.Equal(t, wantCost, cost) +} diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index fa9c74b3..86c3f67f 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -31,6 +31,7 @@ import ( idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient/nonce" @@ -1727,7 +1728,7 @@ func testSupervisorLogin( // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) require.NoError(t, err) @@ -1772,7 +1773,7 @@ func testSupervisorLogin( // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) require.NoError(t, err) diff --git a/test/integration/supervisor_oidc_client_test.go b/test/integration/supervisor_oidc_client_test.go index 4ec9fc55..d8a5ac55 100644 --- a/test/integration/supervisor_oidc_client_test.go +++ b/test/integration/supervisor_oidc_client_test.go @@ -563,7 +563,7 @@ func TestOIDCClientControllerValidations_Parallel(t *testing.T) { AllowedScopes: []supervisorconfigv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, }, }, - secret: testutil.OIDCClientSecretStorageSecretWithoutName(t, env.SupervisorNamespace, []string{"$2y$15$Kh7cRj0ScSD5QelE3ZNSl.nF04JDv7zb3SgGN.tSfLIX.4kt3UX7m"}), + secret: testutil.OIDCClientSecretStorageSecretWithoutName(t, env.SupervisorNamespace, []string{testutil.HashedPassword1AtSupervisorMinCost}), wantPhase: "Ready", wantConditions: []supervisorconfigv1alpha1.Condition{ { diff --git a/test/integration/supervisor_warnings_test.go b/test/integration/supervisor_warnings_test.go index e3ea9485..55cbf7dd 100644 --- a/test/integration/supervisor_warnings_test.go +++ b/test/integration/supervisor_warnings_test.go @@ -31,6 +31,7 @@ import ( idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/certauthority" "go.pinniped.dev/internal/oidc" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" "go.pinniped.dev/internal/psession" "go.pinniped.dev/internal/testutil" "go.pinniped.dev/pkg/oidcclient" @@ -188,7 +189,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // out of kube secret storage. supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) refreshTokenSignature := strings.Split(token.RefreshToken.Token, ".")[1] storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, refreshTokenSignature, nil) require.NoError(t, err) @@ -496,7 +497,7 @@ func TestSupervisorWarnings_Browser(t *testing.T) { // out of kube secret storage. supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration()) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) refreshTokenSignature := strings.Split(token.RefreshToken.Token, ".")[1] storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, refreshTokenSignature, nil) require.NoError(t, err) From e42f5488fa4a39fc5a7756f84f003cd71ed88cf8 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Jul 2022 09:26:00 -0700 Subject: [PATCH 4/8] More unit tests for dynamic clients - Add dynamic client unit tests for the upstream OIDC callback and POST login endpoints. - Enhance a few log statements to print the full fosite error messages into the logs where they were previously only printing the name of the error type. --- internal/oidc/auth/auth_handler_test.go | 20 +- internal/oidc/callback/callback_handler.go | 6 +- .../oidc/callback/callback_handler_test.go | 109 +++++- internal/oidc/login/post_login_handler.go | 3 +- .../oidc/login/post_login_handler_test.go | 345 ++++++++++++++---- internal/oidc/oidc.go | 2 +- .../testutil/oidctestutil/oidctestutil.go | 20 +- test/testlib/client.go | 4 +- 8 files changed, 400 insertions(+), 109 deletions(-) diff --git a/internal/oidc/auth/auth_handler_test.go b/internal/oidc/auth/auth_handler_test.go index 768ab10f..6f8e7598 100644 --- a/internal/oidc/auth/auth_handler_test.go +++ b/internal/oidc/auth/auth_handler_test.go @@ -25,7 +25,6 @@ import ( "k8s.io/apiserver/pkg/authentication/user" "k8s.io/client-go/kubernetes/fake" v1 "k8s.io/client-go/kubernetes/typed/core/v1" - kubetesting "k8s.io/client-go/testing" "k8s.io/utils/pointer" supervisorfake "go.pinniped.dev/generated/latest/client/supervisor/clientset/versioned/fake" @@ -3088,7 +3087,7 @@ func TestAuthorizationEndpoint(t *testing.T) { // OIDC validations are checked in fosite after the OAuth authcode (and sometimes the OIDC session) // is stored, so it is possible with an LDAP upstream to store objects and then return an error to // the client anyway (which makes the stored objects useless, but oh well). - require.Len(t, filterActions(kubeClient.Actions()), test.wantUnnecessaryStoredRecords) + require.Len(t, oidctestutil.FilterClientSecretCreateActions(kubeClient.Actions()), test.wantUnnecessaryStoredRecords) case test.wantRedirectLocationRegexp != "": if test.wantDownstreamClientID == "" { test.wantDownstreamClientID = pinnipedCLIClientID // default assertion value when not provided by test case @@ -3301,20 +3300,3 @@ func requireEqualURLs(t *testing.T, actualURL string, expectedURL string, ignore } require.Equal(t, expectedLocationQuery, actualLocationQuery) } - -// filterActions ignores any reads made to get a storage secret corresponding to an OIDCClient, since these -// are normal actions when the request is using a dynamic client's client_id, and we don't need to make assertions -// about these Secrets since they are not related to session storage. -func filterActions(actions []kubetesting.Action) []kubetesting.Action { - filtered := make([]kubetesting.Action, 0, len(actions)) - for _, action := range actions { - if action.Matches("get", "secrets") { - getAction := action.(kubetesting.GetAction) - if strings.HasPrefix(getAction.GetName(), "pinniped-storage-oidc-client-secret-") { - continue // filter out OIDCClient's storage secret reads - } - } - filtered = append(filtered, action) // otherwise include the action - } - return filtered -} diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index db6ada1a..88b94392 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -48,7 +48,8 @@ func NewHandler( reconstitutedAuthRequest := &http.Request{Form: downstreamAuthParams} authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), reconstitutedAuthRequest) if err != nil { - plog.Error("error using state downstream auth params", err) + plog.Error("error using state downstream auth params", err, + "fositeErr", oidc.FositeErrorForLog(err)) return httperr.New(http.StatusBadRequest, "error using state downstream auth params") } @@ -83,7 +84,8 @@ func NewHandler( authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) if err != nil { - plog.WarningErr("error while generating and saving authcode", err, "upstreamName", upstreamIDPConfig.GetName()) + plog.WarningErr("error while generating and saving authcode", err, + "upstreamName", upstreamIDPConfig.GetName(), "fositeErr", oidc.FositeErrorForLog(err)) return httperr.Wrap(http.StatusInternalServerError, "error while generating and saving authcode", err) } diff --git a/internal/oidc/callback/callback_handler_test.go b/internal/oidc/callback/callback_handler_test.go index dea3f2df..57dcfcd5 100644 --- a/internal/oidc/callback/callback_handler_test.go +++ b/internal/oidc/callback/callback_handler_test.go @@ -54,7 +54,9 @@ const ( downstreamIssuer = "https://my-downstream-issuer.com/path" downstreamRedirectURI = "http://127.0.0.1/callback" - downstreamClientID = "pinniped-cli" + downstreamPinnipedClientID = "pinniped-cli" + downstreamDynamicClientID = "client.oauth.pinniped.dev-test-name" + downstreamDynamicClientUID = "fake-client-uid" downstreamNonce = "some-nonce-value" downstreamPKCEChallenge = "some-challenge" downstreamPKCEChallengeMethod = "S256" @@ -70,14 +72,19 @@ var ( happyDownstreamRequestParamsQuery = url.Values{ "response_type": []string{"code"}, "scope": []string{strings.Join(happyDownstreamScopesRequested, " ")}, - "client_id": []string{downstreamClientID}, + "client_id": []string{downstreamPinnipedClientID}, "state": []string{happyDownstreamState}, "nonce": []string{downstreamNonce}, "code_challenge": []string{downstreamPKCEChallenge}, "code_challenge_method": []string{downstreamPKCEChallengeMethod}, "redirect_uri": []string{downstreamRedirectURI}, } - happyDownstreamRequestParams = happyDownstreamRequestParamsQuery.Encode() + happyDownstreamRequestParams = happyDownstreamRequestParamsQuery.Encode() + + happyDownstreamRequestParamsForDynamicClient = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"client_id": downstreamDynamicClientID}, + ).Encode() + happyDownstreamCustomSessionData = &psession.CustomSessionData{ ProviderUID: happyUpstreamIDPResourceUID, ProviderName: happyUpstreamIDPName, @@ -122,6 +129,7 @@ func TestCallbackEndpoint(t *testing.T) { happyCookieCodec.SetSerializer(securecookie.JSONEncoder{}) happyState := happyUpstreamStateParam().Build(t, happyStateCodec) + happyStateForDynamicClient := happyUpstreamStateParamForDynamicClient().Build(t, happyStateCodec) encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF) require.NoError(t, err) @@ -137,6 +145,13 @@ func TestCallbackEndpoint(t *testing.T) { // Note that fosite puts the granted scopes as a param in the redirect URI even though the spec doesn't seem to require it happyDownstreamRedirectLocationRegexp := downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState + addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + } + tests := []struct { name string @@ -157,6 +172,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamIDTokenGroups []string wantDownstreamRequestedScopes []string wantDownstreamNonce string + wantDownstreamClientID string wantDownstreamPKCEChallenge string wantDownstreamPKCEChallengeMethod string wantDownstreamCustomSessionData *psession.CustomSessionData @@ -185,6 +201,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -208,6 +225,32 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, + wantAuthcodeExchangeCall: &expectedAuthcodeExchange{ + performedByUpstreamName: happyUpstreamIDPName, + args: happyExchangeAndValidateTokensArgs, + }, + }, + { + name: "GET with good state and cookie and successful upstream token exchange returns 303 to downstream client callback with its state and code when using dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: newRequestPath().WithState(happyStateForDynamicClient).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusSeeOther, + wantRedirectLocationRegexp: happyDownstreamRedirectLocationRegexp, + wantBody: "", + wantDownstreamIDTokenSubject: oidcUpstreamIssuer + "?sub=" + oidcUpstreamSubjectQueryEscaped, + wantDownstreamIDTokenUsername: oidcUpstreamUsername, + wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -231,6 +274,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamAccessTokenCustomSessionData, @@ -263,6 +307,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: []string{"openid"}, wantDownstreamGrantedScopes: []string{"openid"}, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -286,6 +331,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: &psession.CustomSessionData{ @@ -321,6 +367,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -346,6 +393,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -373,6 +421,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -401,6 +450,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -531,6 +581,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -556,6 +607,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -581,6 +633,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: happyDownstreamScopesRequested, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -714,6 +767,42 @@ func TestCallbackEndpoint(t *testing.T) { wantContentType: htmlContentType, wantBody: "Bad Request: error using state downstream auth params\n", }, + { + name: "state's downstream auth params have invalid client_id", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam(). + WithAuthorizeRequestParams(shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, map[string]string{"client_id": "bogus"}).Encode()). + Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error using state downstream auth params\n", + }, + { + name: "dynamic clients do not allow response_mode=form_post", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + method: http.MethodGet, + path: newRequestPath().WithState( + happyUpstreamStateParam().WithAuthorizeRequestParams( + shallowCopyAndModifyQuery( + happyDownstreamRequestParamsQuery, + map[string]string{ + "client_id": downstreamDynamicClientID, + "response_mode": "form_post", + "scope": "openid", + }, + ).Encode(), + ).Build(t, happyStateCodec), + ).String(), + csrfCookie: happyCSRFCookie, + wantStatus: http.StatusBadRequest, + wantContentType: htmlContentType, + wantBody: "Bad Request: error using state downstream auth params\n", + }, { name: "state's downstream auth params does not contain openid scope", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithOIDC(happyUpstream().Build()), @@ -733,6 +822,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamGrantedScopes: []string{"groups"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -759,6 +849,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamRequestedScopes: []string{"profile", "email"}, wantDownstreamGrantedScopes: []string{}, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -786,6 +877,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamGrantedScopes: []string{"openid", "offline_access", "groups"}, wantDownstreamIDTokenGroups: oidcUpstreamGroupMembership, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -884,6 +976,7 @@ func TestCallbackEndpoint(t *testing.T) { wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamIDTokenGroups: []string{}, wantDownstreamNonce: downstreamNonce, + wantDownstreamClientID: downstreamPinnipedClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: happyDownstreamCustomSessionData, @@ -1139,7 +1232,7 @@ func TestCallbackEndpoint(t *testing.T) { test.wantDownstreamPKCEChallenge, test.wantDownstreamPKCEChallengeMethod, test.wantDownstreamNonce, - downstreamClientID, + test.wantDownstreamClientID, downstreamRedirectURI, test.wantDownstreamCustomSessionData, ) @@ -1166,7 +1259,7 @@ func TestCallbackEndpoint(t *testing.T) { test.wantDownstreamPKCEChallenge, test.wantDownstreamPKCEChallengeMethod, test.wantDownstreamNonce, - downstreamClientID, + test.wantDownstreamClientID, downstreamRedirectURI, test.wantDownstreamCustomSessionData, ) @@ -1237,6 +1330,12 @@ func happyUpstreamStateParam() *oidctestutil.UpstreamStateParamBuilder { } } +func happyUpstreamStateParamForDynamicClient() *oidctestutil.UpstreamStateParamBuilder { + p := happyUpstreamStateParam() + p.P = happyDownstreamRequestParamsForDynamicClient + return p +} + func happyUpstream() *oidctestutil.TestUpstreamOIDCIdentityProviderBuilder { return oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder(). WithName(happyUpstreamIDPName). diff --git a/internal/oidc/login/post_login_handler.go b/internal/oidc/login/post_login_handler.go index 85ccbc25..4c214452 100644 --- a/internal/oidc/login/post_login_handler.go +++ b/internal/oidc/login/post_login_handler.go @@ -41,7 +41,8 @@ func NewPostHandler(issuerURL string, upstreamIDPs oidc.UpstreamIdentityProvider if err != nil { // This shouldn't really happen because the authorization endpoint has already validated these params // by calling NewAuthorizeRequest() itself. - plog.Error("error using state downstream auth params", err) + plog.Error("error using state downstream auth params", err, + "fositeErr", oidc.FositeErrorForLog(err)) return httperr.New(http.StatusBadRequest, "error using state downstream auth params") } diff --git a/internal/oidc/login/post_login_handler_test.go b/internal/oidc/login/post_login_handler_test.go index 7e8cffef..80931ee9 100644 --- a/internal/oidc/login/post_login_handler_test.go +++ b/internal/oidc/login/post_login_handler_test.go @@ -38,7 +38,9 @@ func TestPostLoginEndpoint(t *testing.T) { downstreamIssuer = "https://my-downstream-issuer.com/path" downstreamRedirectURI = "http://127.0.0.1/callback" - downstreamClientID = "pinniped-cli" + downstreamPinnipedCLIClientID = "pinniped-cli" + downstreamDynamicClientID = "client.oauth.pinniped.dev-test-name" + downstreamDynamicClientUID = "fake-client-uid" happyDownstreamState = "8b-state" downstreamNonce = "some-nonce-value" downstreamPKCEChallenge = "some-challenge" @@ -90,7 +92,7 @@ func TestPostLoginEndpoint(t *testing.T) { happyDownstreamRequestParamsQuery := url.Values{ "response_type": []string{"code"}, "scope": []string{strings.Join(happyDownstreamScopesRequested, " ")}, - "client_id": []string{downstreamClientID}, + "client_id": []string{downstreamPinnipedCLIClientID}, "state": []string{happyDownstreamState}, "nonce": []string{downstreamNonce}, "code_challenge": []string{downstreamPKCEChallenge}, @@ -99,14 +101,10 @@ func TestPostLoginEndpoint(t *testing.T) { } happyDownstreamRequestParams := happyDownstreamRequestParamsQuery.Encode() - copyOfHappyDownstreamRequestParamsQuery := func() url.Values { - params := url.Values{} - for k, v := range happyDownstreamRequestParamsQuery { - params[k] = make([]string, len(v)) - copy(params[k], v) - } - return params - } + happyDownstreamRequestParamsQueryForDynamicClient := shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"client_id": downstreamDynamicClientID}, + ) + happyDownstreamRequestParamsForDynamicClient := happyDownstreamRequestParamsQueryForDynamicClient.Encode() happyLDAPDecodedState := &oidc.UpstreamStateParamData{ AuthParams: happyDownstreamRequestParams, @@ -124,15 +122,20 @@ func TestPostLoginEndpoint(t *testing.T) { return ©OfHappyLDAPDecodedState } - happyActiveDirectoryDecodedState := &oidc.UpstreamStateParamData{ - AuthParams: happyDownstreamRequestParams, - UpstreamName: activeDirectoryUpstreamName, - UpstreamType: activeDirectoryUpstreamType, - Nonce: happyDownstreamNonce, - CSRFToken: happyDownstreamCSRF, - PKCECode: happyDownstreamPKCE, - FormatVersion: happyDownstreamStateVersion, - } + happyLDAPDecodedStateForDynamicClient := modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = happyDownstreamRequestParamsForDynamicClient + }) + + happyActiveDirectoryDecodedState := modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.UpstreamName = activeDirectoryUpstreamName + data.UpstreamType = activeDirectoryUpstreamType + }) + + happyActiveDirectoryDecodedStateForDynamicClient := modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = happyDownstreamRequestParamsForDynamicClient + data.UpstreamName = activeDirectoryUpstreamName + data.UpstreamType = activeDirectoryUpstreamType + }) happyLDAPUsername := "some-ldap-user" happyLDAPUsernameFromAuthenticator := "some-mapped-ldap-username" @@ -232,6 +235,13 @@ func TestPostLoginEndpoint(t *testing.T) { return urlToReturn } + addFullyCapableDynamicClientAndSecretToKubeResources := func(t *testing.T, supervisorClient *supervisorfake.Clientset, kubeClient *fake.Clientset) { + oidcClient, secret := testutil.FullyCapableOIDCClientAndStorageSecret(t, + "some-namespace", downstreamDynamicClientID, downstreamDynamicClientUID, downstreamRedirectURI, []string{testutil.HashedPassword1AtGoMinCost}) + require.NoError(t, supervisorClient.Tracker().Add(oidcClient)) + require.NoError(t, kubeClient.Tracker().Add(secret)) + } + tests := []struct { name string idps *oidctestutil.UpstreamIDPListerBuilder @@ -262,6 +272,7 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamPKCEChallenge string wantDownstreamPKCEChallengeMethod string wantDownstreamNonce string + wantDownstreamClient string wantDownstreamCustomSessionData *psession.CustomSessionData // Authorization requests for either a successful OIDC upstream or for an error with any upstream @@ -289,6 +300,31 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamRedirectURI: downstreamRedirectURI, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamPinnipedCLIClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, + { + name: "happy LDAP login with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithLDAP(&upstreamLDAPIdentityProvider). // should pick this one + WithActiveDirectory(&erroringUpstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + decodedState: happyLDAPDecodedStateForDynamicClient, + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, @@ -311,6 +347,31 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamRedirectURI: downstreamRedirectURI, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamPinnipedCLIClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, + }, + { + name: "happy AD login with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder(). + WithLDAP(&erroringUpstreamLDAPIdentityProvider). + WithActiveDirectory(&upstreamActiveDirectoryIdentityProvider), // should pick this one + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + decodedState: happyActiveDirectoryDecodedStateForDynamicClient, + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: happyAuthcodeDownstreamRedirectLocationRegexp, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyActiveDirectoryUpstreamCustomSession, @@ -319,9 +380,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "happy LDAP login when downstream response_mode=form_post returns 200 with HTML+JS form", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["response_mode"] = []string{"form_post"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"response_mode": "form_post"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusOK, @@ -335,6 +396,7 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamRedirectURI: downstreamRedirectURI, wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamPinnipedCLIClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, @@ -343,9 +405,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "happy LDAP login when downstream redirect uri matches what is configured for client except for the port number", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["redirect_uri"] = []string{"http://127.0.0.1:4242/callback"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"redirect_uri": "http://127.0.0.1:4242/callback"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -359,6 +421,33 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamRedirectURI: "http://127.0.0.1:4242/callback", wantDownstreamGrantedScopes: happyDownstreamScopesGranted, wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamPinnipedCLIClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, + { + name: "happy LDAP login when downstream redirect uri matches what is configured for client except for the port number with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"redirect_uri": "http://127.0.0.1:4242/callback"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: "http://127.0.0.1:4242/callback" + `\?code=([^&]+)&scope=openid\+groups&state=` + happyDownstreamState, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: happyDownstreamScopesRequested, + wantDownstreamRedirectURI: "http://127.0.0.1:4242/callback", + wantDownstreamGrantedScopes: happyDownstreamScopesGranted, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, @@ -367,9 +456,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "happy LDAP login when there are additional allowed downstream requested scopes", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["scope"] = []string{"openid offline_access pinniped:request-audience"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"scope": "openid offline_access pinniped:request-audience"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -383,6 +472,33 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamRedirectURI: downstreamRedirectURI, wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamPinnipedCLIClientID, + wantDownstreamPKCEChallenge: downstreamPKCEChallenge, + wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, + wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, + }, + { + name: "happy LDAP login when there are additional allowed downstream requested scopes with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"scope": "openid offline_access pinniped:request-audience"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationRegexp: downstreamRedirectURI + `\?code=([^&]+)&scope=openid\+offline_access\+pinniped%3Arequest-audience&state=` + happyDownstreamState, + wantDownstreamIDTokenSubject: upstreamLDAPURL + "&sub=" + happyLDAPUID, + wantDownstreamIDTokenUsername: happyLDAPUsernameFromAuthenticator, + wantDownstreamIDTokenGroups: happyLDAPGroups, + wantDownstreamRequestedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantDownstreamRedirectURI: downstreamRedirectURI, + wantDownstreamGrantedScopes: []string{"openid", "offline_access", "pinniped:request-audience"}, + wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamDynamicClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, @@ -391,11 +507,13 @@ func TestPostLoginEndpoint(t *testing.T) { name: "happy LDAP when downstream OIDC validations are skipped because the openid scope was not requested", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["scope"] = []string{"email"} - // The following prompt value is illegal when openid is requested, but note that openid is not requested. - query["prompt"] = []string{"none login"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{ + "scope": "email", + // The following prompt value is illegal when openid is requested, but note that openid is not requested. + "prompt": "none login", + }, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -409,6 +527,7 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamRedirectURI: downstreamRedirectURI, wantDownstreamGrantedScopes: []string{}, // no scopes granted wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamPinnipedCLIClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, @@ -419,9 +538,9 @@ func TestPostLoginEndpoint(t *testing.T) { WithLDAP(&upstreamLDAPIdentityProvider). // should pick this one WithActiveDirectory(&erroringUpstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["scope"] = []string{"openid"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"scope": "openid"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -434,6 +553,7 @@ func TestPostLoginEndpoint(t *testing.T) { wantDownstreamRedirectURI: downstreamRedirectURI, wantDownstreamGrantedScopes: []string{"openid"}, wantDownstreamNonce: downstreamNonce, + wantDownstreamClient: downstreamPinnipedCLIClientID, wantDownstreamPKCEChallenge: downstreamPKCEChallenge, wantDownstreamPKCEChallengeMethod: downstreamPKCEChallengeMethod, wantDownstreamCustomSessionData: expectedHappyLDAPUpstreamCustomSession, @@ -502,9 +622,21 @@ func TestPostLoginEndpoint(t *testing.T) { name: "downstream redirect uri does not match what is configured for client", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["redirect_uri"] = []string{"http://127.0.0.1/wrong_callback"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"redirect_uri": "http://127.0.0.1/wrong_callback"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "downstream redirect uri does not match what is configured for client with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"redirect_uri": "http://127.0.0.1/wrong_callback"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", @@ -513,9 +645,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "downstream client does not exist", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["client_id"] = []string{"wrong_client_id"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"client_id": "wrong_client_id"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", @@ -524,9 +656,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "downstream client is missing", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - delete(query, "client_id") - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"client_id": ""}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", @@ -535,9 +667,21 @@ func TestPostLoginEndpoint(t *testing.T) { name: "response type is unsupported", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["response_type"] = []string{"unsupported"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"response_type": "unsupported"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "response type form_post is unsupported for dynamic clients", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"response_type": "form_post"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", @@ -546,9 +690,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "response type is missing", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - delete(query, "response_type") - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"response_type": ""}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", @@ -557,9 +701,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "PKCE code_challenge is missing", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - delete(query, "code_challenge") - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"code_challenge": ""}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -572,9 +716,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "PKCE code_challenge_method is invalid", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["code_challenge_method"] = []string{"this-is-not-a-valid-pkce-alg"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"code_challenge_method": "this-is-not-a-valid-pkce-alg"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -587,9 +731,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "PKCE code_challenge_method is `plain`", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["code_challenge_method"] = []string{"plain"} // plain is not allowed - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"code_challenge_method": "plain"}, // plain is not allowed + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -602,9 +746,25 @@ func TestPostLoginEndpoint(t *testing.T) { name: "PKCE code_challenge_method is missing", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - delete(query, "code_challenge_method") - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"code_challenge_method": ""}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantStatus: http.StatusSeeOther, + wantContentType: htmlContentType, + wantBodyString: "", + wantRedirectLocationString: urlWithQuery(downstreamRedirectURI, fositeMissingCodeChallengeMethodErrorQuery), + wantUnnecessaryStoredRecords: 2, // fosite already stored the authcode and oidc session before it noticed the error + }, + { + name: "PKCE code_challenge_method is missing with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"code_challenge_method": ""}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -617,9 +777,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "prompt param is not allowed to have none and another legal value at the same time", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["prompt"] = []string{"none login"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"prompt": "none login"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantStatus: http.StatusSeeOther, @@ -632,9 +792,9 @@ func TestPostLoginEndpoint(t *testing.T) { name: "downstream state does not have enough entropy", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["state"] = []string{"short"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"state": "short"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", @@ -643,9 +803,21 @@ func TestPostLoginEndpoint(t *testing.T) { name: "downstream scopes do not match what is configured for client", idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { - query := copyOfHappyDownstreamRequestParamsQuery() - query["scope"] = []string{"openid offline_access pinniped:request-audience scope_not_allowed"} - data.AuthParams = query.Encode() + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQuery, + map[string]string{"scope": "openid offline_access pinniped:request-audience scope_not_allowed"}, + ).Encode() + }), + formParams: happyUsernamePasswordFormParams, + wantErr: "error using state downstream auth params", + }, + { + name: "downstream scopes do not match what is configured for client with dynamic client", + idps: oidctestutil.NewUpstreamIDPListerBuilder().WithLDAP(&upstreamLDAPIdentityProvider), + kubeResources: addFullyCapableDynamicClientAndSecretToKubeResources, + decodedState: modifyHappyLDAPDecodedState(func(data *oidc.UpstreamStateParamData) { + data.AuthParams = shallowCopyAndModifyQuery(happyDownstreamRequestParamsQueryForDynamicClient, + map[string]string{"scope": "openid offline_access pinniped:request-audience scope_not_allowed"}, + ).Encode() }), formParams: happyUsernamePasswordFormParams, wantErr: "error using state downstream auth params", @@ -677,8 +849,8 @@ func TestPostLoginEndpoint(t *testing.T) { secretsClient := kubeClient.CoreV1().Secrets("some-namespace") oidcClientsClient := supervisorClient.ConfigV1alpha1().OIDCClients("some-namespace") - if test.kubeResources != nil { - test.kubeResources(t, supervisorClient, kubeClient) + if tt.kubeResources != nil { + tt.kubeResources(t, supervisorClient, kubeClient) } // Configure fosite the same way that the production code would. @@ -704,7 +876,7 @@ func TestPostLoginEndpoint(t *testing.T) { err := subject(rsp, req, happyEncodedUpstreamState, tt.decodedState) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) - require.Empty(t, kubeClient.Actions()) + require.Empty(t, oidctestutil.FilterClientSecretCreateActions(kubeClient.Actions())) return // the http response doesn't matter when the function returns an error, because the caller should handle the error } // Otherwise, expect no error. @@ -735,7 +907,7 @@ func TestPostLoginEndpoint(t *testing.T) { tt.wantDownstreamPKCEChallenge, tt.wantDownstreamPKCEChallengeMethod, tt.wantDownstreamNonce, - downstreamClientID, + tt.wantDownstreamClient, tt.wantDownstreamRedirectURI, tt.wantDownstreamCustomSessionData, ) @@ -745,12 +917,12 @@ func TestPostLoginEndpoint(t *testing.T) { expectedLocation := downstreamIssuer + oidc.PinnipedLoginPath + "?err=" + tt.wantRedirectToLoginPageError + "&state=" + happyEncodedUpstreamState require.Equal(t, expectedLocation, actualLocation) - require.Len(t, kubeClient.Actions(), tt.wantUnnecessaryStoredRecords) + require.Len(t, oidctestutil.FilterClientSecretCreateActions(kubeClient.Actions()), tt.wantUnnecessaryStoredRecords) case tt.wantRedirectLocationString != "": // Expecting an error redirect to the client. require.Equal(t, tt.wantBodyString, rsp.Body.String()) require.Equal(t, tt.wantRedirectLocationString, actualLocation) - require.Len(t, kubeClient.Actions(), tt.wantUnnecessaryStoredRecords) + require.Len(t, oidctestutil.FilterClientSecretCreateActions(kubeClient.Actions()), tt.wantUnnecessaryStoredRecords) case tt.wantBodyFormResponseRegexp != "": // Expecting the body of the response to be a html page with a form (for "response_mode=form_post"). _, hasLocationHeader := rsp.Header()["Location"] @@ -770,7 +942,7 @@ func TestPostLoginEndpoint(t *testing.T) { tt.wantDownstreamPKCEChallenge, tt.wantDownstreamPKCEChallengeMethod, tt.wantDownstreamNonce, - downstreamClientID, + tt.wantDownstreamClient, tt.wantDownstreamRedirectURI, tt.wantDownstreamCustomSessionData, ) @@ -781,3 +953,18 @@ func TestPostLoginEndpoint(t *testing.T) { }) } } + +func shallowCopyAndModifyQuery(query url.Values, modifications map[string]string) url.Values { + copied := url.Values{} + for key, value := range query { + copied[key] = value + } + for key, value := range modifications { + if value == "" { + copied.Del(key) + } else { + copied[key] = []string{value} + } + } + return copied +} diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index f78eaef5..11218e58 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -457,7 +457,7 @@ func PerformAuthcodeRedirect( ) { authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession) if err != nil { - plog.WarningErr("error while generating and saving authcode", err) + plog.WarningErr("error while generating and saving authcode", err, "fositeErr", FositeErrorForLog(err)) WriteAuthorizeError(w, oauthHelper, authorizeRequester, err, isBrowserless) return } diff --git a/internal/testutil/oidctestutil/oidctestutil.go b/internal/testutil/oidctestutil/oidctestutil.go index fb1e8a7a..23fcc821 100644 --- a/internal/testutil/oidctestutil/oidctestutil.go +++ b/internal/testutil/oidctestutil/oidctestutil.go @@ -25,6 +25,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/fake" v1 "k8s.io/client-go/kubernetes/typed/core/v1" + kubetesting "k8s.io/client-go/testing" "k8s.io/utils/strings/slices" "go.pinniped.dev/internal/authenticators" @@ -954,7 +955,7 @@ func RequireAuthCodeRegexpMatch( if includesOpenIDScope(wantDownstreamGrantedScopes) { expectedNumberOfCreatedSecrets++ } - require.Len(t, kubeClient.Actions(), expectedNumberOfCreatedSecrets) + require.Len(t, FilterClientSecretCreateActions(kubeClient.Actions()), expectedNumberOfCreatedSecrets) // One authcode should have been stored. testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secretsClient, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -1164,3 +1165,20 @@ func castStoredAuthorizeRequest(t *testing.T, storedAuthorizeRequest fosite.Requ return storedRequest, storedSession } + +// FilterClientSecretCreateActions ignores any reads made to get a storage secret corresponding to an OIDCClient, since these +// are normal actions when the request is using a dynamic client's client_id, and we don't need to make assertions +// about these Secrets since they are not related to session storage. +func FilterClientSecretCreateActions(actions []kubetesting.Action) []kubetesting.Action { + filtered := make([]kubetesting.Action, 0, len(actions)) + for _, action := range actions { + if action.Matches("get", "secrets") { + getAction := action.(kubetesting.GetAction) + if strings.HasPrefix(getAction.GetName(), "pinniped-storage-oidc-client-secret-") { + continue // filter out OIDCClient's storage secret reads + } + } + filtered = append(filtered, action) // otherwise include the action + } + return filtered +} diff --git a/test/testlib/client.go b/test/testlib/client.go index 2c514f7d..efad330b 100644 --- a/test/testlib/client.go +++ b/test/testlib/client.go @@ -15,6 +15,8 @@ import ( "testing" "time" + "go.pinniped.dev/internal/oidc/oidcclientvalidator" + "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" authorizationv1 "k8s.io/api/authorization/v1" @@ -435,7 +437,7 @@ func createOIDCClientSecret(t *testing.T, forOIDCClient *configv1alpha1.OIDCClie _, err := io.ReadFull(rand.Reader, buf[:]) require.NoError(t, err) randomSecret := hex.EncodeToString(buf[:]) - hashedRandomSecret, err := bcrypt.GenerateFromPassword([]byte(randomSecret), 15) + hashedRandomSecret, err := bcrypt.GenerateFromPassword([]byte(randomSecret), oidcclientvalidator.DefaultMinBcryptCost) require.NoError(t, err) created, err := kubeClient.CoreV1().Secrets(env.SupervisorNamespace).Create(ctx, &corev1.Secret{ From c12ffad29ebc5675b148eeea28002a269cdab0f9 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Jul 2022 10:13:34 -0700 Subject: [PATCH 5/8] Add integration test for failed client auth for a dynamic client --- test/integration/supervisor_login_test.go | 283 ++++++++++++---------- 1 file changed, 161 insertions(+), 122 deletions(-) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 86c3f67f..981d3343 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -224,10 +224,14 @@ func TestSupervisorLogin_Browser(t *testing.T) { // Want the authorization endpoint to redirect to the callback with this error type. // The rest of the flow will be skipped since the initial authorization failed. - wantErrorType string + wantAuthorizationErrorType string // Want the authorization endpoint to redirect to the callback with this error description. - // Should be used with wantErrorType. - wantErrorDescription string + // Should be used with wantAuthorizationErrorType. + wantAuthorizationErrorDescription string + + // Optionally want to the authcode exchange at the token endpoint to fail. The rest of the flow will be + // skipped since the authcode exchange failed. + wantAuthcodeExchangeError string // Optionally make all required assertions about the response of the RFC8693 token exchange for // the cluster-scoped ID token, given the http response status and response body from the token endpoint. @@ -674,8 +678,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { true, ) }, - wantErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.", - wantErrorType: "access_denied", + wantAuthorizationErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.", + wantAuthorizationErrorType: "access_denied", }, { name: "ldap login still works after updating bind secret", @@ -1125,9 +1129,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { true, ) }, - breakRefreshSessionData: nil, - wantErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.", - wantErrorType: "access_denied", + breakRefreshSessionData: nil, + wantAuthorizationErrorDescription: "The resource owner or authorization server denied the request. Username/password not accepted by LDAP provider.", + wantAuthorizationErrorType: "access_denied", }, { name: "ldap refresh fails when username changes from email as username to dn as username", @@ -1366,6 +1370,30 @@ func TestSupervisorLogin_Browser(t *testing.T) { }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamActiveDirectory.TestUserIndirectGroupsSAMAccountPlusDomainNames, }, + { + name: "ldap upstream with downstream dynamic client, failed client authentication", + maybeSkip: skipLDAPTests, + createIDP: func(t *testing.T) string { + idp, _ := createLDAPIdentityProvider(t, nil) + return idp.Name + }, + createOIDCClient: func(t *testing.T, callbackURL string) (string, string) { + clientID, _ := testlib.CreateOIDCClient(t, configv1alpha1.OIDCClientSpec{ + AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + }, configv1alpha1.PhaseReady) + return clientID, "wrong-client-secret" + }, + testUser: func(t *testing.T) (string, string) { + // return the username and password of the existing user that we want to use for this test + return env.SupervisorUpstreamLDAP.TestUserMailAttributeValue, // username to present to server during login + env.SupervisorUpstreamLDAP.TestUserPassword // password to present to server during login + }, + requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowLDAP, + wantAuthcodeExchangeError: "oauth2: cannot fetch token: 401 Unauthorized\n" + + `Response: {"error":"invalid_client","error_description":"Client authentication failed (e.g., unknown client, no client authentication included, or unsupported authentication method)."}`, + }, } for _, test := range tests { @@ -1373,7 +1401,8 @@ func TestSupervisorLogin_Browser(t *testing.T) { t.Run(tt.name, func(t *testing.T) { tt.maybeSkip(t) - testSupervisorLogin(t, + testSupervisorLogin( + t, tt.createIDP, tt.requestAuthorization, tt.editRefreshSessionDataWithoutBreaking, @@ -1386,8 +1415,9 @@ func TestSupervisorLogin_Browser(t *testing.T) { tt.wantDownstreamIDTokenSubjectToMatch, tt.wantDownstreamIDTokenUsernameToMatch, tt.wantDownstreamIDTokenGroups, - tt.wantErrorDescription, - tt.wantErrorType, + tt.wantAuthorizationErrorType, + tt.wantAuthorizationErrorDescription, + tt.wantAuthcodeExchangeError, tt.wantTokenExchangeResponse, ) }) @@ -1516,8 +1546,8 @@ func testSupervisorLogin( t *testing.T, createIDP func(t *testing.T) string, requestAuthorization func(t *testing.T, downstreamIssuer string, downstreamAuthorizeURL string, downstreamCallbackURL string, username string, password string, httpClient *http.Client), - editRefreshSessionDataWithoutBreaking func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string) []string, - breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName, username string), + editRefreshSessionDataWithoutBreaking func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string, username string) []string, + breakRefreshSessionData func(t *testing.T, pinnipedSession *psession.PinnipedSession, idpName string, username string), testUser func(t *testing.T) (string, string), createOIDCClient func(t *testing.T, callbackURL string) (string, string), downstreamScopes []string, @@ -1526,8 +1556,9 @@ func testSupervisorLogin( wantDownstreamIDTokenSubjectToMatch string, wantDownstreamIDTokenUsernameToMatch func(username string) string, wantDownstreamIDTokenGroups []string, - wantErrorDescription string, - wantErrorType string, + wantAuthorizationErrorType string, + wantAuthorizationErrorDescription string, + wantAuthcodeExchangeError string, wantTokenExchangeResponse func(t *testing.T, status int, body string), ) { env := testlib.IntegrationEnv(t) @@ -1693,116 +1724,124 @@ func testSupervisorLogin( require.NoError(t, err) t.Logf("got callback request: %s", testlib.MaskTokens(callback.URL.String())) - if wantErrorType == "" { // nolint:nestif - require.Equal(t, stateParam.String(), callback.URL.Query().Get("state")) - require.ElementsMatch(t, downstreamScopes, strings.Split(callback.URL.Query().Get("scope"), " ")) - authcode := callback.URL.Query().Get("code") - require.NotEmpty(t, authcode) - // Authcodes should start with the custom prefix "pin_ac_" to make them identifiable as authcodes when seen by a user out of context. - require.True(t, strings.HasPrefix(authcode, "pin_ac_"), "token %q did not have expected prefix 'pin_ac_'", authcode) - - // Call the token endpoint to get tokens. - tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier()) - require.NoError(t, err) - - expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username"} - if slices.Contains(downstreamScopes, "groups") { - expectedIDTokenClaims = append(expectedIDTokenClaims, "groups") - } - verifyTokenResponse(t, - tokenResponse, discovery, downstreamOAuth2Config, nonceParam, - expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups) - - // token exchange on the original token - if requestTokenExchangeAud == "" { - requestTokenExchangeAud = "some-cluster-123" // use a default test value - } - doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, tokenResponse, httpClient, discovery, wantTokenExchangeResponse) - - refreshedGroups := wantDownstreamIDTokenGroups - if editRefreshSessionDataWithoutBreaking != nil { - latestRefreshToken := tokenResponse.RefreshToken - signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken) - - // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. - supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) - supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) - storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) - require.NoError(t, err) - - // Next mutate the part of the session that is used during upstream refresh. - pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) - require.True(t, ok, "should have been able to cast session data to PinnipedSession") - - refreshedGroups = editRefreshSessionDataWithoutBreaking(t, pinnipedSession, idpName, username) - - // Then save the mutated Secret back to Kubernetes. - // There is no update function, so delete and create again at the same name. - require.NoError(t, oauthStore.DeleteRefreshTokenSession(ctx, signatureOfLatestRefreshToken)) - require.NoError(t, oauthStore.CreateRefreshTokenSession(ctx, signatureOfLatestRefreshToken, storedRefreshSession)) - } - // Use the refresh token to get new tokens - refreshSource := downstreamOAuth2Config.TokenSource(oidcHTTPClientContext, &oauth2.Token{RefreshToken: tokenResponse.RefreshToken}) - refreshedTokenResponse, err := refreshSource.Token() - require.NoError(t, err) - - // When refreshing, expect to get an "at_hash" claim, but no "nonce" claim. - expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "at_hash"} - if slices.Contains(downstreamScopes, "groups") { - expectRefreshedIDTokenClaims = append(expectRefreshedIDTokenClaims, "groups") - } - verifyTokenResponse(t, - refreshedTokenResponse, discovery, downstreamOAuth2Config, "", - expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), refreshedGroups) - - require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) - require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) - require.NotEqual(t, tokenResponse.Extra("id_token"), refreshedTokenResponse.Extra("id_token")) - - // token exchange on the refreshed token - doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, refreshedTokenResponse, httpClient, discovery, wantTokenExchangeResponse) - - // Now that we have successfully performed a refresh, let's test what happens when an - // upstream refresh fails during the next downstream refresh. - if breakRefreshSessionData != nil { - latestRefreshToken := refreshedTokenResponse.RefreshToken - signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken) - - // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. - supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) - supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) - oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) - storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) - require.NoError(t, err) - - // Next mutate the part of the session that is used during upstream refresh. - pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) - require.True(t, ok, "should have been able to cast session data to PinnipedSession") - breakRefreshSessionData(t, pinnipedSession, idpName, username) - - // Then save the mutated Secret back to Kubernetes. - // There is no update function, so delete and create again at the same name. - require.NoError(t, oauthStore.DeleteRefreshTokenSession(ctx, signatureOfLatestRefreshToken)) - require.NoError(t, oauthStore.CreateRefreshTokenSession(ctx, signatureOfLatestRefreshToken, storedRefreshSession)) - - // Now try to perform a downstream refresh again, knowing that the corresponding upstream refresh should fail. - _, err = downstreamOAuth2Config.TokenSource(oidcHTTPClientContext, &oauth2.Token{RefreshToken: latestRefreshToken}).Token() - // Should have got an error since the upstream refresh should have failed. - require.Error(t, err) - require.Regexp(t, - regexp.QuoteMeta("oauth2: cannot fetch token: 401 Unauthorized\n")+ - regexp.QuoteMeta(`Response: {"error":"error","error_description":"Error during upstream refresh. Upstream refresh failed`)+ - "[^']+", - err.Error(), - ) - } - } else { + if wantAuthorizationErrorType != "" { errorDescription := callback.URL.Query().Get("error_description") errorType := callback.URL.Query().Get("error") - require.Equal(t, errorDescription, wantErrorDescription) - require.Equal(t, errorType, wantErrorType) + require.Equal(t, errorDescription, wantAuthorizationErrorDescription) + require.Equal(t, errorType, wantAuthorizationErrorType) + // The authorization has failed, so can't continue the login flow, making this the end of the test case. + return + } + + require.Equal(t, stateParam.String(), callback.URL.Query().Get("state")) + require.ElementsMatch(t, downstreamScopes, strings.Split(callback.URL.Query().Get("scope"), " ")) + authcode := callback.URL.Query().Get("code") + require.NotEmpty(t, authcode) + + // Authcodes should start with the custom prefix "pin_ac_" to make them identifiable as authcodes when seen by a user out of context. + require.True(t, strings.HasPrefix(authcode, "pin_ac_"), "token %q did not have expected prefix 'pin_ac_'", authcode) + + // Call the token endpoint to get tokens. + tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier()) + if wantAuthcodeExchangeError != "" { + require.EqualError(t, err, wantAuthcodeExchangeError) + // The authcode exchange has failed, so can't continue the login flow, making this the end of the test case. + return + } else { + require.NoError(t, err) + } + expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username"} + if slices.Contains(downstreamScopes, "groups") { + expectedIDTokenClaims = append(expectedIDTokenClaims, "groups") + } + verifyTokenResponse(t, + tokenResponse, discovery, downstreamOAuth2Config, nonceParam, + expectedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups) + + // token exchange on the original token + if requestTokenExchangeAud == "" { + requestTokenExchangeAud = "some-cluster-123" // use a default test value + } + doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, tokenResponse, httpClient, discovery, wantTokenExchangeResponse) + + refreshedGroups := wantDownstreamIDTokenGroups + if editRefreshSessionDataWithoutBreaking != nil { + latestRefreshToken := tokenResponse.RefreshToken + signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken) + + // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. + supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) + supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) + storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) + require.NoError(t, err) + + // Next mutate the part of the session that is used during upstream refresh. + pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) + require.True(t, ok, "should have been able to cast session data to PinnipedSession") + + refreshedGroups = editRefreshSessionDataWithoutBreaking(t, pinnipedSession, idpName, username) + + // Then save the mutated Secret back to Kubernetes. + // There is no update function, so delete and create again at the same name. + require.NoError(t, oauthStore.DeleteRefreshTokenSession(ctx, signatureOfLatestRefreshToken)) + require.NoError(t, oauthStore.CreateRefreshTokenSession(ctx, signatureOfLatestRefreshToken, storedRefreshSession)) + } + // Use the refresh token to get new tokens + refreshSource := downstreamOAuth2Config.TokenSource(oidcHTTPClientContext, &oauth2.Token{RefreshToken: tokenResponse.RefreshToken}) + refreshedTokenResponse, err := refreshSource.Token() + require.NoError(t, err) + + // When refreshing, expect to get an "at_hash" claim, but no "nonce" claim. + expectRefreshedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "rat", "username", "at_hash"} + if slices.Contains(downstreamScopes, "groups") { + expectRefreshedIDTokenClaims = append(expectRefreshedIDTokenClaims, "groups") + } + verifyTokenResponse(t, + refreshedTokenResponse, discovery, downstreamOAuth2Config, "", + expectRefreshedIDTokenClaims, wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch(username), refreshedGroups) + + require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) + require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) + require.NotEqual(t, tokenResponse.Extra("id_token"), refreshedTokenResponse.Extra("id_token")) + + // token exchange on the refreshed token + doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, refreshedTokenResponse, httpClient, discovery, wantTokenExchangeResponse) + + // Now that we have successfully performed a refresh, let's test what happens when an + // upstream refresh fails during the next downstream refresh. + if breakRefreshSessionData != nil { + latestRefreshToken := refreshedTokenResponse.RefreshToken + signatureOfLatestRefreshToken := getFositeDataSignature(t, latestRefreshToken) + + // First use the latest downstream refresh token to look up the corresponding session in the Supervisor's storage. + supervisorSecretsClient := testlib.NewKubernetesClientset(t).CoreV1().Secrets(env.SupervisorNamespace) + supervisorOIDCClientsClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().OIDCClients(env.SupervisorNamespace) + oauthStore := oidc.NewKubeStorage(supervisorSecretsClient, supervisorOIDCClientsClient, oidc.DefaultOIDCTimeoutsConfiguration(), oidcclientvalidator.DefaultMinBcryptCost) + storedRefreshSession, err := oauthStore.GetRefreshTokenSession(ctx, signatureOfLatestRefreshToken, nil) + require.NoError(t, err) + + // Next mutate the part of the session that is used during upstream refresh. + pinnipedSession, ok := storedRefreshSession.GetSession().(*psession.PinnipedSession) + require.True(t, ok, "should have been able to cast session data to PinnipedSession") + breakRefreshSessionData(t, pinnipedSession, idpName, username) + + // Then save the mutated Secret back to Kubernetes. + // There is no update function, so delete and create again at the same name. + require.NoError(t, oauthStore.DeleteRefreshTokenSession(ctx, signatureOfLatestRefreshToken)) + require.NoError(t, oauthStore.CreateRefreshTokenSession(ctx, signatureOfLatestRefreshToken, storedRefreshSession)) + + // Now try to perform a downstream refresh again, knowing that the corresponding upstream refresh should fail. + _, err = downstreamOAuth2Config.TokenSource(oidcHTTPClientContext, &oauth2.Token{RefreshToken: latestRefreshToken}).Token() + // Should have got an error since the upstream refresh should have failed. + require.Error(t, err) + require.Regexp(t, + regexp.QuoteMeta("oauth2: cannot fetch token: 401 Unauthorized\n")+ + regexp.QuoteMeta(`Response: {"error":"error","error_description":"Error during upstream refresh. Upstream refresh failed`)+ + "[^']+", + err.Error(), + ) } } From 0495286f9727fa39b362d3497f89466829fffb36 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Jul 2022 13:50:33 -0700 Subject: [PATCH 6/8] Fix lint error and remove accidental direct dep on ory/x Fixing some mistakes from previous commit on feature branch. --- internal/oidc/token_exchange.go | 5 ++--- test/integration/supervisor_login_test.go | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/internal/oidc/token_exchange.go b/internal/oidc/token_exchange.go index 5ed83b5e..9cbf566d 100644 --- a/internal/oidc/token_exchange.go +++ b/internal/oidc/token_exchange.go @@ -13,7 +13,6 @@ import ( "github.com/ory/fosite/compose" "github.com/ory/fosite/handler/oauth2" "github.com/ory/fosite/handler/openid" - "github.com/ory/x/errorsx" "github.com/pkg/errors" "go.pinniped.dev/internal/oidc/clientregistry" @@ -75,13 +74,13 @@ func (t *TokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context // Check that the currently authenticated client and the client which was originally used to get the access token are the same. if originalRequester.GetClient().GetID() != requester.GetClient().GetID() { // This error message is copied from the similar check in fosite's flow_authorize_code_token.go. - return errorsx.WithStack(fosite.ErrInvalidGrant.WithHint("The OAuth 2.0 Client ID from this request does not match the one from the authorize request.")) + return errors.WithStack(fosite.ErrInvalidGrant.WithHint("The OAuth 2.0 Client ID from this request does not match the one from the authorize request.")) } // Check that the client is allowed to perform this grant type. if !requester.GetClient().GetGrantTypes().Has(tokenExchangeGrantType) { // This error message is trying to be similar to the analogous one in fosite's flow_authorize_code_token.go. - return errorsx.WithStack(fosite.ErrUnauthorizedClient.WithHintf("The OAuth 2.0 Client is not allowed to use token exchange grant \"%s\".", tokenExchangeGrantType)) + return errors.WithStack(fosite.ErrUnauthorizedClient.WithHintf(`The OAuth 2.0 Client is not allowed to use token exchange grant "%s".`, tokenExchangeGrantType)) } // Require that the incoming access token has the pinniped:request-audience and OpenID scopes. diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 981d3343..b465a17d 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -1748,9 +1748,8 @@ func testSupervisorLogin( require.EqualError(t, err, wantAuthcodeExchangeError) // The authcode exchange has failed, so can't continue the login flow, making this the end of the test case. return - } else { - require.NoError(t, err) } + require.NoError(t, err) expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat", "username"} if slices.Contains(downstreamScopes, "groups") { expectedIDTokenClaims = append(expectedIDTokenClaims, "groups") From b65f872dcdb449fbcdc4e9638c1f7a557c7ed585 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Thu, 21 Jul 2022 16:40:03 -0700 Subject: [PATCH 7/8] Configure printer columns for OIDCClient CRD --- apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl | 6 +++++- .../config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- .../crds/config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- .../crds/config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- .../crds/config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- .../crds/config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- .../crds/config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- .../crds/config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- .../crds/config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- .../crds/config.supervisor.pinniped.dev_oidcclients.yaml | 9 +++++++++ .../apis/supervisor/config/v1alpha1/types_oidcclient.go | 6 +++++- test/integration/kube_api_discovery_test.go | 3 +++ 20 files changed, 134 insertions(+), 10 deletions(-) diff --git a/apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl b/apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl index 8604a4f1..719a597f 100644 --- a/apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl +++ b/apis/supervisor/config/v1alpha1/types_oidcclient.go.tmpl @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/deploy/supervisor/config.supervisor.pinniped.dev_oidcclients.yaml b/deploy/supervisor/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/deploy/supervisor/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/deploy/supervisor/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/1.17/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.17/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/1.17/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.17/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/generated/1.17/crds/config.supervisor.pinniped.dev_oidcclients.yaml b/generated/1.17/crds/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/generated/1.17/crds/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/generated/1.17/crds/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/1.18/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.18/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/1.18/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.18/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/generated/1.18/crds/config.supervisor.pinniped.dev_oidcclients.yaml b/generated/1.18/crds/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/generated/1.18/crds/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/generated/1.18/crds/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/1.19/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.19/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/1.19/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.19/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/generated/1.19/crds/config.supervisor.pinniped.dev_oidcclients.yaml b/generated/1.19/crds/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/generated/1.19/crds/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/generated/1.19/crds/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/1.20/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.20/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/1.20/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.20/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/generated/1.20/crds/config.supervisor.pinniped.dev_oidcclients.yaml b/generated/1.20/crds/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/generated/1.20/crds/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/generated/1.20/crds/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/1.21/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.21/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/1.21/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.21/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/generated/1.21/crds/config.supervisor.pinniped.dev_oidcclients.yaml b/generated/1.21/crds/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/generated/1.21/crds/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/generated/1.21/crds/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/1.22/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.22/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/1.22/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.22/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/generated/1.22/crds/config.supervisor.pinniped.dev_oidcclients.yaml b/generated/1.22/crds/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/generated/1.22/crds/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/generated/1.22/crds/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/1.23/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.23/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/1.23/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.23/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/generated/1.23/crds/config.supervisor.pinniped.dev_oidcclients.yaml b/generated/1.23/crds/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/generated/1.23/crds/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/generated/1.23/crds/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/1.24/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/1.24/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/1.24/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/1.24/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/generated/1.24/crds/config.supervisor.pinniped.dev_oidcclients.yaml b/generated/1.24/crds/config.supervisor.pinniped.dev_oidcclients.yaml index 76c0cab0..e4978627 100644 --- a/generated/1.24/crds/config.supervisor.pinniped.dev_oidcclients.yaml +++ b/generated/1.24/crds/config.supervisor.pinniped.dev_oidcclients.yaml @@ -18,6 +18,15 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: + - jsonPath: .spec.allowedScopes[?(@ == "pinniped:request-audience")] + name: Privileged Scopes + type: string + - jsonPath: .status.totalClientSecrets + name: Client Secrets + type: integer + - jsonPath: .status.phase + name: Status + type: string - jsonPath: .metadata.creationTimestamp name: Age type: date diff --git a/generated/latest/apis/supervisor/config/v1alpha1/types_oidcclient.go b/generated/latest/apis/supervisor/config/v1alpha1/types_oidcclient.go index 8604a4f1..719a597f 100644 --- a/generated/latest/apis/supervisor/config/v1alpha1/types_oidcclient.go +++ b/generated/latest/apis/supervisor/config/v1alpha1/types_oidcclient.go @@ -88,13 +88,17 @@ type OIDCClientStatus struct { Conditions []Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type"` // totalClientSecrets is the current number of client secrets that are detected for this OIDCClient. - TotalClientSecrets int32 `json:"totalClientSecrets,omitempty"` + // +optional + TotalClientSecrets int32 `json:"totalClientSecrets"` // do not omitempty to allow it to show in the printer column even when it is 0 } // OIDCClient describes the configuration of an OIDC client. // +genclient // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object // +kubebuilder:resource:categories=pinniped +// +kubebuilder:printcolumn:name="Privileged Scopes",type=string,JSONPath=`.spec.allowedScopes[?(@ == "pinniped:request-audience")]` +// +kubebuilder:printcolumn:name="Client Secrets",type=integer,JSONPath=`.status.totalClientSecrets` +// +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.phase` // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // +kubebuilder:subresource:status type OIDCClient struct { diff --git a/test/integration/kube_api_discovery_test.go b/test/integration/kube_api_discovery_test.go index c46d01bf..d2a49fa2 100644 --- a/test/integration/kube_api_discovery_test.go +++ b/test/integration/kube_api_discovery_test.go @@ -527,6 +527,9 @@ func TestCRDAdditionalPrinterColumns_Parallel(t *testing.T) { }, addSuffix("oidcclients.config.supervisor"): { "v1alpha1": []apiextensionsv1.CustomResourceColumnDefinition{ + {Name: "Privileged Scopes", Type: "string", JSONPath: `.spec.allowedScopes[?(@ == "pinniped:request-audience")]`}, + {Name: "Client Secrets", Type: "integer", JSONPath: ".status.totalClientSecrets"}, + {Name: "Status", Type: "string", JSONPath: ".status.phase"}, {Name: "Age", Type: "date", JSONPath: ".metadata.creationTimestamp"}, }, }, From 88f611d31a07382565ca78485980023ac8ef2145 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 22 Jul 2022 15:19:19 -0700 Subject: [PATCH 8/8] Be extra defensive and don't lookup dynamic client ID's lacking prefix --- .../oidc/clientregistry/clientregistry.go | 17 ++++++++++--- .../clientregistry/clientregistry_test.go | 25 +++++++++++++++++++ internal/supervisor/server/server.go | 2 +- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/oidc/clientregistry/clientregistry.go b/internal/oidc/clientregistry/clientregistry.go index 0de96cfa..90451784 100644 --- a/internal/oidc/clientregistry/clientregistry.go +++ b/internal/oidc/clientregistry/clientregistry.go @@ -7,6 +7,7 @@ package clientregistry import ( "context" "fmt" + "strings" "time" "github.com/coreos/go-oidc/v3/oidc" @@ -21,8 +22,12 @@ import ( "go.pinniped.dev/internal/plog" ) -// PinnipedCLIClientID is the client ID of the statically defined public OIDC client which is used by the CLI. -const PinnipedCLIClientID = "pinniped-cli" +const ( + // PinnipedCLIClientID is the client ID of the statically defined public OIDC client which is used by the CLI. + PinnipedCLIClientID = "pinniped-cli" + + requiredOIDCClientPrefix = "client.oauth.pinniped.dev-" +) // Client represents a Pinniped OAuth/OIDC client. It can be the static pinniped-cli client // or a dynamic client defined by an OIDCClient CR. @@ -37,7 +42,7 @@ var ( _ fosite.ResponseModeClient = (*Client)(nil) ) -func (c Client) GetResponseModes() []fosite.ResponseModeType { +func (c *Client) GetResponseModes() []fosite.ResponseModeType { if c.ID == PinnipedCLIClientID { // The pinniped-cli client supports "" (unspecified), "query", and "form_post" response modes. return []fosite.ResponseModeType{fosite.ResponseModeDefault, fosite.ResponseModeQuery, fosite.ResponseModeFormPost} @@ -78,6 +83,12 @@ func (m *ClientManager) GetClient(ctx context.Context, id string) (fosite.Client return PinnipedCLI(), nil } + if !strings.HasPrefix(id, requiredOIDCClientPrefix) { + // It shouldn't really be possible to find this OIDCClient because the OIDCClient CRD validates the name prefix + // upon create, but just in case, don't even try to lookup clients which lack the required name prefix. + return nil, fosite.ErrNotFound.WithDescription("no such client") + } + // Try to look up an OIDCClient with the given client ID (which will be the Name of the OIDCClient). oidcClient, err := m.oidcClientsClient.Get(ctx, id, v1.GetOptions{}) if errors.IsNotFound(err) { diff --git a/internal/oidc/clientregistry/clientregistry_test.go b/internal/oidc/clientregistry/clientregistry_test.go index 77ab18b9..b0b2e01e 100644 --- a/internal/oidc/clientregistry/clientregistry_test.go +++ b/internal/oidc/clientregistry/clientregistry_test.go @@ -125,6 +125,31 @@ func TestClientManager(t *testing.T) { require.Nil(t, got) }, }, + { + name: "find a dynamic client which somehow does not have the required prefix in its name, just in case, although should not be possible since prefix is a validation on the CRD", + oidcClients: []*configv1alpha1.OIDCClient{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "does-not-have-prefix", Generation: 1234, UID: testUID}, + Spec: configv1alpha1.OIDCClientSpec{ + AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, + AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + AllowedRedirectURIs: []configv1alpha1.RedirectURI{"http://localhost:80", "https://foobar.com/callback"}, + }, + }, + }, + secrets: []*corev1.Secret{ + testutil.OIDCClientSecretStorageSecretForUID(t, testNamespace, testUID, []string{testutil.HashedPassword1AtSupervisorMinCost, testutil.HashedPassword2AtSupervisorMinCost}), + }, + run: func(t *testing.T, subject *ClientManager) { + got, err := subject.GetClient(ctx, "does-not-have-prefix") + require.Error(t, err) + require.Nil(t, got) + rfcErr := fosite.ErrorToRFC6749Error(err) + require.NotNil(t, rfcErr) + require.Equal(t, rfcErr.CodeField, 404) + require.Equal(t, rfcErr.GetDescription(), "no such client") + }, + }, { name: "when there is an unexpected error getting the OIDCClient", addSupervisorReactions: func(client *supervisorfake.Clientset) { diff --git a/internal/supervisor/server/server.go b/internal/supervisor/server/server.go index ac71376a..76a034ff 100644 --- a/internal/supervisor/server/server.go +++ b/internal/supervisor/server/server.go @@ -439,7 +439,7 @@ func runSupervisor(ctx context.Context, podInfo *downward.PodInfo, cfg *supervis dynamicUpstreamIDPProvider, &secretCache, clientWithoutLeaderElection.Kubernetes.CoreV1().Secrets(serverInstallationNamespace), // writes to kube storage are allowed for non-leaders - clientWithoutLeaderElection.PinnipedSupervisor.ConfigV1alpha1().OIDCClients(serverInstallationNamespace), + client.PinnipedSupervisor.ConfigV1alpha1().OIDCClients(serverInstallationNamespace), ) // Get the "real" name of the client secret supervisor API group (i.e., the API group name with the