Skip to content

Let Cedar read user claims the upstream asserts only in its ID token - #6054

Open
JAORMX wants to merge 2 commits into
mainfrom
feat-cedar-id-token-group-claims-6049
Open

Let Cedar read user claims the upstream asserts only in its ID token#6054
JAORMX wants to merge 2 commits into
mainfrom
feat-cedar-id-token-group-claims-6049

Conversation

@JAORMX

@JAORMX JAORMX commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

Cedar read group and role claims from the pinned upstream's access token only, so a
provider that asserts group membership in its id_token produced an empty group set
and every principal in THVGroup::"..." policy denied for every user, with no
diagnostic beyond the has guard failing. Same shape as #5916 — a claim the upstream
does assert, in a token ToolHive already holds, that the authorizer never looked at.

profileClaimsFromIDToken = {name, email} is replaced by
idTokenClaimsNeverSupplemented, a closed spec-derived deny-list; every other claim
the pinned provider's id_token asserts is now admitted, still fill-only (the access
token always wins).

Fixes #6049

Two things need a maintainer decision rather than my say-so — please read
"Special notes" before approving:
this is wider than the issue scoped (an allow-list
became a deny-list), and it makes group-revocation latency session-scoped for
id_token-sourced groups.

Type of change

  • Bug fix
  • New feature
  • Refactoring (no behavior change)
  • Dependency update
  • Documentation
  • Other (describe):

Test plan

  • Unit tests (task test)
  • Linting (task lint-fix)
  • E2E tests (task test-e2e)

task lint-fix 0 issues; codespell clean over all 5 changed files;
go test -race -count=1 ./pkg/authz/... ./pkg/auth/... green; task test shows only
three failures that are pre-existing sandbox limitations (pkg/api
TestNewServer_ReadTimeoutConfigured and TestSecurityHeaders — no container runtime;
pkg/secrets/keyring TestCompositeProvider_RealProviders — no keyring).

Two negative controls, because tests for a change that grants access have to prove
the grant rather than describe it:

  • Reverting the deny-list to the old {name, email} semantics fails 8 subtests,
    including both grant paths. Critically, the three security invariants pass
    identically in both configurations — so they bound the boundary rather than
    merely exercising the new code: toolhive_groups_ignored_when_upstream_configured,
    direct_groups_ignored_when_upstream_configured,
    id_token_registered_claims_are_never_filled_or_overwritten, and
    TestAuthorizeWithJWTClaims_PrincipalStaysUpstreamSourced.
  • Deleting the single "scope": {}, "scp": {} deny-list line fails exactly three
    subtests, so that exclusion is load-bearing rather than decorative.

Does this introduce a user-facing change?

Yes — it grants access that is currently denied. Only where primaryUpstreamProvider
is set, the pinned provider has a stored id_token, and the claim is one its access
token omits entirely
:

  1. Requests denied by a THVGroup/GroupEntityType policy because the provider
    asserts groups in the id_token only — tools/list goes from empty to populated,
    tools/call from 403 to 200. The widest case, and the reported one.
  2. The same for RoleClaimName, cognito:groups, and URI-style group claims.
  3. has-guarded gates on hd, email_verified, acr/amr/auth_time and custom
    user claims the provider puts in the id_token only.

Unchanged: primaryUpstreamProvider empty; the opaque-token branch; any claim the
access token already asserts (fill-only); providers with no stored id_token; group,
role and scope claims on the ToolHive-issued token (still grant nothing); the
principal; and the 15 token-describing claims. One direction can tighten rather than
loosen — a forbid/unless policy keyed on a claim being absent would now deny
where the id_token supplies it; no realistic example was constructible, since unless
gates in this codebase test presence, but it belongs in the release note.

Special notes for reviewers

1. The scope expansion — why an allow-list was replaced rather than extended. An
allow-list provably cannot work here: the claim carrying group membership is named by
the deployment (GroupClaimName/RoleClaimName) or by the IdP (Auth0/Okta URI-style
names), so any fixed list denies silently for names ToolHive does not choose — the
defect shape of both #5916 and #6049. Config-derived would cover the two configured
names and still miss the well-known defaults' relatives, hd, and per-tenant
organization claims, guaranteeing a third round.

The shorter argument for why a deny-list is safe: resolveClaims already admits
every claim of the upstream access token with no allow-list at all. An allow-list
applied to the id_token alone was therefore an asymmetry between two tokens at the
same trust level from the same provider — not a boundary being held. Inverting restores
parity rather than widening a trust boundary.

2. Precedence is fill-only, deliberately not union. Union looked attractive for
set-valued claims and is unsafe in the direction that matters: per-application claim
filtering (Okta group filters, Entra group-claim config) is applied when a token is
minted for a given audience, so the access token's group list is the provider's answer
to "which of this user's groups are relevant to this resource server". Unioning the
id_token's list — minted for ToolHive's own auth-server client — would restore groups
the provider deliberately withheld from this audience. Fill-only can only ever supply
a claim the provider left entirely absent; it can never contradict or extend one.

