Skip to content

fix(provider): preserve non-standard claims in generic OIDC parser#2504

Open
mastermanas805 wants to merge 1 commit intosupabase:masterfrom
mastermanas805:fix/parse-generic-oidc-preserves-custom-claims
Open

fix(provider): preserve non-standard claims in generic OIDC parser#2504
mastermanas805 wants to merge 1 commit intosupabase:masterfrom
mastermanas805:fix/parse-generic-oidc-preserves-custom-claims

Conversation

@mastermanas805
Copy link
Copy Markdown

What

Fixes #2494.

parseGenericIDToken (internal/api/provider/oidc.go) unmarshals the ID token into the strongly-typed Claims struct, which has no catch-all field. JSON unmarshal silently drops every key that is not declared as a struct field. For Zitadel, that means the role claims emitted under URN-namespaced keys (urn:zitadel:iam:org:project:roles, urn:zitadel:iam:org:project:<projectId>:roles) never reach data.Metadata.CustomClaims, never get serialized into raw_user_meta_data, and never appear in the session returned by supabase.auth.getSession().

The same path also drops standard OIDC claims that custom providers commonly emit (amr, auth_time, azp, sid, at_hash) for any issuer that does not match one of the named cases in ParseIDToken.

How

Mirror the two-pass pattern parseAzureIDToken already uses (oidc.go:329-365):

  1. Parse the token into the typed Claims struct so OIDC standard fields land in their typed homes.
  2. Re-parse the token into a map[string]interface{} to capture every key the JWT carries.
  3. Strip the registered JWT claims and the OIDC claims that already have explicit fields on Claims.
  4. Retain whatever remains in data.Metadata.CustomClaims.

This is exactly what Azure and Apple parsers do today; the generic path was missing it.

Empirical evidence (Zitadel ID token before vs after)

Reproduced locally with supabase/auth running against a fresh Zitadel instance and a custom OIDC provider configured for it.

Raw claims Zitadel emits (18 keys):

amr, at_hash, aud, auth_time, azp, client_id, email, email_verified,
exp, family_name, given_name, iat, iss, locale, name, preferred_username,
sid, sub, updated_at,
urn:zitadel:iam:org:project:roles,
urn:zitadel:iam:org:project:370187906414804997:roles

data.Metadata after the strongly-typed unmarshal — before this fix:

iss, sub, aud, iat, exp, name, family_name, given_name,
preferred_username, updated_at, email, email_verified

6 standard OIDC claims and both URN role claims dropped.

data.Metadata.CustomClaims after this fix:

{
  "urn:zitadel:iam:org:project:roles": {"admin": {"<orgId>": "<orgDomain>"}},
  "urn:zitadel:iam:org:project:<projectId>:roles": {"admin": {"<orgId>": "<orgDomain>"}}
}

After running the OAuth flow end to end, auth.users.raw_user_meta_data.custom_claims now contains both URN keys, which is what getSession() returns to the client.

Tests

Adds internal/api/provider/oidc_zitadel_test.go with TestParseGenericIDTokenPreservesURNNamespacedClaims. The test:

  • Generates an in-memory RSA keypair and signs a Zitadel-shaped ID token containing both URN role variants plus standard OIDC claims.
  • Spins up an httptest.Server exposing /.well-known/openid-configuration and /jwks so oidc.NewProvider can perform real discovery.
  • Runs the token through ParseIDToken, asserts both URN claims are preserved in CustomClaims, and asserts standard claims (iss, sub, email, etc.) do not leak into CustomClaims.

This is a self-contained alternative to the captured real tokens used elsewhere in oidc_test.go, which avoids the key-rotation flakiness already flagged in that file.

make test passes locally; gofmt -l is clean on touched files; go vet ./... is clean.

Notes for review

  • No new provider added. The CONTRIBUTING.md note about not accepting new OAuth provider contributions does not apply — this fixes claim handling in the existing CustomOIDCProvider path.
  • Behavior change implication. Generic OIDC providers will now flow non-standard claims through user_metadata.custom_claims and into the JWT access token via tokens/service.go. This is consistent with the existing Apple and Azure behaviors, not a new failure surface.
  • Out of scope but related. The named provider parsers (parseGoogleIDToken, parseLinkedinIDToken, parseKakaoIDToken, parseFacebookIDToken, parseSnapchatIDToken, parseVercelMarketplaceIDToken) drop unknown claims the same way. The same fix pattern would apply, but each is a separate semantic change for that provider's user base. Happy to follow up in separate PRs if you'd like.

parseGenericIDToken unmarshalled ID tokens into the strongly-typed
Claims struct with no catch-all, silently dropping every claim that
was not declared as a struct field. Zitadel emits role data under
URN-namespaced keys ("urn:zitadel:iam:org:project:roles") which
disappeared before reaching the user's session, the symptom reported
in supabase#2494. The same path also dropped standard OIDC claims like amr,
auth_time, azp, sid, and at_hash for any custom OIDC provider whose
issuer was not one of the named cases.

Mirror the two-pass pattern parseAzureIDToken already uses: parse the
token into the typed Claims struct, then re-parse into a
map[string]interface{}, strip the registered JWT claims and the OIDC
standard claims that already have explicit fields on Claims, and
retain whatever remains in CustomClaims. The kept values flow through
the existing structs.Map serialization into raw_user_meta_data.custom_claims
and from there into the JWT access token, matching the pre-existing
Apple and Azure behaviors.

Adds an in-memory OIDC provider regression test that signs a
Zitadel-shaped ID token (both project-scoped and unscoped URN role
claims) and asserts the URN keys survive in CustomClaims while the
standard claims live only in their typed fields.

Fixes supabase#2494

Signed-off-by: Manas Srivastava <mastermanas805@gmail.com>
@mastermanas805 mastermanas805 requested a review from a team as a code owner April 25, 2026 20:15
@mastermanas805
Copy link
Copy Markdown
Author

@fadymak requesting your review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Roles in ID token from Zitadel is removed

1 participant