mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-09-06 16:17:08 +00:00
Merge pull request #2491 from vmware/jwtauthenticator_new_features
add new features in JWTAuthenticator CRD
This commit is contained in:
@@ -209,6 +209,7 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) {
|
||||
"personalInfo": map[string]any{
|
||||
"username": "redacted",
|
||||
"groups": []any{"redacted 2 values"},
|
||||
"extras": map[string]any{"redacted": "redacted 1 keys"},
|
||||
},
|
||||
},
|
||||
}, allConciergeTCRLogs)
|
||||
@@ -342,6 +343,25 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) {
|
||||
for _, log := range allConciergeTCRLogs {
|
||||
require.NotEmpty(t, log["issuedClientCert"])
|
||||
delete(log, "issuedClientCert")
|
||||
|
||||
// The value at the extras key "authentication.kubernetes.io/credential-id" will be a JWT ID,
|
||||
// which is hard to predict, so just assert that it is there without worrying about its exact value.
|
||||
require.Contains(t, log, "personalInfo")
|
||||
personalInfo, ok := log["personalInfo"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, personalInfo["extras"])
|
||||
extras, ok := personalInfo["extras"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, extras, "authentication.kubernetes.io/credential-id")
|
||||
require.Len(t, extras, 1) // should be the only key
|
||||
id := extras["authentication.kubernetes.io/credential-id"]
|
||||
idValues, ok := id.([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, idValues, 1)
|
||||
require.Regexp(t, "JTI=.+", idValues[0])
|
||||
// Now that we have made assertions about all the expected extras,
|
||||
// delete it so we can compare the rest using equals below.
|
||||
delete(personalInfo, "extras")
|
||||
}
|
||||
|
||||
// All values in the personalInfo map should not be redacted anymore.
|
||||
@@ -357,6 +377,7 @@ func TestAuditLogsDuringLogin_Disruptive(t *testing.T) {
|
||||
"personalInfo": map[string]any{
|
||||
"username": expectedUsername,
|
||||
"groups": expectedGroups,
|
||||
// note: also has an "extras" key, which we deleted from the actual value above
|
||||
},
|
||||
},
|
||||
}, allConciergeTCRLogs)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright 2024 the Pinniped contributors. All Rights Reserved.
|
||||
// Copyright 2024-2025 the Pinniped contributors. All Rights Reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package integration
|
||||
@@ -322,6 +322,308 @@ func TestConciergeJWTAuthenticatorStatus_Parallel(t *testing.T) {
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "claims cannot use both username and usernameExpression",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
Claims: authenticationv1alpha1.JWTTokenClaims{
|
||||
Username: "foo",
|
||||
UsernameExpression: "bar",
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: `could not initialize jwt authenticator: claims.username: Invalid value: "": claim and expression can't both be set`,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "claims cannot use both groups and groupsExpression",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
Claims: authenticationv1alpha1.JWTTokenClaims{
|
||||
Groups: "foo",
|
||||
GroupsExpression: "bar",
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: `could not initialize jwt authenticator: claims.groups: Invalid value: "": claim and expression can't both be set`,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "username claim expression cannot use clams.email unless it also uses claims.email_verified elsewhere",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
Claims: authenticationv1alpha1.JWTTokenClaims{
|
||||
UsernameExpression: "claims.email",
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: `could not initialize jwt authenticator: claims.usernameExpression: Invalid value: "claims.email": ` +
|
||||
`claims.email_verified must be used in claims.usernameExpression or claims.extra[*].valueExpression or ` +
|
||||
`claimValidationRules[*].expression when claims.email is used in claims.usernameExpression`,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "username claim expression cannot use invalid CEL expression",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
Claims: authenticationv1alpha1.JWTTokenClaims{
|
||||
UsernameExpression: "this is not a valid CEL expression",
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: "could not initialize jwt authenticator: claims.usernameExpression: Invalid value: " +
|
||||
"\"this is not a valid CEL expression\": compilation failed: ERROR: <input>:1:6: Syntax error: mismatched input 'is' expecting <EOF>\n" +
|
||||
" | this is not a valid CEL expression\n" +
|
||||
" | .....^",
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "groups claim expression cannot use invalid CEL expression",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
Claims: authenticationv1alpha1.JWTTokenClaims{
|
||||
GroupsExpression: "this is not a valid CEL expression",
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: "could not initialize jwt authenticator: claims.groupsExpression: Invalid value: " +
|
||||
"\"this is not a valid CEL expression\": compilation failed: ERROR: <input>:1:6: Syntax error: mismatched input 'is' expecting <EOF>\n" +
|
||||
" | this is not a valid CEL expression\n" +
|
||||
" | .....^",
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "extra keys cannot have equal sign and must be domain-prefixed path",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
Claims: authenticationv1alpha1.JWTTokenClaims{
|
||||
Extra: []authenticationv1alpha1.ExtraMapping{
|
||||
{
|
||||
Key: "a=b",
|
||||
ValueExpression: `"value"`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: `could not initialize jwt authenticator: [` +
|
||||
`claims.extra[0].key: Invalid value: "a=b": must be a domain-prefixed path (such as "acme.io/foo"), ` +
|
||||
`claims.extra[0].key: Invalid value: "a=b": Pinniped does not allow extra key names to contain equals sign]`,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "claimValidationRules claim and requiredValue are mutually exclusive with expression and message",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
ClaimValidationRules: []authenticationv1alpha1.ClaimValidationRule{
|
||||
{
|
||||
Claim: "foo",
|
||||
RequiredValue: "bar",
|
||||
Expression: "baz",
|
||||
Message: "bat",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: `could not initialize jwt authenticator: claimValidationRules[0]: Invalid value: "foo": claim and expression can't both be set`,
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "claimValidationRules cannot use invalid CEL expressions",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
ClaimValidationRules: []authenticationv1alpha1.ClaimValidationRule{
|
||||
{
|
||||
Expression: "this is not a valid CEL expression",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: "could not initialize jwt authenticator: claimValidationRules[0].expression: Invalid value: " +
|
||||
"\"this is not a valid CEL expression\": compilation failed: ERROR: <input>:1:6: Syntax error: mismatched input 'is' expecting <EOF>\n" +
|
||||
" | this is not a valid CEL expression\n" +
|
||||
" | .....^",
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "userValidationRules must use valid CEL expressions",
|
||||
spec: authenticationv1alpha1.JWTAuthenticatorSpec{
|
||||
Issuer: env.SupervisorUpstreamOIDC.Issuer,
|
||||
Audience: "some-fake-audience",
|
||||
TLS: &authenticationv1alpha1.TLSSpec{
|
||||
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(env.SupervisorUpstreamOIDC.CABundle)),
|
||||
},
|
||||
UserValidationRules: []authenticationv1alpha1.UserValidationRule{
|
||||
{
|
||||
Expression: "this is not a valid CEL expression",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantPhase: authenticationv1alpha1.JWTAuthenticatorPhaseError,
|
||||
wantConditions: replaceSomeConditions(t,
|
||||
allSuccessfulJWTAuthenticatorConditions(len(env.SupervisorUpstreamOIDC.CABundle) != 0),
|
||||
[]metav1.Condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: "False",
|
||||
Reason: "NotReady",
|
||||
Message: "the JWTAuthenticator is not ready: see other conditions for details",
|
||||
}, {
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "False",
|
||||
Reason: "InvalidAuthenticator",
|
||||
Message: "could not initialize jwt authenticator: userValidationRules[0].expression: Invalid value: " +
|
||||
"\"this is not a valid CEL expression\": compilation failed: ERROR: <input>:1:6: Syntax error: mismatched input 'is' expecting <EOF>\n" +
|
||||
" | this is not a valid CEL expression\n" +
|
||||
" | .....^",
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -479,40 +781,42 @@ func allSuccessfulJWTAuthenticatorConditions(caBundleExists bool) []metav1.Condi
|
||||
if caBundleExists {
|
||||
tlsConfigValidMsg = "spec.tls is valid: using configured CA bundle"
|
||||
}
|
||||
return []metav1.Condition{{
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "authenticator initialized",
|
||||
}, {
|
||||
Type: "DiscoveryURLValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "discovery performed successfully",
|
||||
}, {
|
||||
Type: "IssuerURLValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "issuer is a valid URL",
|
||||
}, {
|
||||
Type: "JWKSFetchValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "successfully fetched jwks",
|
||||
}, {
|
||||
Type: "JWKSURLValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "jwks_uri is a valid URL",
|
||||
}, {
|
||||
Type: "Ready",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "the JWTAuthenticator is ready",
|
||||
}, {
|
||||
Type: "TLSConfigurationValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: tlsConfigValidMsg,
|
||||
}}
|
||||
return []metav1.Condition{
|
||||
{
|
||||
Type: "AuthenticatorValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "authenticator initialized",
|
||||
}, {
|
||||
Type: "DiscoveryURLValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "discovery performed successfully",
|
||||
}, {
|
||||
Type: "IssuerURLValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "issuer is a valid URL",
|
||||
}, {
|
||||
Type: "JWKSFetchValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "successfully fetched jwks",
|
||||
}, {
|
||||
Type: "JWKSURLValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "jwks_uri is a valid URL",
|
||||
}, {
|
||||
Type: "Ready",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: "the JWTAuthenticator is ready",
|
||||
}, {
|
||||
Type: "TLSConfigurationValid",
|
||||
Status: "True",
|
||||
Reason: "Success",
|
||||
Message: tlsConfigValidMsg,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,8 +451,9 @@ func TestGetAPIResourceList(t *testing.T) { //nolint:gocyclo // each t.Run is pr
|
||||
}
|
||||
}
|
||||
|
||||
// manually update this value whenever you add additional fields to an API resource and then run the generator
|
||||
totalExpectedAPIFields := 310
|
||||
// Manually update this value whenever you add additional fields to an API resource and then run the generator.
|
||||
// This is to ensure that this test checked every field in our whole API surface area.
|
||||
totalExpectedAPIFields := 323
|
||||
|
||||
// Because we are parsing text from `kubectl explain` and because the format of that text can change
|
||||
// over time, make a rudimentary assertion that this test exercised the whole tree of all fields of all
|
||||
|
||||
@@ -555,6 +555,7 @@ func TestSupervisorFederationDomainStatus_Disruptive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocyclo // we have a lot of "if" conditions here, but it's okay
|
||||
func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
env := testlib.IntegrationEnv(t)
|
||||
fdClient := testlib.NewSupervisorClientset(t).ConfigV1alpha1().FederationDomains(env.SupervisorNamespace)
|
||||
@@ -566,7 +567,10 @@ func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
|
||||
// Certain non-CEL validation failures will prevent CEL validations from running,
|
||||
// and the Kubernetes API server will return this error message for those cases.
|
||||
const couldNotRunCELValidationsErrMessage = `<nil>: Invalid value: "null": some validation rules were not checked because the object was invalid; correct the existing errors to complete validation`
|
||||
const couldNotRunCELValidationsErrMessage = "some validation rules were not checked because the object was invalid; correct the existing errors to complete validation"
|
||||
const oldCouldNotRunCELValidationsErrMessage = `<nil>: Invalid value: "null": ` + couldNotRunCELValidationsErrMessage
|
||||
// Starting in beta version of Kube 1.34, they removed the quotes around the null in this message.
|
||||
const newCouldNotRunCELValidationsErrMessage = `<nil>: Invalid value: null: ` + couldNotRunCELValidationsErrMessage
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -576,11 +580,14 @@ func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
// optionally override wantErr for one or more specific versions of Kube, due to changing validation error text
|
||||
wantKube23OrOlderErrs []string
|
||||
wantKube24Through31InclusiveErrs []string
|
||||
wantKube32OrNewerErrs []string
|
||||
wantKube32Through33InclusiveErrs []string
|
||||
wantKube34OrNewerErrs []string
|
||||
|
||||
// These errors are appended to any other wanted errors when k8sAPIServerSupportsCEL is true
|
||||
wantCELErrorsForKube25Through28Inclusive []string
|
||||
wantCELErrorsForKube29Through33Inclusive []string
|
||||
wantCELErrorsForKube29OrNewer []string
|
||||
wantCELErrorsForKube34OrNewer []string
|
||||
}{
|
||||
{
|
||||
name: "issuer cannot be empty",
|
||||
@@ -644,6 +651,7 @@ func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
// For some unknown reason, Kubernetes versions 1.23 and older return errors *with indices* for this test case only.
|
||||
wantKube23OrOlderErrs: []string{`spec.identityProviders[0].transforms.constants[1]: Duplicate value: map[string]interface {}{"name":"notUnique"}`},
|
||||
wantErrs: []string{`spec.identityProviders[0].transforms.constants[1]: Duplicate value: map[string]interface {}{"name":"notUnique"}`},
|
||||
wantKube34OrNewerErrs: []string{`spec.identityProviders[0].transforms.constants[1]: Duplicate value: {"name":"notUnique"}`},
|
||||
},
|
||||
{
|
||||
name: "IDP transform constant names cannot be empty",
|
||||
@@ -685,9 +693,10 @@ func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
},
|
||||
wantKube23OrOlderErrs: []string{`spec.identityProviders.transforms.constants.name: Invalid value: "12345678901234567890123456789012345678901234567890123456789012345": spec.identityProviders.transforms.constants.name in body should be at most 64 chars long`},
|
||||
wantKube24Through31InclusiveErrs: []string{`spec.identityProviders[0].transforms.constants[0].name: Too long: may not be longer than 64`},
|
||||
wantKube32OrNewerErrs: []string{`spec.identityProviders[0].transforms.constants[0].name: Too long: may not be more than 64 bytes`},
|
||||
wantCELErrorsForKube25Through28Inclusive: []string{couldNotRunCELValidationsErrMessage},
|
||||
wantCELErrorsForKube29OrNewer: []string{couldNotRunCELValidationsErrMessage},
|
||||
wantErrs: []string{`spec.identityProviders[0].transforms.constants[0].name: Too long: may not be more than 64 bytes`},
|
||||
wantCELErrorsForKube25Through28Inclusive: []string{oldCouldNotRunCELValidationsErrMessage},
|
||||
wantCELErrorsForKube29Through33Inclusive: []string{oldCouldNotRunCELValidationsErrMessage},
|
||||
wantCELErrorsForKube34OrNewer: []string{newCouldNotRunCELValidationsErrMessage},
|
||||
},
|
||||
{
|
||||
name: "IDP transform constant names must be a legal CEL variable name",
|
||||
@@ -737,8 +746,9 @@ func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErrs: []string{`spec.identityProviders[0].transforms.constants[0].type: Unsupported value: "this is invalid": supported values: "string", "stringList"`},
|
||||
wantCELErrorsForKube29OrNewer: []string{couldNotRunCELValidationsErrMessage}, // this should not be checked on kind 1.25, 1.26, 1.27, 1.28
|
||||
wantErrs: []string{`spec.identityProviders[0].transforms.constants[0].type: Unsupported value: "this is invalid": supported values: "string", "stringList"`},
|
||||
wantCELErrorsForKube29Through33Inclusive: []string{oldCouldNotRunCELValidationsErrMessage},
|
||||
wantCELErrorsForKube34OrNewer: []string{newCouldNotRunCELValidationsErrMessage},
|
||||
},
|
||||
{
|
||||
name: "IDP transform expression types must be one of the allowed enum strings",
|
||||
@@ -761,8 +771,9 @@ func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErrs: []string{`spec.identityProviders[0].transforms.expressions[0].type: Unsupported value: "this is invalid": supported values: "policy/v1", "username/v1", "groups/v1"`},
|
||||
wantCELErrorsForKube29OrNewer: []string{couldNotRunCELValidationsErrMessage}, // this should not be checked on kind 1.25, 1.26, 1.27, 1.28
|
||||
wantErrs: []string{`spec.identityProviders[0].transforms.expressions[0].type: Unsupported value: "this is invalid": supported values: "policy/v1", "username/v1", "groups/v1"`},
|
||||
wantCELErrorsForKube29Through33Inclusive: []string{oldCouldNotRunCELValidationsErrMessage},
|
||||
wantCELErrorsForKube34OrNewer: []string{newCouldNotRunCELValidationsErrMessage},
|
||||
},
|
||||
{
|
||||
name: "IDP transform expressions cannot be empty",
|
||||
@@ -872,7 +883,7 @@ func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
if len(tt.wantErrs) > 0 && len(tt.wantKube23OrOlderErrs) > 0 && len(tt.wantKube24Through31InclusiveErrs) > 0 && len(tt.wantKube32OrNewerErrs) > 0 {
|
||||
if len(tt.wantErrs) > 0 && len(tt.wantKube23OrOlderErrs) > 0 && len(tt.wantKube24Through31InclusiveErrs) > 0 && len(tt.wantKube32Through33InclusiveErrs) > 0 && len(tt.wantKube34OrNewerErrs) > 0 {
|
||||
require.Fail(t, "test setup problem: wanted every possible kind of error, which would cause tt.wantErr to be unused")
|
||||
}
|
||||
|
||||
@@ -900,16 +911,27 @@ func TestSupervisorFederationDomainCRDValidations_Parallel(t *testing.T) {
|
||||
// Also allow overriding with an exact expected error for these Kube versions.
|
||||
wantErr = tt.wantKube24Through31InclusiveErrs
|
||||
}
|
||||
if minor >= 32 && len(tt.wantKube32OrNewerErrs) > 0 {
|
||||
if minor >= 32 && minor <= 33 && len(tt.wantKube32Through33InclusiveErrs) > 0 {
|
||||
// Also allow overriding with an exact expected error for these Kube versions.
|
||||
wantErr = tt.wantKube32OrNewerErrs
|
||||
wantErr = tt.wantKube32Through33InclusiveErrs
|
||||
}
|
||||
if minor >= 34 && len(tt.wantKube34OrNewerErrs) > 0 {
|
||||
// Also allow overriding with an exact expected error for these Kube versions.
|
||||
wantErr = tt.wantKube34OrNewerErrs
|
||||
}
|
||||
|
||||
if minor >= 25 && minor <= 28 && len(tt.wantCELErrorsForKube25Through28Inclusive) > 0 {
|
||||
wantErr = append(wantErr, tt.wantCELErrorsForKube25Through28Inclusive...)
|
||||
} else if minor >= 29 && len(tt.wantCELErrorsForKube29OrNewer) > 0 {
|
||||
}
|
||||
if minor >= 29 && minor <= 33 && len(tt.wantCELErrorsForKube29Through33Inclusive) > 0 {
|
||||
wantErr = append(wantErr, tt.wantCELErrorsForKube29Through33Inclusive...)
|
||||
}
|
||||
if minor >= 29 && len(tt.wantCELErrorsForKube29OrNewer) > 0 {
|
||||
wantErr = append(wantErr, tt.wantCELErrorsForKube29OrNewer...)
|
||||
}
|
||||
if minor >= 34 && len(tt.wantCELErrorsForKube34OrNewer) > 0 {
|
||||
wantErr = append(wantErr, tt.wantCELErrorsForKube34OrNewer...)
|
||||
}
|
||||
|
||||
// Did not want any error.
|
||||
if len(wantErr) == 0 {
|
||||
|
||||
+1
-28
@@ -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(
|
||||
|
||||
+9
-6
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user