add usernameExpression and groupsExpression to JWTAuthenticator CRD

This commit is contained in:
Ryan Richard
2025-07-16 14:28:37 -07:00
parent 2a83d00373
commit 64e5e20010
31 changed files with 1700 additions and 128 deletions

View File

@@ -15,45 +15,65 @@ import (
// but is otherwise a straight conversion. The Pinniped type includes TLS configuration which does not need
// to be converted because that is applied elsewhere.
func convertJWTAuthenticatorSpecType(spec *authenticationv1alpha1.JWTAuthenticatorSpec) apiserver.JWTAuthenticator {
usernameClaim := spec.Claims.Username
if usernameClaim == "" {
usernameClaim = defaultUsernameClaim
}
groupsClaim := spec.Claims.Groups
if groupsClaim == "" {
groupsClaim = defaultGroupsClaim
return apiserver.JWTAuthenticator{
Issuer: convertIssuerType(spec),
ClaimMappings: convertClaimMappingsType(spec.Claims),
ClaimValidationRules: convertClaimValidationRulesType(spec.ClaimValidationRules),
UserValidationRules: convertUserValidationRulesType(spec.UserValidationRules),
}
}
func convertIssuerType(spec *authenticationv1alpha1.JWTAuthenticatorSpec) apiserver.Issuer {
var aud []string
if len(spec.Audience) > 0 {
aud = []string{spec.Audience}
}
jwtAuthenticator := apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: spec.Issuer,
Audiences: aud,
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Claim: usernameClaim,
Prefix: ptr.To(""),
},
Groups: apiserver.PrefixedClaimOrExpression{
Claim: groupsClaim,
Prefix: ptr.To(""),
},
Extra: convertExtraType(spec.Claims.Extra),
},
ClaimValidationRules: convertClaimValidationRulesType(spec.ClaimValidationRules),
UserValidationRules: convertUserValidationRules(spec.UserValidationRules),
return apiserver.Issuer{
URL: spec.Issuer,
Audiences: aud,
}
return jwtAuthenticator
}
func convertUserValidationRules(rules []authenticationv1alpha1.UserValidationRule) []apiserver.UserValidationRule {
func convertClaimMappingsType(claims authenticationv1alpha1.JWTTokenClaims) apiserver.ClaimMappings {
usernameClaim := claims.Username
if usernameClaim == "" && claims.UsernameExpression == "" {
usernameClaim = defaultUsernameClaim
}
var usernamePrefix *string
if usernameClaim != "" {
// Must be set only when username claim name is set.
usernamePrefix = ptr.To("")
}
groupsClaim := claims.Groups
if groupsClaim == "" && claims.GroupsExpression == "" {
groupsClaim = defaultGroupsClaim
}
var groupsPrefix *string
if groupsClaim != "" {
// Must be set only when groups claim name is set.
groupsPrefix = ptr.To("")
}
return apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Claim: usernameClaim,
Prefix: usernamePrefix,
Expression: claims.UsernameExpression,
},
Groups: apiserver.PrefixedClaimOrExpression{
Claim: groupsClaim,
Prefix: groupsPrefix,
Expression: claims.GroupsExpression,
},
Extra: convertExtraType(claims.Extra),
}
}
func convertUserValidationRulesType(rules []authenticationv1alpha1.UserValidationRule) []apiserver.UserValidationRule {
if len(rules) == 0 {
return nil
}

View File

@@ -21,7 +21,7 @@ func Test_convertJWTAuthenticatorSpecType(t *testing.T) {
want apiserver.JWTAuthenticator
}{
{
name: "defaults the username and groups claims",
name: "defaults the username and groups claims when the usernameExpression and groupExpression are not set",
spec: &authenticationv1alpha1.JWTAuthenticatorSpec{
Issuer: "https://example.com",
},
@@ -41,6 +41,33 @@ func Test_convertJWTAuthenticatorSpecType(t *testing.T) {
},
},
},
{
name: "does not default the username and groups claims an prefixes when the usernameExpression and groupExpression are set",
spec: &authenticationv1alpha1.JWTAuthenticatorSpec{
Issuer: "https://example.com",
Claims: authenticationv1alpha1.JWTTokenClaims{
UsernameExpression: `"foo"`,
GroupsExpression: `["foo"]`,
},
},
want: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://example.com",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Claim: "",
Prefix: nil,
Expression: `"foo"`,
},
Groups: apiserver.PrefixedClaimOrExpression{
Claim: "",
Prefix: nil,
Expression: `["foo"]`,
},
},
},
},
{
name: "converts every field except for TLS",
spec: &authenticationv1alpha1.JWTAuthenticatorSpec{

View File

@@ -360,13 +360,13 @@ func TestController(t *testing.T) {
Groups: customGroupsClaim,
},
}
someJWTAuthenticatorSpecWithEveryOptionalValue := &authenticationv1alpha1.JWTAuthenticatorSpec{
someJWTAuthenticatorSpecWithManyOptionalValues := &authenticationv1alpha1.JWTAuthenticatorSpec{
Issuer: goodIssuer,
Audience: goodAudience,
TLS: goodOIDCIssuerServerTLSSpec,
Claims: authenticationv1alpha1.JWTTokenClaims{
Username: "my-custom-username-claim",
Groups: customGroupsClaim,
Username: "my-custom-username-claim", // note: can't specify this and usernameExpression at the same time
Groups: customGroupsClaim, // note: can't specify this and groupsExpression at the same time
Extra: []authenticationv1alpha1.ExtraMapping{
{
Key: "example.com/key-name", // must be a domain and path
@@ -387,6 +387,15 @@ func TestController(t *testing.T) {
},
},
}
someJWTAuthenticatorSpecWithUsernameAndGroupExpressions := &authenticationv1alpha1.JWTAuthenticatorSpec{
Issuer: goodIssuer,
Audience: goodAudience,
TLS: goodOIDCIssuerServerTLSSpec,
Claims: authenticationv1alpha1.JWTTokenClaims{
UsernameExpression: "claims.otherUsernameClaim",
GroupsExpression: "has(claims.otherGroupsClaim) ? claims.otherGroupsClaim : []", // handles the case where the claim does not exist in the JWT
},
}
invalidClaimsExtraJWTAuthenticatorSpec := &authenticationv1alpha1.JWTAuthenticatorSpec{
Issuer: goodIssuer,
Audience: goodAudience,
@@ -420,6 +429,17 @@ func TestController(t *testing.T) {
},
},
}
invalidClaimsMutualExclusiveRulesBothSetJWTAuthenticatorSpec := &authenticationv1alpha1.JWTAuthenticatorSpec{
Issuer: goodIssuer,
Audience: goodAudience,
TLS: goodOIDCIssuerServerTLSSpec,
Claims: authenticationv1alpha1.JWTTokenClaims{
Username: "user",
UsernameExpression: `"user"`,
Groups: "groups",
GroupsExpression: `["group1"]`,
},
}
invalidClaimsExtraContainsEqualSignJWTAuthenticatorSpec := &authenticationv1alpha1.JWTAuthenticatorSpec{
Issuer: goodIssuer,
Audience: goodAudience,
@@ -1193,13 +1213,13 @@ func TestController(t *testing.T) {
wantNamesOfJWTAuthenticatorsInCache: []string{"test-name"},
},
{
name: "Sync: JWTAuthenticator with every optional value: loop will complete successfully and update status conditions",
name: "Sync: JWTAuthenticator with many optional values: loop will complete successfully and update status conditions",
jwtAuthenticators: []runtime.Object{
&authenticationv1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Name: "test-name",
},
Spec: *someJWTAuthenticatorSpecWithEveryOptionalValue,
Spec: *someJWTAuthenticatorSpecWithManyOptionalValues,
},
},
wantLogLines: []string{
@@ -1211,7 +1231,7 @@ func TestController(t *testing.T) {
ObjectMeta: metav1.ObjectMeta{
Name: "test-name",
},
Spec: *someJWTAuthenticatorSpecWithEveryOptionalValue,
Spec: *someJWTAuthenticatorSpecWithManyOptionalValues,
Status: authenticationv1alpha1.JWTAuthenticatorStatus{
Conditions: allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0),
Phase: "Ready",
@@ -1229,6 +1249,42 @@ func TestController(t *testing.T) {
wantExtras: map[string][]string{"example.com/key-name": {"extra-value"}},
wantNamesOfJWTAuthenticatorsInCache: []string{"test-name"},
},
{
name: "Sync: JWTAuthenticator with usernameExpression and groupsExpression values: loop will complete successfully and update status conditions",
jwtAuthenticators: []runtime.Object{
&authenticationv1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Name: "test-name",
},
Spec: *someJWTAuthenticatorSpecWithUsernameAndGroupExpressions,
},
},
wantLogLines: []string{
fmt.Sprintf(`{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"jwtcachefiller-controller","caller":"jwtcachefiller/jwtcachefiller.go:<line>$jwtcachefiller.(*jwtCacheFillerController).updateStatus","message":"jwtauthenticator status successfully updated","jwtAuthenticator":"test-name","issuer":"%s","phase":"Ready"}`, goodIssuer),
fmt.Sprintf(`{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"jwtcachefiller-controller","caller":"jwtcachefiller/jwtcachefiller.go:<line>$jwtcachefiller.(*jwtCacheFillerController).syncIndividualJWTAuthenticator","message":"added or updated jwt authenticator in cache","jwtAuthenticator":"test-name","issuer":"%s","isOverwrite":false}`, goodIssuer),
},
wantActions: func() []coretesting.Action {
updateStatusAction := coretesting.NewUpdateAction(jwtAuthenticatorsGVR, "", &authenticationv1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Name: "test-name",
},
Spec: *someJWTAuthenticatorSpecWithUsernameAndGroupExpressions,
Status: authenticationv1alpha1.JWTAuthenticatorStatus{
Conditions: allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0),
Phase: "Ready",
},
})
updateStatusAction.Subresource = "status"
return []coretesting.Action{
coretesting.NewListAction(jwtAuthenticatorsGVR, jwtAUthenticatorGVK, "", metav1.ListOptions{}),
coretesting.NewWatchAction(jwtAuthenticatorsGVR, "", metav1.ListOptions{Watch: true}),
updateStatusAction,
}
},
wantUsernameClaim: "otherUsernameClaim",
wantGroupsClaim: "otherGroupsClaim",
wantNamesOfJWTAuthenticatorsInCache: []string{"test-name"},
},
{
name: "Sync: JWTAuthenticator with new spec.tls fields: loop will close previous instance of JWTAuthenticator and complete successfully and update status conditions",
cache: func(t *testing.T, cache *authncache.Cache, wantClose bool) {
@@ -2652,6 +2708,53 @@ func TestController(t *testing.T) {
}
},
},
{
name: "newCachedJWTAuthenticator: validateAuthenticationConfiguration: when username and usernameExpression and/or groups and groupsExpression are both set: loop will fail sync, will write failed and unknown status conditions, but will not enqueue a resync due to user config error",
jwtAuthenticators: []runtime.Object{
&authenticationv1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Name: "test-name",
},
Spec: *invalidClaimsMutualExclusiveRulesBothSetJWTAuthenticatorSpec,
},
},
wantLogLines: []string{
fmt.Sprintf(`{"level":"info","timestamp":"2099-08-08T13:57:36.123456Z","logger":"jwtcachefiller-controller","caller":"jwtcachefiller/jwtcachefiller.go:<line>$jwtcachefiller.(*jwtCacheFillerController).syncIndividualJWTAuthenticator","message":"invalid jwt authenticator","jwtAuthenticator":"test-name","issuer":"%s","removedFromCache":false}`, invalidClaimsMutualExclusiveRulesBothSetJWTAuthenticatorSpec.Issuer),
fmt.Sprintf(`{"level":"debug","timestamp":"2099-08-08T13:57:36.123456Z","logger":"jwtcachefiller-controller","caller":"jwtcachefiller/jwtcachefiller.go:<line>$jwtcachefiller.(*jwtCacheFillerController).updateStatus","message":"jwtauthenticator status successfully updated","jwtAuthenticator":"test-name","issuer":"%s","phase":"Error"}`, invalidClaimsMutualExclusiveRulesBothSetJWTAuthenticatorSpec.Issuer),
},
wantActions: func() []coretesting.Action {
updateStatusAction := coretesting.NewUpdateAction(jwtAuthenticatorsGVR, "", &authenticationv1alpha1.JWTAuthenticator{
ObjectMeta: metav1.ObjectMeta{
Name: "test-name",
},
Spec: *invalidClaimsMutualExclusiveRulesBothSetJWTAuthenticatorSpec,
Status: authenticationv1alpha1.JWTAuthenticatorStatus{
Conditions: conditionstestutil.Replace(
allHappyConditionsSuccess(goodIssuer, frozenMetav1Now, 0),
[]metav1.Condition{
sadReadyCondition(frozenMetav1Now, 0),
happyIssuerURLValid(frozenMetav1Now, 0),
happyDiscoveryURLValid(frozenMetav1Now, 0),
sadAuthenticatorValid(
`could not initialize jwt authenticator: [claims.username: Invalid value: "": claim and expression can't both be set, claims.groups: Invalid value: "": claim and expression can't both be set]`,
frozenMetav1Now,
0,
),
happyJWKSURLValid(frozenMetav1Now, 0),
happyJWKSFetch(frozenMetav1Now, 0),
},
),
Phase: "Error",
},
})
updateStatusAction.Subresource = "status"
return []coretesting.Action{
coretesting.NewListAction(jwtAuthenticatorsGVR, jwtAUthenticatorGVK, "", metav1.ListOptions{}),
coretesting.NewWatchAction(jwtAuthenticatorsGVR, "", metav1.ListOptions{Watch: true}),
updateStatusAction,
}
},
},
{
name: "newCachedJWTAuthenticator: when any claims.extra[].key contains an equals sign: loop will fail sync, will write failed and unknown status conditions, but will not enqueue a resync due to user config error",
jwtAuthenticators: []runtime.Object{
@@ -2903,6 +3006,15 @@ func TestController(t *testing.T) {
cachedAuthenticator, ok := temp.(tokenAuthenticatorCloser)
require.True(t, ok)
usernameClaimIsCelExpression := false
if temp.(*cachedJWTAuthenticator).claims.UsernameExpression != "" {
usernameClaimIsCelExpression = true
}
groupsClaimIsCelExpression := false
if temp.(*cachedJWTAuthenticator).claims.GroupsExpression != "" {
groupsClaimIsCelExpression = true
}
// Schedule it to be closed at the end of the test.
t.Cleanup(cachedAuthenticator.Close)
@@ -2930,13 +3042,19 @@ func TestController(t *testing.T) {
group1,
goodUsername,
tt.wantUsernameClaim,
usernameClaimIsCelExpression,
tt.wantGroupsClaim,
groupsClaimIsCelExpression,
tt.wantExtras,
goodIssuer,
) {
t.Run(test.name, func(t *testing.T) {
t.Parallel()
if test.skip != nil {
test.skip(t) // give the test a chance to skip itself if it wants to
}
wellKnownClaims := josejwt.Claims{
Issuer: goodIssuer,
Subject: goodSubject,
@@ -3012,7 +3130,9 @@ func testTableForAuthenticateTokenTests(
group1 string,
goodUsername string,
expectedUsernameClaim string,
usernameClaimIsCelExpression bool,
expectedGroupsClaim string,
groupsClaimIsCelExpression bool,
expectedExtras map[string][]string,
issuer string,
) []struct {
@@ -3023,7 +3143,22 @@ func testTableForAuthenticateTokenTests(
wantAuthenticated bool
wantErr testutil.RequireErrorStringFunc
distributedGroupsClaimURL string
skip func(t *testing.T)
} {
expectedErrForBadTokenWithGroupsAsMap := func() testutil.RequireErrorStringFunc {
if groupsClaimIsCelExpression {
return testutil.WantExactErrorString(`oidc: error evaluating group claim expression: expression must return a string or a list of strings`)
}
return testutil.WantExactErrorString(`oidc: parse groups claim "` + expectedGroupsClaim + `": json: cannot unmarshal object into Go value of type string`)
}
expectedErrForTokenDoesNotHaveUsernameClaim := func() testutil.RequireErrorStringFunc {
if usernameClaimIsCelExpression {
return testutil.WantMatchingErrorString(`oidc: error evaluating username claim expression: expression '.+' resulted in error: no such key: ` + expectedUsernameClaim)
}
return testutil.WantExactErrorString(`oidc: parse username claims "` + expectedUsernameClaim + `": claim not present`)
}
tests := []struct {
name string
jwtClaims func(wellKnownClaims *josejwt.Claims, groups *any, username *string)
@@ -3032,6 +3167,7 @@ func testTableForAuthenticateTokenTests(
wantAuthenticated bool
wantErr testutil.RequireErrorStringFunc
distributedGroupsClaimURL string
skip func(t *testing.T)
}{
{
name: "good token without groups and with EC signature",
@@ -3085,13 +3221,23 @@ func testTableForAuthenticateTokenTests(
},
},
wantAuthenticated: true,
skip: func(t *testing.T) {
if groupsClaimIsCelExpression {
t.Skip("skipping test because Kubernetes does not support using a CEL expression for groups mapping with distributed claims")
}
},
},
{
name: "distributed groups returns a 404",
jwtClaims: func(claims *josejwt.Claims, groups *any, username *string) {
},
distributedGroupsClaimURL: issuer + "/not_found_claim_source",
wantErr: testutil.WantMatchingErrorString(`oidc: could not expand distributed claims: while getting distributed claim "` + expectedGroupsClaim + `": error while getting distributed claim JWT: 404 Not Found`),
wantErr: testutil.WantExactErrorString(`oidc: could not expand distributed claims: while getting distributed claim "` + expectedGroupsClaim + `": error while getting distributed claim JWT: 404 Not Found`),
skip: func(t *testing.T) {
if groupsClaimIsCelExpression {
t.Skip("skipping test because Kubernetes does not support using a CEL expression for groups mapping with distributed claims")
}
},
},
{
name: "distributed groups doesn't return the right claim",
@@ -3099,6 +3245,11 @@ func testTableForAuthenticateTokenTests(
},
distributedGroupsClaimURL: issuer + "/wrong_claim_source",
wantErr: testutil.WantMatchingErrorString(`oidc: could not expand distributed claims: jwt returned by distributed claim endpoint "` + issuer + `/wrong_claim_source" did not contain claim: `),
skip: func(t *testing.T) {
if groupsClaimIsCelExpression {
t.Skip("skipping test because Kubernetes does not support using a CEL expression for groups mapping with distributed claims")
}
},
},
{
name: "good token with groups as string",
@@ -3132,7 +3283,7 @@ func testTableForAuthenticateTokenTests(
jwtClaims: func(_ *josejwt.Claims, groups *any, username *string) {
*groups = map[string]string{"not an array": "or a string"}
},
wantErr: testutil.WantMatchingErrorString(`oidc: parse groups claim "` + expectedGroupsClaim + `": json: cannot unmarshal object into Go value of type string`),
wantErr: expectedErrForBadTokenWithGroupsAsMap(),
},
{
name: "bad token with wrong issuer",
@@ -3147,14 +3298,14 @@ func testTableForAuthenticateTokenTests(
jwtClaims: func(claims *josejwt.Claims, _ *any, username *string) {
claims.Audience = nil
},
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: expected audience "some-audience" got \[\]`),
wantErr: testutil.WantExactErrorString(`oidc: verify token: oidc: expected audience "some-audience" got []`),
},
{
name: "bad token with wrong audience",
jwtClaims: func(claims *josejwt.Claims, _ *any, username *string) {
claims.Audience = []string{"wrong-audience"}
},
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: expected audience "some-audience" got \["wrong-audience"\]`),
wantErr: testutil.WantExactErrorString(`oidc: verify token: oidc: expected audience "some-audience" got ["wrong-audience"]`),
},
{
name: "bad token with nbf in the future",
@@ -3182,7 +3333,7 @@ func testTableForAuthenticateTokenTests(
jwtClaims: func(claims *josejwt.Claims, _ *any, username *string) {
*username = ""
},
wantErr: testutil.WantMatchingErrorString(`oidc: parse username claims "` + expectedUsernameClaim + `": claim not present`),
wantErr: expectedErrForTokenDoesNotHaveUsernameClaim(),
},
{
name: "signing key is wrong",
@@ -3192,7 +3343,7 @@ func testTableForAuthenticateTokenTests(
require.NoError(t, err)
*algo = jose.ES256
},
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: failed to verify signature: failed to verify id token signature`),
wantErr: testutil.WantExactErrorString(`oidc: verify token: failed to verify signature: failed to verify id token signature`),
},
{
name: "signing algo is unsupported",
@@ -3202,7 +3353,7 @@ func testTableForAuthenticateTokenTests(
require.NoError(t, err)
*algo = jose.ES384
},
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: id token signed with unsupported algorithm, expected \["RS256" "ES256"\] got "ES384"`),
wantErr: testutil.WantExactErrorString(`oidc: verify token: oidc: id token signed with unsupported algorithm, expected ["RS256" "ES256"] got "ES384"`),
},
}