mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2025-12-23 06:15:47 +00:00
add new login integration tests for new JWTAuthenticator features
This commit is contained in:
@@ -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 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(
|
||||
|
||||
@@ -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