From 81090d185536a4b966e0d7621bec042445b7234f Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Mon, 20 Jul 2026 10:02:40 +0100 Subject: [PATCH] Normalize opt-in multi-valued JWT claims for Cedar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OAuth scope claims arrive in different shapes across identity providers: Entra and Keycloak emit them as one space-delimited string, while Okta emits a JSON array. The Cedar authorizer maps a string to a Cedar String and an array to a Set, and no single policy expression matches element membership across both shapes — a type mismatch errors and the policy is silently ignored, so a policy authored for one IdP fails closed for another. Add an opt-in MultiValuedClaims config field naming the JWT claims to normalize. For each listed claim, the authorizer exposes two forms: - claim_ is normalized to a space-delimited string (arrays are joined, strings pass through unchanged) so existing string-membership policies match uniformly across IdPs; and - a companion Cedar Set is exposed as claimset_ for exact-element membership via contains/containsAll/containsAny. Both surface on the principal entity and the request context. The claimset_ prefix is reserved and cannot be shadowed by a JWT claim, since all claims are prefixed with claim_. The field is opt-in: with it empty, output is byte-identical to before, so existing deployments are unaffected. Co-Authored-By: Claude Opus 4.8 --- pkg/authz/authorizers/cedar/core.go | 185 +++++++++- pkg/authz/authorizers/cedar/core_test.go | 439 +++++++++++++++++++++++ 2 files changed, 621 insertions(+), 3 deletions(-) diff --git a/pkg/authz/authorizers/cedar/core.go b/pkg/authz/authorizers/cedar/core.go index 03dba9cb6d..ef5a538428 100644 --- a/pkg/authz/authorizers/cedar/core.go +++ b/pkg/authz/authorizers/cedar/core.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "log/slog" + "slices" "strings" "sync" "time" @@ -149,6 +150,16 @@ var ( // ClientIDContextKey is the key used to store client ID in the context. type ClientIDContextKey struct{} +// claimPrefix namespaces every JWT claim in the Cedar context/principal +// attributes. claimSetPrefix namespaces the synthetic multi-valued-claim Sets. +// They are deliberately disjoint: because every JWT claim is emitted under +// claimPrefix, a token can never produce a claimSetPrefix key, so the synthetic +// Sets cannot be shadowed or spoofed by claim content. +const ( + claimPrefix = "claim_" + claimSetPrefix = "claimset_" +) + // Authorizer authorizes MCP operations using Cedar policies. type Authorizer struct { // Cedar policy set @@ -179,6 +190,10 @@ type Authorizer struct { // claimKeyLog rate-limits the diagnostic log of resolved JWT claim keys // so it emits at most once per 30 seconds instead of once per authorization check. claimKeyLog *syncutil.AtMost + // multiValuedClaims lists JWT claim names normalized to a canonical unpadded + // space-delimited string, plus a companion Cedar Set, before Cedar evaluation. + // See ConfigOptions.MultiValuedClaims. + multiValuedClaims []string } // ConfigOptions represents the Cedar-specific authorization configuration options. @@ -214,6 +229,43 @@ type ConfigOptions struct { // names (e.g. "Platform::Group") are not yet supported and are rejected at // construction. See issue #5072. GroupEntityType string `json:"group_entity_type,omitempty" yaml:"group_entity_type,omitempty"` + + // MultiValuedClaims lists JWT claim NAMES (bare, e.g. "scp" or "scope") whose + // value is exposed to Cedar in two normalized forms, regardless of whether the + // IdP emits the claim as a space-delimited string (Entra `scp`, Keycloak `scope`) + // or a JSON array (Okta `scp`): + // + // 1. "claim_" (both principal attribute and context) is a space-delimited + // string: an array is joined with single spaces; a string value is passed + // through VERBATIM — its spacing and order are preserved, not + // re-canonicalized. Use this with `like`/`==` string policies. + // 2. "claimset_" (bare key, both principal attribute and context) is a Cedar + // Set of the claim's elements. Use this with `.contains`/`.containsAll`/ + // `.containsAny` for exact-element matching that cannot be fooled by a + // substring (e.g. "Mail.Read" will not match "Mail.ReadWrite"). The "claimset_" + // prefix is reserved and never collides with a JWT claim, since all JWT + // claims are surfaced under the "claim_" prefix. + // + // Only for claims whose ELEMENTS are space-free (OAuth scopes, per RFC 6749 §3.3). + // Do NOT list group/role claims or any claim whose elements can contain spaces + // (e.g. group display names) — element boundaries would become ambiguous. + // + // Backward compatibility: opt-in. When empty (default), "claim_" is + // byte-identical to today and no "claimset_" key is added. Listing a claim + // leaves OTHER claims untouched. + // + // Migration hazard: listing a claim that an IdP emits as an ARRAY changes its + // "claim_" form from a Cedar Set to a String. A pre-existing policy that + // tested it with `like`/substring — which errored on the Set and so failed + // closed (deny) — will then match against the joined string and can + // substring-match (e.g. `context.claim_scp like "*Mail.Read*"` also matches + // "Mail.ReadWrite"), an unintended grant. Audit existing `like` policies on a + // claim before listing it, and prefer "claimset_" with `.contains`/ + // `.containsAll`/`.containsAny` for exact-element membership. + // + // Note: this unifies claim SHAPE, not NAME — `scp` and `scope` remain distinct + // claim names, so a policy still references one specific name. + MultiValuedClaims []string `json:"multi_valued_claims,omitempty" yaml:"multi_valued_claims,omitempty"` } // validateGroupEntityType validates a GroupEntityType value. Empty string is @@ -269,6 +321,7 @@ func NewCedarAuthorizer(options ConfigOptions, serverName string) (authorizers.A roleClaimName: options.RoleClaimName, serverName: serverName, claimKeyLog: syncutil.NewAtMost(30 * time.Second), + multiValuedClaims: options.MultiValuedClaims, } // Load policies @@ -587,12 +640,134 @@ func extractClientIDFromClaims(claims jwt.MapClaims) (string, bool) { func preprocessClaims(claims jwt.MapClaims) map[string]interface{} { preprocessed := make(map[string]interface{}) for k, v := range claims { - claimKey := fmt.Sprintf("claim_%s", k) + claimKey := claimPrefix + k preprocessed[claimKey] = v } return preprocessed } +// normalizeMultiValuedClaims returns claims with every claim named in names +// and present in claims rewritten to a canonical unpadded space-delimited +// string. This lets a single Cedar `like`/`==` policy match a claim regardless +// of whether the IdP emitted it as a space-delimited string or a JSON array. +// See ConfigOptions.MultiValuedClaims for the full rationale. +// +// When names is empty the input is returned directly (no copy); otherwise a +// shallow copy is returned and the input is never mutated. +// +// - array ([]interface{} or []string): elements joined with single spaces. +// Non-string elements are stringified with fmt.Sprint rather than +// dropped; nested array/object elements are skipped with a slog.Debug +// since they have no scalar string form. +// - string: passthrough, completely unchanged (not trimmed). +// - empty array, or an array with no usable elements: "" (empty string — +// present but empty). +// - claim absent from claims, or names empty: left untouched (no key +// fabricated). +// +// Only top-level claim keys are matched; there is no dot-notation support. +// claims is not mutated. +func normalizeMultiValuedClaims(claims jwt.MapClaims, names []string) jwt.MapClaims { + if len(names) == 0 { + // Nothing to normalize. Return the input directly — the sole caller + // (preprocessClaims) only reads it, so no defensive copy is needed and + // the opt-in-empty path pays no allocation. + return claims + } + + out := make(jwt.MapClaims, len(claims)) + for k, v := range claims { + out[k] = v + } + + for _, name := range names { + v, ok := claims[name] + if !ok { + continue + } + + switch val := v.(type) { + case string: + // Verbatim passthrough: out[name] already equals val (out is a copy + // of claims), so this is intentionally a no-op. The explicit branch + // documents the string contract and, importantly, keeps a string + // value out of the default "unrecognized type" log below. This is the + // one place the string handling diverges from addMultiValuedClaimSets, + // which collapses whitespace via strings.Fields for the Set form. + out[name] = val + case []interface{}: + out[name] = strings.Join(multiValuedTokens(name, val), " ") + case []string: + out[name] = strings.Join(val, " ") + default: + slog.Debug("multi-valued claim has unrecognized type, leaving unchanged", + "claim", name, "type", fmt.Sprintf("%T", v)) + } + } + + return out +} + +// addMultiValuedClaimSets adds a bare "claimset_" key to processed for every +// claim named in names and present in raw, holding the claim's elements as a +// []string (converted to a Cedar Set by convertToCedarValueAtDepth). This is +// the companion to normalizeMultiValuedClaims's "claim_" string form: +// where "claim_" supports substring-style `like` matching, "claimset_" +// supports exact-element `.contains`/`.containsAll`/`.containsAny` matching. +// The "claimset_" prefix is reserved and cannot collide with a JWT claim, since all +// JWT claims are "claim_"-prefixed by preprocessClaims. +// +// processed must already have the "claim_" prefix applied (i.e. this must run +// after preprocessClaims) so the added key stays bare. raw is not mutated. +func addMultiValuedClaimSets(processed map[string]interface{}, raw jwt.MapClaims, names []string) { + for _, name := range names { + v, ok := raw[name] + if !ok { + continue + } + + // The array path shares multiValuedTokens with normalizeMultiValuedClaims + // (string-only elements) so the Set and the joined string stay in sync. + // The string path deliberately differs: normalize passes the string + // through verbatim, whereas here strings.Fields splits it into exact + // elements (collapsing irregular whitespace) — the two cannot share one + // coercion for that reason. + switch val := v.(type) { + case string: + processed[claimSetPrefix+name] = strings.Fields(val) + case []interface{}: + processed[claimSetPrefix+name] = multiValuedTokens(name, val) + case []string: + processed[claimSetPrefix+name] = slices.Clone(val) + default: + slog.Debug("multi-valued claim has unrecognized type, omitting claimset set", + "claim", name, "type", fmt.Sprintf("%T", v)) + } + } +} + +// multiValuedTokens extracts the string elements of a []interface{} claim value +// for normalizeMultiValuedClaims and addMultiValuedClaimSets. Only string +// elements are kept: OAuth scope tokens are strings (RFC 6749 §3.3), so a +// non-string element (number, bool, null, or a nested array/object) is +// malformed and is skipped with a slog.Debug rather than stringified. Skipping +// (a) avoids spurious tokens like "" or "1e+06" polluting both claim +// forms, and (b) keeps the array shape consistent with the string shape, whose +// strings.Fields path likewise yields only real string tokens. +func multiValuedTokens(claimName string, elems []interface{}) []string { + tokens := make([]string, 0, len(elems)) + for _, elem := range elems { + s, ok := elem.(string) + if !ok { + slog.Debug("multi-valued claim element is not a string, skipping", + "claim", claimName, "type", fmt.Sprintf("%T", elem)) + continue + } + tokens = append(tokens, s) + } + return tokens +} + // preprocessArguments adds an "arg_" prefix to all argument keys. // For complex types, it just notes their presence with an "_present" suffix. func preprocessArguments(arguments map[string]interface{}) map[string]interface{} { @@ -884,8 +1059,12 @@ func (a *Authorizer) AuthorizeWithJWTClaims( extractGroups(resolvedClaims, a.roleClaimName)..., )) - // Preprocess claims and arguments - processedClaims := preprocessClaims(resolvedClaims) + // Preprocess claims and arguments. Multi-valued claim normalization runs + // after group/role extraction (which needs the raw claim shapes) and + // before the "claim_" prefix is applied. + normalizedClaims := normalizeMultiValuedClaims(resolvedClaims, a.multiValuedClaims) + processedClaims := preprocessClaims(normalizedClaims) + addMultiValuedClaimSets(processedClaims, resolvedClaims, a.multiValuedClaims) processedArgs := preprocessArguments(arguments) // Authorize based on the feature and operation diff --git a/pkg/authz/authorizers/cedar/core_test.go b/pkg/authz/authorizers/cedar/core_test.go index 1bab4bce19..f49a0428f7 100644 --- a/pkg/authz/authorizers/cedar/core_test.go +++ b/pkg/authz/authorizers/cedar/core_test.go @@ -2571,6 +2571,205 @@ func TestAuthorizeWithJWTClaims_BackwardCompat(t *testing.T) { } } +// TestAuthorizeWithJWTClaims_MultiValuedClaimSets is the end-to-end proof that +// both normalized surfaces work for both IdP shapes (array and space-delimited +// string): the "claimset_" Cedar Set for exact-element `.contains`/ +// `.containsAll` matching, and (in the containsAll subtest) that a missing +// token correctly denies. It also proves `.contains` is exact-element — a +// "Mail.ReadWrite" token does not satisfy a "Mail.Read" check. +func TestAuthorizeWithJWTClaims_MultiValuedClaimSets(t *testing.T) { + t.Parallel() + + newAuthorizerCtx := func(t *testing.T, scp interface{}) context.Context { + t.Helper() + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]any{ + "sub": "user1", + "scp": scp, + }, + }, + } + return auth.WithIdentity(context.Background(), identity) + } + + t.Run("contains_exact_element_no_substring_bleed", func(t *testing.T) { + t.Parallel() + + policy := ` + permit(principal, action, resource) + when { context.claimset_scp.contains("Mail.Read") }; + ` + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: `[]`, + MultiValuedClaims: []string{"scp"}, + }, "") + require.NoError(t, err) + + tests := []struct { + name string + scp interface{} + wantAuth bool + }{ + { + name: "array_shaped_scp_permitted", + scp: []interface{}{"User.Read.All", "Mail.Read"}, + wantAuth: true, + }, + { + name: "string_shaped_scp_permitted", + scp: "User.Read.All Mail.Read", + wantAuth: true, + }, + { + name: "substring_scope_not_matched", + scp: []interface{}{"Mail.ReadWrite"}, + wantAuth: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + authorized, err := authorizer.AuthorizeWithJWTClaims( + newAuthorizerCtx(t, tt.scp), + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "any-tool", + nil, + ) + require.NoError(t, err) + assert.Equal(t, tt.wantAuth, authorized) + }) + } + }) + + t.Run("contains_all_tokens", func(t *testing.T) { + t.Parallel() + + policy := ` + permit(principal, action, resource) + when { context.claimset_scp.containsAll(["User.Read.All", "Mail.Read"]) }; + ` + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: `[]`, + MultiValuedClaims: []string{"scp"}, + }, "") + require.NoError(t, err) + + tests := []struct { + name string + scp interface{} + wantAuth bool + }{ + { + name: "array_shaped_scp_has_both_tokens_permitted", + scp: []interface{}{"User.Read.All", "Mail.Read", "Extra.Scope"}, + wantAuth: true, + }, + { + name: "string_shaped_scp_has_both_tokens_permitted", + scp: "User.Read.All Mail.Read", + wantAuth: true, + }, + { + name: "missing_one_token_denied", + scp: []interface{}{"User.Read.All"}, + wantAuth: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + authorized, err := authorizer.AuthorizeWithJWTClaims( + newAuthorizerCtx(t, tt.scp), + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "any-tool", + nil, + ) + require.NoError(t, err) + assert.Equal(t, tt.wantAuth, authorized) + }) + } + }) + + // principal_claimset_set proves the claimset_ Set also appears as a principal + // entity attribute, not just in the context — mirroring how claim_ + // is available on both surfaces. + t.Run("principal_claimset_set", func(t *testing.T) { + t.Parallel() + + policy := ` + permit(principal, action, resource) + when { principal.claimset_scp.contains("Mail.Read") }; + ` + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: `[]`, + MultiValuedClaims: []string{"scp"}, + }, "") + require.NoError(t, err) + + authorized, err := authorizer.AuthorizeWithJWTClaims( + newAuthorizerCtx(t, []interface{}{"User.Read.All", "Mail.Read"}), + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "any-tool", + nil, + ) + require.NoError(t, err) + assert.True(t, authorized) + }) +} + +// TestAuthorizeWithJWTClaims_MultiValuedClaimsEmptyIsBackwardCompat verifies that +// when MultiValuedClaims is unset, a whole-string equality policy on the claim +// still matches — i.e. normalization is fully opt-in and byte-identical to the +// pre-#5828 behavior when no claim names are listed. +func TestAuthorizeWithJWTClaims_MultiValuedClaimsEmptyIsBackwardCompat(t *testing.T) { + t.Parallel() + + policy := ` + permit(principal, action, resource) + when { context.claim_scp == "User.Read.All Mail.Read" }; + ` + + authorizer, err := NewCedarAuthorizer(ConfigOptions{ + Policies: []string{policy}, + EntitiesJSON: `[]`, + // MultiValuedClaims intentionally left unset. + }, "") + require.NoError(t, err) + + identity := &auth.Identity{ + PrincipalInfo: auth.PrincipalInfo{ + Subject: "user1", + Claims: map[string]any{ + "sub": "user1", + "scp": "User.Read.All Mail.Read", + }, + }, + } + ctx := auth.WithIdentity(context.Background(), identity) + + authorized, err := authorizer.AuthorizeWithJWTClaims( + ctx, + authorizers.MCPFeatureTool, + authorizers.MCPOperationCall, + "any-tool", + nil, + ) + require.NoError(t, err) + assert.True(t, authorized, "unpadded whole-string equality must still match when MultiValuedClaims is unset") +} + // TestParseCedarEntityID tests the parseCedarEntityID helper function. func TestParseCedarEntityID(t *testing.T) { t.Parallel() @@ -2768,6 +2967,201 @@ func TestPreprocessClaims(t *testing.T) { } } +// TestNormalizeMultiValuedClaims tests the normalizeMultiValuedClaims helper, +// which rewrites listed claims to a canonical unpadded space-delimited string +// so a single Cedar `like`/`==` policy matches regardless of whether the IdP +// emitted the claim as a space-delimited string or a JSON array. +func TestNormalizeMultiValuedClaims(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + claims jwt.MapClaims + names []string + want jwt.MapClaims + }{ + { + name: "array_of_strings_joined_unpadded", + claims: jwt.MapClaims{"scp": []interface{}{"a", "b"}}, + names: []string{"scp"}, + want: jwt.MapClaims{"scp": "a b"}, + }, + { + name: "string_array_joined_unpadded", + claims: jwt.MapClaims{"scp": []string{"a", "b"}}, + names: []string{"scp"}, + want: jwt.MapClaims{"scp": "a b"}, + }, + { + name: "single_element_array_unpadded", + claims: jwt.MapClaims{"scp": []interface{}{"a"}}, + names: []string{"scp"}, + want: jwt.MapClaims{"scp": "a"}, + }, + { + name: "string_passthrough_unchanged", + claims: jwt.MapClaims{"scp": "a b"}, + names: []string{"scp"}, + want: jwt.MapClaims{"scp": "a b"}, + }, + { + name: "string_with_edge_and_internal_spaces_unchanged", + claims: jwt.MapClaims{"scp": " a b "}, + names: []string{"scp"}, + want: jwt.MapClaims{"scp": " a b "}, + }, + { + name: "empty_array_becomes_empty_string", + claims: jwt.MapClaims{"scp": []interface{}{}}, + names: []string{"scp"}, + want: jwt.MapClaims{"scp": ""}, + }, + { + name: "absent_claim_stays_absent", + claims: jwt.MapClaims{"sub": "user1"}, + names: []string{"scp"}, + want: jwt.MapClaims{"sub": "user1"}, + }, + { + name: "claim_not_in_names_untouched", + claims: jwt.MapClaims{"scp": []interface{}{"a", "b"}, "sub": "user1"}, + names: []string{"scope"}, + want: jwt.MapClaims{"scp": []interface{}{"a", "b"}, "sub": "user1"}, + }, + { + name: "non_string_elements_skipped", + claims: jwt.MapClaims{"scp": []interface{}{"a", 1, true}}, + names: []string{"scp"}, + want: jwt.MapClaims{"scp": "a"}, + }, + { + name: "nested_element_skipped", + claims: jwt.MapClaims{"scp": []interface{}{"a", []interface{}{"nested"}}}, + names: []string{"scp"}, + want: jwt.MapClaims{"scp": "a"}, + }, + { + name: "empty_names_list_leaves_contents_identical", + claims: jwt.MapClaims{"scp": []interface{}{"a", "b"}, "sub": "user1"}, + names: nil, + want: jwt.MapClaims{"scp": []interface{}{"a", "b"}, "sub": "user1"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := normalizeMultiValuedClaims(tt.claims, tt.names) + assert.Equal(t, tt.want, got) + }) + } +} + +// TestAddMultiValuedClaimSets tests the addMultiValuedClaimSets helper, which +// injects a bare "claimset_" key holding the claim's elements as a []string +// (converted to a Cedar Set downstream) — the companion to +// normalizeMultiValuedClaims's "claim_" string form. +func TestAddMultiValuedClaimSets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + processed map[string]interface{} + raw jwt.MapClaims + names []string + want map[string]interface{} + }{ + { + name: "array_claim_becomes_set", + processed: map[string]interface{}{"claim_scp": "a b"}, + raw: jwt.MapClaims{"scp": []interface{}{"a", "b"}}, + names: []string{"scp"}, + want: map[string]interface{}{"claim_scp": "a b", "claimset_scp": []string{"a", "b"}}, + }, + { + name: "string_claim_split_into_set", + processed: map[string]interface{}{"claim_scp": "a b"}, + raw: jwt.MapClaims{"scp": "a b"}, + names: []string{"scp"}, + want: map[string]interface{}{"claim_scp": "a b", "claimset_scp": []string{"a", "b"}}, + }, + { + // Companion to normalizeMultiValuedClaims's verbatim passthrough: + // claim_scp keeps irregular spacing untouched, while claimset_scp + // collapses it via strings.Fields into exact elements. + name: "multi_space_string_collapsed_into_set", + processed: map[string]interface{}{"claim_scp": " a b "}, + raw: jwt.MapClaims{"scp": " a b "}, + names: []string{"scp"}, + want: map[string]interface{}{"claim_scp": " a b ", "claimset_scp": []string{"a", "b"}}, + }, + { + name: "empty_array_becomes_empty_set", + processed: map[string]interface{}{"claim_scp": ""}, + raw: jwt.MapClaims{"scp": []interface{}{}}, + names: []string{"scp"}, + want: map[string]interface{}{"claim_scp": "", "claimset_scp": []string{}}, + }, + { + name: "empty_string_becomes_empty_set", + processed: map[string]interface{}{"claim_scp": ""}, + raw: jwt.MapClaims{"scp": ""}, + names: []string{"scp"}, + want: map[string]interface{}{"claim_scp": "", "claimset_scp": []string{}}, + }, + { + name: "absent_claim_no_key_added", + processed: map[string]interface{}{"claim_sub": "user1"}, + raw: jwt.MapClaims{"sub": "user1"}, + names: []string{"scp"}, + want: map[string]interface{}{"claim_sub": "user1"}, + }, + { + name: "claim_not_in_names_no_key_added", + processed: map[string]interface{}{"claim_scp": "a b"}, + raw: jwt.MapClaims{"scp": []interface{}{"a", "b"}}, + names: []string{"scope"}, + want: map[string]interface{}{"claim_scp": "a b"}, + }, + { + name: "non_string_elements_skipped", + processed: map[string]interface{}{"claim_scp": "a"}, + raw: jwt.MapClaims{"scp": []interface{}{"a", 1, true}}, + names: []string{"scp"}, + want: map[string]interface{}{"claim_scp": "a", "claimset_scp": []string{"a"}}, + }, + { + name: "nested_element_skipped", + processed: map[string]interface{}{"claim_scp": "a"}, + raw: jwt.MapClaims{"scp": []interface{}{"a", []interface{}{"nested"}}}, + names: []string{"scp"}, + want: map[string]interface{}{"claim_scp": "a", "claimset_scp": []string{"a"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + addMultiValuedClaimSets(tt.processed, tt.raw, tt.names) + assert.Equal(t, tt.want, tt.processed) + + // The injected key must be bare "claimset_", never + // "claim_claimset_" — it must not be reachable via the + // claim_-prefix path. + for _, name := range tt.names { + _, hadRaw := tt.raw[name] + if !hadRaw { + continue + } + _, hasBareKey := tt.processed["claimset_"+name] + _, hasWrongKey := tt.processed["claim_claimset_"+name] + assert.True(t, hasBareKey, "expected bare claimset_%s key", name) + assert.False(t, hasWrongKey, "must not add claim_claimset_%s", name) + } + }) + } +} + // TestPreprocessArguments tests the preprocessArguments helper. func TestPreprocessArguments(t *testing.T) { t.Parallel() @@ -2985,6 +3379,51 @@ func TestConfigOptionsRoleClaimNameJSON(t *testing.T) { } } +// TestConfigOptionsMultiValuedClaimsJSON verifies JSON marshal/unmarshal of the +// MultiValuedClaims field, including backward compatibility when the field is absent. +func TestConfigOptionsMultiValuedClaimsJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + jsonInput string + want []string + wantOmitOnMar bool // when true, marshal output must NOT contain "multi_valued_claims" + }{ + { + name: "present", + jsonInput: `{"policies":["permit(principal,action,resource);"],"multi_valued_claims":["scp","scope"]}`, + want: []string{"scp", "scope"}, + }, + { + name: "absent_gives_nil", + jsonInput: `{"policies":["permit(principal,action,resource);"]}`, + want: nil, + wantOmitOnMar: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var opts ConfigOptions + err := json.Unmarshal([]byte(tt.jsonInput), &opts) + require.NoError(t, err) + assert.Equal(t, tt.want, opts.MultiValuedClaims) + + marshalled, err := json.Marshal(opts) + require.NoError(t, err) + if tt.wantOmitOnMar { + assert.NotContains(t, string(marshalled), "multi_valued_claims", + "empty MultiValuedClaims must be omitted from JSON output") + } else { + assert.Contains(t, string(marshalled), "multi_valued_claims") + } + }) + } +} + // TestValidateGroupEntityType exercises the private validateGroupEntityType helper // directly. Each case names an input, states whether it should succeed, and — for // error cases — a substring that the error message must contain so operators can