diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index 8dc3f2a28..35c15dc93 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. +# Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. # SPDX-License-Identifier: Apache-2.0 # @@ -535,6 +535,9 @@ export PINNIPED_TEST_CLI_OIDC_CLIENT_ID=pinniped-cli export PINNIPED_TEST_CLI_OIDC_CALLBACK_URL=http://127.0.0.1:48095/callback export PINNIPED_TEST_CLI_OIDC_USERNAME=pinny@example.com export PINNIPED_TEST_CLI_OIDC_PASSWORD=${dex_test_password} +export PINNIPED_TEST_CLI_OIDC_USERNAME_CLAIM=email +export PINNIPED_TEST_CLI_OIDC_GROUPS_CLAIM=groups +export PINNIPED_TEST_CLI_OIDC_EXPECTED_GROUPS= # Dex's local user store does not let us configure groups. export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER=https://dex.tools.svc.cluster.local/dex export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ISSUER_CA_BUNDLE="${test_ca_bundle_pem}" export PINNIPED_TEST_SUPERVISOR_UPSTREAM_OIDC_ADDITIONAL_SCOPES="offline_access,email" diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go index 45439c30f..3b5e178e7 100644 --- a/test/integration/cli_test.go +++ b/test/integration/cli_test.go @@ -222,7 +222,7 @@ func TestCLILoginOIDC_Browser(t *testing.T) { cacheKey := oidcclient.SessionCacheKey{ Issuer: env.CLIUpstreamOIDC.Issuer, ClientID: env.CLIUpstreamOIDC.ClientID, - Scopes: []string{"email", "offline_access", "openid", "profile"}, + Scopes: []string{"email", "groups", "offline_access", "openid", "profile"}, // in alphabetical order RedirectURI: strings.ReplaceAll(env.CLIUpstreamOIDC.CallbackURL, "127.0.0.1", "localhost"), } cached := cache.GetToken(cacheKey) @@ -413,7 +413,7 @@ func oidcLoginCommand(ctx context.Context, t *testing.T, pinnipedExe string, ses cmd := exec.CommandContext(ctx, pinnipedExe, "login", "oidc", "--issuer", env.CLIUpstreamOIDC.Issuer, "--client-id", env.CLIUpstreamOIDC.ClientID, - "--scopes", "offline_access,openid,email,profile", + "--scopes", "offline_access,openid,email,profile,groups", "--listen-port", callbackURL.Port(), "--session-cache", sessionCachePath, "--credential-cache", t.TempDir()+"/credentials.yaml", diff --git a/test/integration/concierge_credentialrequest_test.go b/test/integration/concierge_credentialrequest_test.go index 01f5dd98c..91a3a90b9 100644 --- a/test/integration/concierge_credentialrequest_test.go +++ b/test/integration/concierge_credentialrequest_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -6,7 +6,9 @@ package integration import ( "context" "crypto/x509" + "encoding/base64" "encoding/pem" + "fmt" "testing" "time" @@ -23,12 +25,306 @@ import ( "go.pinniped.dev/test/testlib" ) +// TestCredentialRequest_Browser cannot run in parallel because runPinnipedLoginOIDC uses a fixed port +// for its localhost listener via --listen-port=env.CLIUpstreamOIDC.CallbackURL.Port() per oidcLoginCommand. +// Since ports are global to the process, tests using oidcLoginCommand must be run serially. +func TestCredentialRequest_Browser(t *testing.T) { + env := testlib.IntegrationEnv(t).WithCapability(testlib.ClusterSigningKeyIsAvailable) + + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + t.Cleanup(cancel) + + jwtAuthenticatorTypedLocalObjectReference := func(a *authenticationv1alpha1.JWTAuthenticator) corev1.TypedLocalObjectReference { + return corev1.TypedLocalObjectReference{ + APIGroup: &authenticationv1alpha1.SchemeGroupVersion.Group, + Kind: "JWTAuthenticator", + Name: a.Name, + } + } + + expectedExtras := func(t *testing.T, jwt string) []string { + // Dex tokens do not include a jti claim, so check if it exists. + claims := getJWTClaims(t, jwt) + _, ok := claims["jti"] + if !ok { + return []string{} + } + + // Okta tokens contain a jti, so use it to make the expected value. + jti := getJWTClaimAsString(t, jwt, "jti") + require.NotEmpty(t, jti) + return []string{ + // The Kubernetes jwtAuthenticator will automatically add this extra when there is a jti claim. + fmt.Sprintf("authentication.kubernetes.io/credential-id=JTI=%s", jti), + } + } + + tests := []struct { + name string + authenticator func(context.Context, *testing.T) corev1.TypedLocalObjectReference + token func(t *testing.T) (tokenToSubmit string, wantUsername string, wantGroups []string, wantExtras []string) + }{ + { + name: "webhook", + authenticator: func(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { + return testlib.CreateTestWebhookAuthenticator(ctx, t, &env.TestWebhook, authenticationv1alpha1.WebhookAuthenticatorPhaseReady) + }, + token: func(t *testing.T) (string, string, []string, []string) { + return env.TestUser.Token, env.TestUser.ExpectedUsername, env.TestUser.ExpectedGroups, []string{} + }, + }, + { + name: "minimal jwt authenticator", + authenticator: func(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { + authenticator := testlib.CreateTestJWTAuthenticator(ctx, t, authenticationv1alpha1.JWTAuthenticatorSpec{ + Issuer: env.CLIUpstreamOIDC.Issuer, + Audience: env.CLIUpstreamOIDC.ClientID, + Claims: authenticationv1alpha1.JWTTokenClaims{ + Username: env.CLIUpstreamOIDC.UsernameClaim, + Groups: env.CLIUpstreamOIDC.GroupsClaim, + }, + TLS: tlsSpecForCLIUpstreamOIDC(t), + }, authenticationv1alpha1.JWTAuthenticatorPhaseReady) + + return jwtAuthenticatorTypedLocalObjectReference(authenticator) + }, + token: func(t *testing.T) (string, string, []string, []string) { + pinnipedExe := testlib.PinnipedCLIPath(t) + credOutput, _ := runPinnipedLoginOIDC(ctx, t, pinnipedExe) + token := credOutput.Status.Token + + // Sanity check that the JWT contains the expected username claim. + username := getJWTClaimAsString(t, token, env.CLIUpstreamOIDC.UsernameClaim) + require.Equal(t, env.CLIUpstreamOIDC.Username, username) + + // Sanity check that the JWT contains the expected groups claim. + // Dex doesn't return groups, so only check where we are expecting groups. + if len(env.CLIUpstreamOIDC.ExpectedGroups) > 0 { + groups := getJWTClaimAsStringSlice(t, token, env.CLIUpstreamOIDC.GroupsClaim) + t.Logf("found groups in JWT token: %#v", groups) + require.ElementsMatch(t, groups, env.CLIUpstreamOIDC.ExpectedGroups) + } + + return token, env.CLIUpstreamOIDC.Username, env.CLIUpstreamOIDC.ExpectedGroups, expectedExtras(t, token) + }, + }, + { + name: "jwt authenticator with username and groups CEL expressions and additional extras and validation rules which allow auth", + authenticator: func(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { + authenticator := testlib.CreateTestJWTAuthenticator(ctx, t, authenticationv1alpha1.JWTAuthenticatorSpec{ + Issuer: env.CLIUpstreamOIDC.Issuer, + Audience: env.CLIUpstreamOIDC.ClientID, + Claims: authenticationv1alpha1.JWTTokenClaims{ + UsernameExpression: "claims.sub", + GroupsExpression: `["group1", "group2"]`, + Extra: []authenticationv1alpha1.ExtraMapping{ + { + Key: "example.com/sub", + ValueExpression: "claims.sub", + }, + { + Key: "example.com/const", + ValueExpression: `"some-value"`, + }, + }, + }, + ClaimValidationRules: []authenticationv1alpha1.ClaimValidationRule{ + { + Claim: env.CLIUpstreamOIDC.UsernameClaim, + RequiredValue: env.CLIUpstreamOIDC.Username, + }, + { + Expression: fmt.Sprintf("claims.%s == '%s'", env.CLIUpstreamOIDC.UsernameClaim, env.CLIUpstreamOIDC.Username), + Message: "only one specific user is allowed", + }, + }, + UserValidationRules: []authenticationv1alpha1.UserValidationRule{ + { + Expression: "!user.username.startsWith('system:')", + Message: "username cannot used reserved system: prefix", + }, + }, + TLS: tlsSpecForCLIUpstreamOIDC(t), + }, authenticationv1alpha1.JWTAuthenticatorPhaseReady) + + return jwtAuthenticatorTypedLocalObjectReference(authenticator) + }, + token: func(t *testing.T) (string, string, []string, []string) { + pinnipedExe := testlib.PinnipedCLIPath(t) + credOutput, _ := runPinnipedLoginOIDC(ctx, t, pinnipedExe) + token := credOutput.Status.Token + + subject := getJWTClaimAsString(t, token, "sub") + require.NotEmpty(t, subject) + + wantExtras := expectedExtras(t, token) + wantExtras = append(wantExtras, fmt.Sprintf("example.com/sub=%s", subject)) + wantExtras = append(wantExtras, "example.com/const=some-value") + + return token, subject, []string{"group1", "group2"}, wantExtras + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + authenticatorRefToSubmit := test.authenticator(ctx, t) + tokenToSubmit, wantUsername, wantGroups, wantExtras := test.token(t) + + var response *loginv1alpha1.TokenCredentialRequest + testlib.RequireEventually(t, func(requireEventually *require.Assertions) { + var err error + response, err = testlib.CreateTokenCredentialRequest(ctx, t, + loginv1alpha1.TokenCredentialRequestSpec{Token: tokenToSubmit, Authenticator: authenticatorRefToSubmit}, + ) + requireEventually.NoError(err, "the request should never fail at the HTTP level") + requireEventually.NotNil(response) + requireEventually.NotNil(response.Status.Credential, "the response should contain a credential") + requireEventually.Emptyf(response.Status.Message, "value is: %q", safeDerefStringPtr(response.Status.Message)) + requireEventually.NotNil(response.Status.Credential) + requireEventually.Empty(response.Spec) + requireEventually.Empty(response.Status.Credential.Token) + requireEventually.NotEmpty(response.Status.Credential.ClientCertificateData) + requireEventually.Equal(wantUsername, getCommonName(t, response.Status.Credential.ClientCertificateData)) + requireEventually.ElementsMatch(wantGroups, getOrganizations(t, response.Status.Credential.ClientCertificateData)) + requireEventually.ElementsMatch(wantExtras, getOrganizationalUnits(t, response.Status.Credential.ClientCertificateData)) + requireEventually.NotEmpty(response.Status.Credential.ClientKeyData) + requireEventually.NotNil(response.Status.Credential.ExpirationTimestamp) + requireEventually.InDelta(5*time.Minute, time.Until(response.Status.Credential.ExpirationTimestamp.Time), float64(time.Minute)) + }, 10*time.Second, 500*time.Millisecond) + + // Create a client using the certificate from the CredentialRequest. + clientWithCertFromCredentialRequest := testlib.NewClientsetWithCertAndKey( + t, + response.Status.Credential.ClientCertificateData, + response.Status.Credential.ClientKeyData, + ) + + t.Run( + "access as user", + testlib.AccessAsUserTest(ctx, wantUsername, clientWithCertFromCredentialRequest), + ) + for _, group := range wantGroups { + t.Run( + "access as group "+group, + testlib.AccessAsGroupTest(ctx, group, clientWithCertFromCredentialRequest), + ) + } + }) + } +} + +// This test cannot run in parallel because runPinnipedLoginOIDC uses a fixed port +// for its localhost listener via --listen-port=env.CLIUpstreamOIDC.CallbackURL.Port() per oidcLoginCommand. +// Since ports are global to the process, tests using oidcLoginCommand must be run serially. +func TestCredentialRequest_JWTAuthenticatorRulesToDisallowLogin_Browser(t *testing.T) { + env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) + + basicSpec := &authenticationv1alpha1.JWTAuthenticatorSpec{ + Issuer: env.CLIUpstreamOIDC.Issuer, + Audience: env.CLIUpstreamOIDC.ClientID, + Claims: authenticationv1alpha1.JWTTokenClaims{ + Username: env.CLIUpstreamOIDC.UsernameClaim, + Groups: env.CLIUpstreamOIDC.GroupsClaim, + }, + TLS: tlsSpecForCLIUpstreamOIDC(t), + } + + tests := []struct { + name string + authenticator func(context.Context, *testing.T) *authenticationv1alpha1.JWTAuthenticator + wantSuccessfulAuth bool + }{ + { + // Sanity check to make sure that the basic JWTAuthenticator spec works before adding rules which should cause auth failure. + name: "JWTAuthenticator successful login", + authenticator: func(ctx context.Context, t *testing.T) *authenticationv1alpha1.JWTAuthenticator { + return testlib.CreateTestJWTAuthenticator(ctx, t, *basicSpec.DeepCopy(), authenticationv1alpha1.JWTAuthenticatorPhaseReady) + }, + wantSuccessfulAuth: true, + }, + { + name: "JWTAuthenticator ClaimValidationRules using CEL expression should be able to prevent login", + authenticator: func(ctx context.Context, t *testing.T) *authenticationv1alpha1.JWTAuthenticator { + spec := basicSpec.DeepCopy() + spec.ClaimValidationRules = []authenticationv1alpha1.ClaimValidationRule{ + { + // This should cause the login to fail for this specific user. + Expression: fmt.Sprintf("claims.%s != '%s'", env.CLIUpstreamOIDC.UsernameClaim, env.CLIUpstreamOIDC.Username), + Message: "one specific user is disallowed", + }, + } + return testlib.CreateTestJWTAuthenticator(ctx, t, *spec, authenticationv1alpha1.JWTAuthenticatorPhaseReady) + }, + }, + { + name: "JWTAuthenticator ClaimValidationRules using RequiredValue should be able to prevent login", + authenticator: func(ctx context.Context, t *testing.T) *authenticationv1alpha1.JWTAuthenticator { + spec := basicSpec.DeepCopy() + spec.ClaimValidationRules = []authenticationv1alpha1.ClaimValidationRule{ + { + Claim: "sub", + RequiredValue: "this-will-never-be-the-sub-value", + }, + } + return testlib.CreateTestJWTAuthenticator(ctx, t, *spec, authenticationv1alpha1.JWTAuthenticatorPhaseReady) + }, + }, + { + name: "JWTAuthenticator UserValidationRules CEL expressions should be able to prevent login", + authenticator: func(ctx context.Context, t *testing.T) *authenticationv1alpha1.JWTAuthenticator { + spec := basicSpec.DeepCopy() + spec.UserValidationRules = []authenticationv1alpha1.UserValidationRule{ + { + Expression: "false", + Message: "nobody is allowed to auth", + }, + } + return testlib.CreateTestJWTAuthenticator(ctx, t, *spec, authenticationv1alpha1.JWTAuthenticatorPhaseReady) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + t.Cleanup(cancel) + + authenticator := test.authenticator(ctx, t) + + pinnipedExe := testlib.PinnipedCLIPath(t) + credOutput, _ := runPinnipedLoginOIDC(ctx, t, pinnipedExe) + + response, err := testlib.CreateTokenCredentialRequest(ctx, t, + loginv1alpha1.TokenCredentialRequestSpec{ + Token: credOutput.Status.Token, + Authenticator: corev1.TypedLocalObjectReference{ + APIGroup: &authenticationv1alpha1.SchemeGroupVersion.Group, + Kind: "JWTAuthenticator", + Name: authenticator.Name, + }, + }, + ) + require.NoError(t, err, testlib.Sdump(err)) + + if test.wantSuccessfulAuth { + require.NotEmpty(t, response.Status.Credential) + require.Empty(t, response.Status.Message) + } else { + require.Nil(t, response.Status.Credential) + require.NotNil(t, response.Status.Message) + require.Equal(t, "authentication failed", *response.Status.Message) + } + }) + } +} + // TCRs are non-mutating and safe to run in parallel with serial tests, see main_test.go. -func TestUnsuccessfulCredentialRequest_Parallel(t *testing.T) { +func TestCredentialRequest_ShouldFailWhenTheAuthenticatorDoesNotExist_Parallel(t *testing.T) { env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() + t.Cleanup(cancel) response, err := testlib.CreateTokenCredentialRequest(ctx, t, loginv1alpha1.TokenCredentialRequestSpec{ @@ -46,110 +342,15 @@ func TestUnsuccessfulCredentialRequest_Parallel(t *testing.T) { require.Equal(t, "authentication failed", *response.Status.Message) } -// TestSuccessfulCredentialRequest_Browser cannot run in parallel because runPinnipedLoginOIDC uses a fixed port -// for its localhost listener via --listen-port=env.CLIUpstreamOIDC.CallbackURL.Port() per oidcLoginCommand. -// Since ports are global to the process, tests using oidcLoginCommand must be run serially. -func TestSuccessfulCredentialRequest_Browser(t *testing.T) { - env := testlib.IntegrationEnv(t).WithCapability(testlib.ClusterSigningKeyIsAvailable) - - ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) - defer cancel() - - tests := []struct { - name string - authenticator func(context.Context, *testing.T) corev1.TypedLocalObjectReference - token func(t *testing.T) (token string, username string, groups []string) - }{ - { - name: "webhook", - authenticator: func(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { - return testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, authenticationv1alpha1.WebhookAuthenticatorPhaseReady) - }, - token: func(t *testing.T) (string, string, []string) { - return testlib.IntegrationEnv(t).TestUser.Token, env.TestUser.ExpectedUsername, env.TestUser.ExpectedGroups - }, - }, - { - name: "jwt authenticator", - authenticator: func(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference { - authenticator := testlib.CreateTestJWTAuthenticatorForCLIUpstream(ctx, t) - return corev1.TypedLocalObjectReference{ - APIGroup: &authenticationv1alpha1.SchemeGroupVersion.Group, - Kind: "JWTAuthenticator", - Name: authenticator.Name, - } - }, - token: func(t *testing.T) (string, string, []string) { - pinnipedExe := testlib.PinnipedCLIPath(t) - credOutput, _ := runPinnipedLoginOIDC(ctx, t, pinnipedExe) - token := credOutput.Status.Token - - // By default, the JWTAuthenticator expects the username to be in the "username" claim and the - // groups to be in the "groups" claim. - // However, we are configuring Pinniped in the `CreateTestJWTAuthenticatorForCLIUpstream` method above - // to read the username from the "sub" claim of the token instead. - username, groups := getJWTSubAndGroupsClaims(t, token) - - return token, username, groups - }, - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - authenticator := test.authenticator(ctx, t) - token, username, groups := test.token(t) - - var response *loginv1alpha1.TokenCredentialRequest - testlib.RequireEventually(t, func(requireEventually *require.Assertions) { - var err error - response, err = testlib.CreateTokenCredentialRequest(ctx, t, - loginv1alpha1.TokenCredentialRequestSpec{Token: token, Authenticator: authenticator}, - ) - requireEventually.NoError(err, "the request should never fail at the HTTP level") - requireEventually.NotNil(response) - requireEventually.NotNil(response.Status.Credential, "the response should contain a credential") - requireEventually.Emptyf(response.Status.Message, "value is: %q", safeDerefStringPtr(response.Status.Message)) - requireEventually.NotNil(response.Status.Credential) - requireEventually.Empty(response.Spec) - requireEventually.Empty(response.Status.Credential.Token) - requireEventually.NotEmpty(response.Status.Credential.ClientCertificateData) - requireEventually.Equal(username, getCommonName(t, response.Status.Credential.ClientCertificateData)) - requireEventually.ElementsMatch(groups, getOrganizations(t, response.Status.Credential.ClientCertificateData)) - requireEventually.NotEmpty(response.Status.Credential.ClientKeyData) - requireEventually.NotNil(response.Status.Credential.ExpirationTimestamp) - requireEventually.InDelta(5*time.Minute, time.Until(response.Status.Credential.ExpirationTimestamp.Time), float64(time.Minute)) - }, 10*time.Second, 500*time.Millisecond) - - // Create a client using the certificate from the CredentialRequest. - clientWithCertFromCredentialRequest := testlib.NewClientsetWithCertAndKey( - t, - response.Status.Credential.ClientCertificateData, - response.Status.Credential.ClientKeyData, - ) - - t.Run( - "access as user", - testlib.AccessAsUserTest(ctx, username, clientWithCertFromCredentialRequest), - ) - for _, group := range groups { - t.Run( - "access as group "+group, - testlib.AccessAsGroupTest(ctx, group, clientWithCertFromCredentialRequest), - ) - } - }) - } -} - // TCRs are non-mutating and safe to run in parallel with serial tests, see main_test.go. -func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser_Parallel(t *testing.T) { - _ = testlib.IntegrationEnv(t).WithCapability(testlib.ClusterSigningKeyIsAvailable) +func TestCredentialRequest_ShouldFailWhenTheRequestIsValidButTheTokenDoesNotAuthenticateTheUser_Parallel(t *testing.T) { + env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) - // Create a testWebhook so we have a legitimate authenticator to pass to the - // TokenCredentialRequest API. + // Create a testWebhook so we have a legitimate authenticator to pass to the TokenCredentialRequest API. ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, authenticationv1alpha1.WebhookAuthenticatorPhaseReady) + t.Cleanup(cancel) + + testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t, &env.TestWebhook, authenticationv1alpha1.WebhookAuthenticatorPhaseReady) response, err := testlib.CreateTokenCredentialRequest(context.Background(), t, loginv1alpha1.TokenCredentialRequestSpec{Token: "not a good token", Authenticator: testWebhook}, @@ -164,13 +365,13 @@ func TestFailedCredentialRequestWhenTheRequestIsValidButTheTokenDoesNotAuthentic // TCRs are non-mutating and safe to run in parallel with serial tests, see main_test.go. func TestCredentialRequest_ShouldFailWhenRequestDoesNotIncludeToken_Parallel(t *testing.T) { - _ = testlib.IntegrationEnv(t).WithCapability(testlib.ClusterSigningKeyIsAvailable) + env := testlib.IntegrationEnv(t).WithCapability(testlib.AnonymousAuthenticationSupported) - // Create a testWebhook so we have a legitimate authenticator to pass to the - // TokenCredentialRequest API. + // Create a testWebhook so we have a legitimate authenticator to pass to the TokenCredentialRequest API. ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t, &testlib.IntegrationEnv(t).TestWebhook, authenticationv1alpha1.WebhookAuthenticatorPhaseReady) + t.Cleanup(cancel) + + testWebhook := testlib.CreateTestWebhookAuthenticator(ctx, t, &env.TestWebhook, authenticationv1alpha1.WebhookAuthenticatorPhaseReady) response, err := testlib.CreateTokenCredentialRequest(context.Background(), t, loginv1alpha1.TokenCredentialRequestSpec{Token: "", Authenticator: testWebhook}, @@ -210,6 +411,16 @@ func getOrganizations(t *testing.T, certPEM string) []string { return cert.Subject.Organization } +func getOrganizationalUnits(t *testing.T, certPEM string) []string { + t.Helper() + + pemBlock, _ := pem.Decode([]byte(certPEM)) + cert, err := x509.ParseCertificate(pemBlock.Bytes) + require.NoError(t, err) + + return cert.Subject.OrganizationalUnit +} + func safeDerefStringPtr(s *string) string { if s == nil { return "" @@ -217,18 +428,51 @@ func safeDerefStringPtr(s *string) string { return *s } -func getJWTSubAndGroupsClaims(t *testing.T, jwtToken string) (string, []string) { +func getJWTClaimAsString(t *testing.T, jwtToken string, claimName string) string { + t.Helper() + claims := getJWTClaims(t, jwtToken) + require.Contains(t, claims, claimName) + val := claims[claimName] + strVal, ok := val.(string) + require.Truef(t, ok, "expected value of claim %q to be a string, but it was: %#v", claimName, claims[claimName]) + return strVal +} + +func getJWTClaimAsStringSlice(t *testing.T, jwtToken string, claimName string) []string { + t.Helper() + claims := getJWTClaims(t, jwtToken) + require.Contains(t, claims, claimName) + val := claims[claimName] + anySliceVal, ok := val.([]any) + require.Truef(t, ok, "expected value of claim %q to be a []any, but it was: %#v", claimName, claims[claimName]) + strSliceVal := make([]string, len(anySliceVal)) + for i := range anySliceVal { + strSliceVal[i], ok = anySliceVal[i].(string) + require.Truef(t, ok, "expected every value of array at claim %q to be a string, but one element was: %#v", claimName, anySliceVal[i]) + } + return strSliceVal +} + +func getJWTClaims(t *testing.T, jwtToken string) map[string]any { t.Helper() token, err := josejwt.ParseSigned(jwtToken, []jose.SignatureAlgorithm{jose.ES256, jose.RS256}) require.NoError(t, err) - var claims struct { - Sub string `json:"sub"` - Groups []string `json:"groups"` - } + claims := map[string]any{} err = token.UnsafeClaimsWithoutVerification(&claims) require.NoError(t, err) - return claims.Sub, claims.Groups + return claims +} + +func tlsSpecForCLIUpstreamOIDC(t *testing.T) *authenticationv1alpha1.TLSSpec { + env := testlib.IntegrationEnv(t) + // If the test upstream does not have a CA bundle specified, then don't configure it. + if env.CLIUpstreamOIDC.CABundle != "" { + return &authenticationv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.CLIUpstreamOIDC.CABundle)), + } + } + return nil } diff --git a/test/testlib/client.go b/test/testlib/client.go index 4f6db331c..6210c6be8 100644 --- a/test/testlib/client.go +++ b/test/testlib/client.go @@ -1,4 +1,4 @@ -// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2025 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package testlib @@ -6,7 +6,6 @@ package testlib import ( "context" "crypto/rand" - "encoding/base64" "encoding/hex" "fmt" "io" @@ -253,32 +252,6 @@ func WaitForWebhookAuthenticatorStatusConditions(ctx context.Context, t *testing }, 60*time.Second, 1*time.Second, "wanted WebhookAuthenticator conditions") } -// CreateTestJWTAuthenticatorForCLIUpstream creates and returns a test JWTAuthenticator which will be automatically -// deleted at the end of the current test's lifetime. -// -// CreateTestJWTAuthenticatorForCLIUpstream gets the OIDC issuer info from IntegrationEnv().CLIUpstreamOIDC. -func CreateTestJWTAuthenticatorForCLIUpstream(ctx context.Context, t *testing.T) *authenticationv1alpha1.JWTAuthenticator { - t.Helper() - testEnv := IntegrationEnv(t) - spec := authenticationv1alpha1.JWTAuthenticatorSpec{ - Issuer: testEnv.CLIUpstreamOIDC.Issuer, - Audience: testEnv.CLIUpstreamOIDC.ClientID, - // The default UsernameClaim is "username" but the upstreams that we use for - // integration tests won't necessarily have that claim, so use "sub" here. - Claims: authenticationv1alpha1.JWTTokenClaims{Username: "sub"}, - } - // If the test upstream does not have a CA bundle specified, then don't configure one in the - // JWTAuthenticator. Leaving TLSSpec set to nil will result in OIDC discovery using the OS's root - // CA store. - if testEnv.CLIUpstreamOIDC.CABundle != "" { - spec.TLS = &authenticationv1alpha1.TLSSpec{ - CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(testEnv.CLIUpstreamOIDC.CABundle)), - } - } - authenticator := CreateTestJWTAuthenticator(ctx, t, spec, authenticationv1alpha1.JWTAuthenticatorPhaseReady) - return authenticator -} - // CreateTestJWTAuthenticator creates and returns a test JWTAuthenticator which will be automatically deleted // at the end of the current test's lifetime. func CreateTestJWTAuthenticator( diff --git a/test/testlib/env.go b/test/testlib/env.go index 01f10237e..662221201 100644 --- a/test/testlib/env.go +++ b/test/testlib/env.go @@ -304,12 +304,15 @@ func loadEnvVars(t *testing.T, result *TestEnv) { result.ShellContainerImage = needEnv(t, "PINNIPED_TEST_SHELL_CONTAINER_IMAGE") result.CLIUpstreamOIDC = TestOIDCUpstream{ - Issuer: needEnv(t, "PINNIPED_TEST_CLI_OIDC_ISSUER"), - CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_CLI_OIDC_ISSUER_CA_BUNDLE")), - ClientID: needEnv(t, "PINNIPED_TEST_CLI_OIDC_CLIENT_ID"), - CallbackURL: needEnv(t, "PINNIPED_TEST_CLI_OIDC_CALLBACK_URL"), - Username: needEnv(t, "PINNIPED_TEST_CLI_OIDC_USERNAME"), - Password: needEnv(t, "PINNIPED_TEST_CLI_OIDC_PASSWORD"), + Issuer: needEnv(t, "PINNIPED_TEST_CLI_OIDC_ISSUER"), + CABundle: base64Decoded(t, os.Getenv("PINNIPED_TEST_CLI_OIDC_ISSUER_CA_BUNDLE")), + UsernameClaim: os.Getenv("PINNIPED_TEST_CLI_OIDC_USERNAME_CLAIM"), + GroupsClaim: os.Getenv("PINNIPED_TEST_CLI_OIDC_GROUPS_CLAIM"), + ClientID: needEnv(t, "PINNIPED_TEST_CLI_OIDC_CLIENT_ID"), + CallbackURL: needEnv(t, "PINNIPED_TEST_CLI_OIDC_CALLBACK_URL"), + Username: needEnv(t, "PINNIPED_TEST_CLI_OIDC_USERNAME"), + Password: needEnv(t, "PINNIPED_TEST_CLI_OIDC_PASSWORD"), + ExpectedGroups: filterEmpty(strings.Split(strings.ReplaceAll(os.Getenv("PINNIPED_TEST_CLI_OIDC_EXPECTED_GROUPS"), " ", ""), ",")), } result.SupervisorUpstreamOIDC = TestOIDCUpstream{