Skip to content

Commit c1292c4

Browse files
jhrozekclaude
andauthored
Extend group extraction with dual-claim and dot-notation support (#4911)
A single Cedar authorizer must handle the group/role claim layouts of all major IDPs: Entra ID puts roles in "roles" and groups in "groups" (dual-claim), Okta uses "groups" or a URI-style claim, and Keycloak nests roles under "realm_access.roles". The previous single-claim extractGroupsFromClaims could not express any of these. Replace extractGroupsFromClaims with resolveNestedClaim + extractGroups + dedup. Groups are now extracted from both GroupClaimName and RoleClaimName, merged, and deduplicated before Cedar evaluation. resolveNestedClaim tries exact top-level match first (Auth0/Okta URL-style claim names containing dots) then dot-notation traversal (Keycloak-style nested claims). The well-known fallback ("groups", "roles", "cognito:groups") is preserved: when the configured GroupClaimName is absent from the token or unconfigured, the well-known names are still checked. This matches the documented contract that the custom name takes priority over defaults, not replaces them. E2E tested in a Kind cluster with real IDP tokens: Entra MCPServer (RoleClaimName: "roles"): JWT: { "roles": ["mcp-admin", "developer"] } permit(principal in THVGroup::"mcp-admin", action, resource in MCP::"<server>"); call_tool "echo" -> 200 (role extracted, THVGroup parent set) Okta MCPServer (GroupClaimName: "groups"): JWT: { "groups": ["Everyone", "engineering"] } permit(principal in THVGroup::"engineering", action, resource in MCP::"<server>"); call_tool "echo" -> 200 Wrong-group denial (same Okta OIDC, different policy): permit(principal in THVGroup::"platform-ops", ...); user in "engineering" only -> 403 Dot-notation traversal (Keycloak) is unit-tested; no Keycloak server was deployed for E2E. Fixes #4768 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ee823d8 commit c1292c4

File tree

2 files changed

+555
-127
lines changed

2 files changed

+555
-127
lines changed

pkg/authz/authorizers/cedar/core.go

Lines changed: 115 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -754,11 +754,29 @@ func (a *Authorizer) AuthorizeWithJWTClaims(
754754
return false, ErrMissingPrincipal
755755
}
756756

757-
// Extract groups from the resolved claims and pass them into the entity
758-
// factory to build THVGroup parent entities for Cedar evaluation.
757+
// Extract groups from the group claim (or well-known defaults) and the
758+
// role claim, merge, and dedup. Both claim sources map to Cedar THVGroup
759+
// entities. Extraction runs BEFORE preprocessClaims so the raw claim
760+
// values are available.
759761
// The identity pointer is not mutated here because Identity MUST NOT be
760762
// modified after it is placed in the request context (concurrent reads).
761-
groups := extractGroupsFromClaims(resolvedClaims, a.groupClaimName)
763+
groupClaims := extractGroups(resolvedClaims, a.groupClaimName)
764+
if groupClaims == nil {
765+
// Fall back to well-known claim names. This covers two cases:
766+
// 1. No GroupClaimName configured — backward compatible default.
767+
// 2. GroupClaimName configured but absent from the token — the
768+
// documented contract says the custom name takes *priority*
769+
// over defaults, not that it replaces them.
770+
for _, name := range defaultGroupClaimNames {
771+
if groupClaims = extractGroups(resolvedClaims, name); groupClaims != nil {
772+
break
773+
}
774+
}
775+
}
776+
groups := dedup(append(
777+
groupClaims,
778+
extractGroups(resolvedClaims, a.roleClaimName)...,
779+
))
762780

763781
// Preprocess claims and arguments
764782
processedClaims := preprocessClaims(resolvedClaims)
@@ -787,51 +805,109 @@ func (a *Authorizer) AuthorizeWithJWTClaims(
787805
// providers. They are checked in order; the first non-empty match is returned.
788806
//
789807
// Sources:
790-
// - "groups" — Microsoft Entra ID, Okta, Auth0, PingIdentity (the de-facto standard).
791-
// https://learn.microsoft.com/en-us/security/zero-trust/develop/configure-tokens-group-claims-app-roles
792-
// https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/
793-
// - "roles" — Keycloak (when a protocol mapper flattens realm_access.roles to a top-level claim).
794-
// https://www.keycloak.org/docs/latest/authorization_services/index.html
795-
// - "cognito:groups" — AWS Cognito user pools (included in both ID and access tokens).
796-
// https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-the-access-token.html
808+
// - "groups" — Microsoft Entra ID, Okta, Auth0, PingIdentity.
809+
// - "roles" — Keycloak (realm_access.roles flattened to top-level).
810+
// - "cognito:groups" — AWS Cognito user pools.
797811
var defaultGroupClaimNames = []string{"groups", "roles", "cognito:groups"}
798812

799-
// extractGroupsFromClaims looks for group membership claims in the provided
800-
// claims map. It checks customClaimName first (if non-empty), then falls back to
801-
// the well-known names "groups", "roles", and "cognito:groups". Returns the
802-
// string-slice value of the first matching claim key (which may be empty), or nil
803-
// when no group claim key is found.
813+
// resolveNestedClaim resolves a claim value from JWT claims, supporting both
814+
// top-level keys and dot-separated nested paths.
815+
//
816+
// Resolution order:
817+
// 1. Exact top-level match — handles Auth0 / Okta URL-style claim names
818+
// (e.g. "https://myapp.example.com/roles") that contain dots but are
819+
// top-level keys in the JWT.
820+
// 2. Dot-notation traversal — handles Keycloak-style nested claims
821+
// (e.g. "realm_access.roles" → claims["realm_access"]["roles"]).
804822
//
805-
// Passing a non-empty customClaimName allows callers to support IDPs that use
806-
// URI-style claim names (e.g. "https://example.com/groups" used by Auth0/Okta).
807-
func extractGroupsFromClaims(claims map[string]any, customClaimName string) []string {
808-
names := defaultGroupClaimNames
809-
if customClaimName != "" {
810-
// Prepend the custom name so it takes priority over well-known names.
811-
names = append([]string{customClaimName}, defaultGroupClaimNames...)
823+
// Returns nil when the claim is absent or traversal hits a non-map value.
824+
func resolveNestedClaim(claims jwt.MapClaims, path string) interface{} {
825+
if path == "" {
826+
return nil
827+
}
828+
829+
// 1. Exact top-level match (handles Auth0 URL claims with dots).
830+
if val, ok := claims[path]; ok {
831+
return val
832+
}
833+
834+
// 2. Dot-notation traversal.
835+
parts := strings.Split(path, ".")
836+
if len(parts) < 2 {
837+
return nil // single segment already tried above
812838
}
813839

814-
for _, name := range names {
815-
val, ok := claims[name]
840+
var current interface{} = map[string]interface{}(claims)
841+
for _, segment := range parts {
842+
m, ok := current.(map[string]interface{})
816843
if !ok {
817-
continue
844+
return nil
845+
}
846+
current, ok = m[segment]
847+
if !ok {
848+
return nil
818849
}
819-
switch v := val.(type) {
820-
case []interface{}:
821-
groups := make([]string, 0, len(v))
822-
for _, g := range v {
823-
if s, ok := g.(string); ok {
824-
groups = append(groups, s)
825-
}
850+
}
851+
return current
852+
}
853+
854+
// extractGroups extracts group/role names from a specific JWT claim.
855+
// It resolves the claim via resolveNestedClaim (supporting both flat and
856+
// dot-notation paths) and coerces the value to []string.
857+
//
858+
// Return value distinguishes "claim absent" from "claim present but empty"
859+
// so callers can decide whether to fall back to defaults:
860+
// - nil: claimName is empty, the claim is absent, or the value has an
861+
// unsupported scalar/object type (e.g. string, number).
862+
// - non-nil, possibly empty: the claim is an array. Non-string elements
863+
// are silently dropped, so an array of all non-strings yields an empty
864+
// slice (not nil). A genuinely empty array (`[]`) also yields an empty
865+
// slice. Both cases mean "the IdP said this claim exists with no usable
866+
// group names" and suppress fallback.
867+
func extractGroups(claims jwt.MapClaims, claimName string) []string {
868+
if claimName == "" {
869+
return nil
870+
}
871+
872+
val := resolveNestedClaim(claims, claimName)
873+
if val == nil {
874+
return nil
875+
}
876+
877+
switch v := val.(type) {
878+
case []interface{}:
879+
groups := make([]string, 0, len(v))
880+
for _, g := range v {
881+
if s, ok := g.(string); ok {
882+
groups = append(groups, s)
826883
}
827-
return groups
828-
case []string:
829-
return v
830884
}
831-
// Claim key exists but has an unrecognized type; stop searching.
832-
slog.Warn("group claim has unrecognized type, ignoring",
833-
"claim", name, "type", fmt.Sprintf("%T", val))
885+
return groups
886+
case []string:
887+
return v
888+
default:
889+
slog.Warn("group/role claim has unrecognized type, ignoring",
890+
"claim", claimName, "type", fmt.Sprintf("%T", val))
834891
return nil
835892
}
836-
return nil
893+
}
894+
895+
// dedup removes duplicate strings while preserving first-occurrence order.
896+
// Returns nil when the input is nil (not an empty slice) so callers can
897+
// distinguish "no groups" from "empty groups".
898+
func dedup(groups []string) []string {
899+
if groups == nil {
900+
return nil
901+
}
902+
903+
seen := make(map[string]struct{}, len(groups))
904+
result := make([]string, 0, len(groups))
905+
for _, g := range groups {
906+
if _, exists := seen[g]; exists {
907+
continue
908+
}
909+
seen[g] = struct{}{}
910+
result = append(result, g)
911+
}
912+
return result
837913
}

0 commit comments

Comments
 (0)