3. scope/scp are excluded, and this is the one place parity does not hold.
Parity between the two tokens holds for claims about the user; a claim asserting
what a token may do is not symmetric between them. Concretely, granular and
incremental consent (Google granular permissions, Entra incremental consent) make
requested-minus-granted the normal case, and an id_token is minted from the
authorization request: client asks for Mail.ReadWrite, user grants only
Mail.Read, the access token carries the narrower grant, and supplementing would let
a claimset_scp.containsAny("Mail.ReadWrite") gate match authority the user
explicitly declined. Note this reverses an earlier position in this series — a prior
test named scope alongside groups as authorization-bearing — so it is a deliberate
change of stance, and TestAuthorizeWithJWTClaims_ScopeStaysAccessTokenSourced
includes the downscoped-consent case.

4. Group revocation latency becomes session-scoped for id_token-sourced groups —
this is the trade-off to weigh.
Access tokens are refreshed, so a group revoked
upstream propagates within an access-token lifetime. A stored id_token is replaced only
when a refresh happens to rotate one, and is deliberately used past exp (see
Identity.UpstreamIDTokens), so a revoked group can keep granting until the session
ends — refreshTokenLifespan, 7 days by default. Enforcing exp is not the remedy: it
would flip permit→deny mid-session for every user, not only the revoked one. The
remedies are to assert groups in the access token, or shorten refreshTokenLifespan.
The affected deployments currently deny everything, so this is
broken→works-with-stale-groups rather than a regression — but it is a real change in
security posture and the call is yours. It is a ⚠️ callout in the operator guide rather
than prose.

This also corrects a claim I wrote in #6036: Identity.UpstreamIDTokens said the
id_token's claims are "no staler than the alternative, since the profile claims
mirrored into the ToolHive-issued token are likewise captured once at login". True for
email; false for groups, because there is no issued-token alternative for those.

Interaction with #6052 (issue #6048): both touch resolveClaims, but the only edit
here is its doc comment — the !looksLikeJWT branch body is byte-identical to main —
so whichever lands second needs a comment-block rebase only. If #6052 lands first, its
opaque branch inherits this deny-list automatically, which is the intended coupling:
both branches widen together as one release decision.

Generated with Claude Code

@github-actions github-actions Bot added the size/L Large PR: 600-999 lines changed label Jul 27, 2026
@codecov

codecov Bot commented Jul 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.29%. Comparing base (33023da) to head (82c4f57).
⚠️ Report is 6 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #6054      +/-   ##
==========================================
+ Coverage   72.26%   72.29%   +0.03%     
==========================================
  Files         722      724       +2     
  Lines       75138    75346     +208     
==========================================
+ Hits        54296    54473     +177     
+ Misses      16980    16972       -8     
- Partials     3862     3901      +39     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

JAORMX and others added 2 commits July 28, 2026 10:15
Cedar read group and role claims from the pinned upstream's access token
only, so a provider that asserts group membership in its id_token — common
where groups are a profile-scope claim — produced an empty group set and
every `principal in THVGroup::"..."` policy denied for every user.

#6022 fixed the same shape for `email` but deliberately left groups out,
because widening the supplement newly grants requests that deny. This is
that change. Provenance is what makes it safe: since #6036 the supplement
source is the same provider's id_token, so a group read from it is asserted
by the same IdP that issued the access token.

The supplement is inverted from an allow-list of {name, email} to a
deny-list of claims that describe the id_token rather than the user
(registered/OIDC token claims) plus `sub`, which is the Cedar principal.
An allow-list cannot work here: the group claim's name is chosen by the
deployment via GroupClaimName/RoleClaimName or by the IdP as a URI-style
name, so any fixed list denies silently for names ToolHive does not choose
— the defect shape of #5916 and #6049 both. Inverting also settles `hd`
and custom namespaced claims in the same pass.

Precedence is unchanged and stays fill-only rather than union: an access
token asserting a partial group list keeps exactly that list, because
per-audience claim filtering means that list is the provider's answer for
this resource server.

The ToolHive-issued token is still never a claim source, so a group on it
still grants nothing.

Fixes #6049

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
Group and role claims are facts about the user, so either of the pinned
provider's tokens may report them. `scope`/`scp` are not: they assert the
authority a particular token carries, and the only authority at issue is
the presented access token's. Where a provider emits `scope` in an id_token
at all — non-standard — it may echo what the client requested rather than
what the user granted, and granular consent makes those differ routinely,
so supplementing it could satisfy a scope gate on authority the access
token does not carry.

Excluding them by name is effective rather than cosmetic, because unlike a
group claim name these are standardised (RFC 8693/9068 `scope`, Entra
`scp`). The argument that retired the allow-list for groups does not
transfer.

Also records the shortest form of the deny-list argument: the access-token
branch already admits every claim with no allow-list, so an allow-list on
the id_token alone was an asymmetry between two tokens at the same trust
level, and inverting it restores parity rather than widening a boundary.

Promotes the group-revocation window to a callout in the operator guide.
Groups sourced from an id_token are login-time facts, so revocation may not
propagate until the session ends (7d default) rather than within an
access-token lifetime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
@JAORMX
JAORMX force-pushed the feat-cedar-id-token-group-claims-6049 branch from 4485b74 to 82c4f57 Compare July 28, 2026 07:15
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/L Large PR: 600-999 lines changed labels Jul 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L Large PR: 600-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cedar cannot see group claims that the upstream asserts only in its id_token

1 participant