add new login integration tests for new JWTAuthenticator features

This commit is contained in:
Ryan Richard
2025-07-18 10:17:25 -07:00
parent 52622d5e4c
commit cc4a148c70
5 changed files with 375 additions and 152 deletions

View File

@@ -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",

View File

@@ -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 "<nil>"
@@ -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
}

View File

@@ -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(

View File

@@ -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{