Skip to content

frontegg-auth: resolve the group claim path against all JWT claims - #38410

Merged
jubrad merged 1 commit into
MaterializeInc:mainfrom
jubrad:justin/frontegg-jwt-group-claim-typed-fields
Aug 21, 2026
Merged

jubrad merged 1 commit into
MaterializeInc:mainfrom
jubrad:justin/frontegg-jwt-group-claim-typed-fields

Conversation

@jubrad

@jubrad jubrad commented Aug 21, 2026

Copy link
Copy Markdown
Member

Motivation

oidc_group_claim selects which JWT claim group-to-role sync reads group names from. On the Frontegg authenticator, that lookup ran against Claims::unknown_claims, the #[serde(flatten)] catch-all:

pub fn groups(&self, claim_path: &str) -> Option<Vec<String>> {
    mz_auth::group_claims::extract_groups(&self.unknown_claims, claim_path)
}

Serde's flatten collects only the keys that no named field claimed. Claims declares ten named fields, so sub, exp, iss, type, email, userId, tenantId, roles, permissions, and metadata never appear in that map and could not be selected as the group claim at all, however oidc_group_claim was set.

roles is the claim an operator is most likely to reach for. Frontegg emits it natively and populates it from group-to-role mappings, so mapping IdP groups to Frontegg roles and syncing those role names is the obvious configuration, and it needs no custom claim, no JWT prehook, and no group cache. Today that setting is accepted and then silently finds nothing: extraction returns None, which maybe_sync_jwt_groups treats as "claim absent, skip sync". That branch logs nothing and sends no client notice, so there is no signal anywhere that the configured claim is unreachable.

The OIDC authenticator does not have this problem. OidcClaims types only iss, exp, iat, and aud, so every other claim lands in its flatten map and resolves normally, roles included. The same oidc_group_claim value therefore selected different claims depending on the authenticator, which defeats the purpose of routing both through the shared mz_auth::group_claims extractor.

Change

Resolve the claim path against a JSON view of the whole Claims struct rather than the flatten map alone. Claims already derives Serialize with rename_all = "camelCase", so the reconstructed keys match the wire format.

The typed fields are left in place, is_admin reads roles directly. Nested untyped paths such as customClaims.groups keep resolving exactly as before. roles becomes reachable generically, with no special case for it.

Tests

Adds groups_from_typed_roles_claim to the frontegg-auth unit tests, covering selection of a claim backed by a typed field. The existing groups_absent_returns_none, groups_empty_returns_some_empty, and groups_populated_returns_some_values cases continue to cover the untyped paths.

Release notes

This release will allow oidc_group_claim to select any claim present in the JWT when using Frontegg authentication, including roles. Previously, claims that Materialize parses into typed fields, such as roles and permissions, could not be used as the group claim, and configuring one caused group-to-role sync to be skipped without any error or notice.

`oidc_group_claim` selects the JWT claim that group-to-role sync reads
group names from. On the Frontegg authenticator that lookup ran against
`Claims::unknown_claims`, the `#[serde(flatten)]` catch-all. Serde's
flatten collects only the keys that no named field claimed, so the ten
claims backed by a typed field on `Claims`, `roles` and `permissions`
among them, never appear in that map and could not be selected at all.

Setting `oidc_group_claim = 'roles'` was therefore accepted and then
silently found nothing: extraction returned `None`, which
`maybe_sync_jwt_groups` treats as "claim absent, skip sync", a branch
that logs nothing and sends no notice. An operator gets no signal that
the claim they chose is unreachable.

The OIDC authenticator does not have this problem. `OidcClaims` types
only `iss`, `exp`, `iat`, and `aud`, so every other claim lands in its
flatten map and resolves normally. The same `oidc_group_claim` value
consequently selected different claims under the two authenticators,
which defeats the purpose of the shared `mz_auth::group_claims`
extractor.

Resolve the path against a JSON view of the whole `Claims` struct
instead. The typed fields stay as they are, since `is_admin` reads
`roles` directly, and nested paths such as `customClaims.groups` keep
working. `roles` is now reachable generically rather than by a special
case.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@jubrad
jubrad requested a review from mtabebe August 21, 2026 19:20
@jubrad
jubrad marked this pull request as ready for review August 21, 2026 19:20
@jubrad
jubrad requested a review from a team as a code owner August 21, 2026 19:20
@jubrad
jubrad merged commit 6d8b3db into MaterializeInc:main Aug 21, 2026
84 checks passed
@jubrad
jubrad deleted the justin/frontegg-jwt-group-claim-typed-fields branch August 21, 2026 22:49
@bosconi bosconi mentioned this pull request Oct 3, 2026
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.

2 participants