Source Cedar claims from the upstream ID token for opaque access tokens - #6052
Open
JAORMX wants to merge 2 commits into
Open
Source Cedar claims from the upstream ID token for opaque access tokens#6052JAORMX wants to merge 2 commits into
JAORMX wants to merge 2 commits into
Conversation
JAORMX
requested review from
ChrisJBurns,
blkt,
jerm-dro,
jhrozek,
rdimitrov,
reyortiz3 and
tgrunnagle
as code owners
July 27, 2026 19:39
9 tasks
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6052 +/- ##
==========================================
+ Coverage 72.26% 72.27% +0.01%
==========================================
Files 722 724 +2
Lines 75138 75367 +229
==========================================
+ Hits 54296 54474 +178
- Misses 16980 17010 +30
- Partials 3862 3883 +21 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
11 tasks
With the embedded auth server active, Cedar resolved claims from two sources depending on an accident of the pinned upstream's access token format. A JWT access token gave claims from that upstream, so the Cedar principal was the upstream subject. An opaque one (Google's ya29.*, GitHub's gho_*) hit the #5147 fallback and gave the claims of the token ToolHive itself issued, so the principal became ToolHive's internal user UUID and `forbid(principal == Client::"okta|alice", ...)` silently stopped matching. In a multi-upstream chain the same fallback attributed the FIRST configured upstream's profile to the pinned provider, since that is whose name/email the auth server mirrors. Read the pinned provider's own id_token on that branch instead. It supplies the principal `sub` plus the profileClaimsFromIDToken allowlist that already governs the JWT path, so both paths now admit the same claim names from the same provider and session, and widening the allowlist widens them together. Claims outside it stay out: an id_token's iss/aud/nonce describe the token rather than the user, and admitting more here than on the JWT path would recreate the inconsistency being removed. `sub` is not being supplemented in the sense the JWT path forbids: an opaque token asserts nothing, so there is no access-token `sub` to override, and an id_token that omits `sub` still fails closed with ErrMissingPrincipal rather than borrowing the auth server's subject. `exp` is not enforced, as for the existing supplement — the token records what the upstream asserted at login, and rejecting it once expired would flip a policy from permit to deny mid-session. A pure OAuth 2.0 upstream never asked for the `openid` scope has no id_token at all (GitHub OAuth apps are the common case) and no other record of that provider's subject exists in the identity. For that configuration alone the ToolHive-issued token's claims are still used, which is what shipped before: failing closed would deny every request on those deployments with nothing an operator could configure to recover, so the weaker attribution is the lesser harm. A rate-limited WARN names the provider and the consequence. Fixes #6048 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
Narrowing the opaque branch to the pinned provider's id_token also drops the claims only the ToolHive-issued token carried: `client_id` (session.New) plus fosite's `iss`/`aud`/`exp`/`iat`/`jti`. A policy gating tool access on `claim_client_id` therefore stops matching, which an operator needs told rather than left to discover. This removes an asymmetry rather than creating one: the JWT branch never exposed those claims either, since this auth server's client_id appears in neither the upstream access token nor its id_token, and a `client_id` an upstream access token does assert is the auth server's own registration at that upstream rather than the calling MCP client. It also fails toward deny, as `principal has claim_client_id` becomes false. State it in the resolveClaims contract and in the operator guide's upgrade note, and pin it with a case asserting `client_id` reaches Cedar from neither token. The unit test's ToolHive-issued claim set now matches what claimsToIdentity actually produces — `client_id` present, `tsid` filtered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5WXPQr5Da9Cox2nZWhg6n
JAORMX
force-pushed
the
fix-cedar-opaque-claim-source-6048
branch
from
July 28, 2026 07:14
4a99b16 to
8a1e5a1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Cedar resolved policy claims along two different paths depending on whether the
pinned upstream's access token happened to be JWT-shaped, and the two disagreed
about both the claim source and the principal. The JWT branch (fixed in #6022/#6036)
reads that provider's access token plus its
id_token; the opaque branch — the!looksLikeJWTfallback from #5147 — read the ToolHive-issued token instead.Two silent consequences, both fixed here:
subis ToolHive's internal user ID (aUserResolverUUID), soforbid(principal == Client::"okta|alice", ...)matched with a JWT-issuing upstreamand silently did not with an opaque-token one. Cedar has no
has-style guard inthe principal position, so there was no diagnostic.
server mirrors into its own token always come from the first configured upstream
(
validateChainpinschain[0]toupstreams[0]; only the first leg resolvesidentity), which need not be the provider
primaryUpstreamProvidernames.The opaque branch now reads the pinned provider's
id_token(
identity.UpstreamIDTokens[provider]), admittingsubplus the sameprofileClaimsFromIDTokenallowlist the JWT branch uses — so both paths carry thatprovider's provenance and the principal is the upstream subject either way.
Fixes #6048
Type of change
Test plan
task test)task lint-fix)task test-e2e)New:
TestResolveClaimsOpaqueUpstreamToken(id_token supplies principal + profile;claims outside the allowlist not admitted; expired id_token still used; id_token
without
subleaves the principal absent; no id_token and unparsable id_token bothfall back) and
TestAuthorizeWithJWTClaims_OpaqueUpstreamTokenPrincipal(end to end:a banned upstream subject is forbidden, i.e. the principal really is the upstream
subject; an id_token without
emaildenies rather than using the AS-issued value).Plus
toolhive_groups_ignored_when_upstream_token_is_opaque, extending the existingissued-token invariant to the newly-changed branch.
All pinned security tests pass unchanged:
authorization_bearing_claims_are_not_filled,toolhive_groups_ignored_when_upstream_configured,direct_groups_ignored_when_upstream_configured,TestAuthorizeWithJWTClaims_PrincipalStaysUpstreamSourced,tampered_upstream_jwt_still_denies.task lint-fix0 issues; codespell clean;go test -race ./pkg/authz/... ./pkg/auth/...clean;
task testshows only three failures that are pre-existing sandbox limitations(
pkg/apiTestNewServer_ReadTimeoutConfiguredandTestSecurityHeaders— nocontainer runtime;
pkg/secrets/keyringTestCompositeProvider_RealProviders— nokeyring).
Does this introduce a user-facing change?
Yes, and it is a behaviour change on a shipped path — please read the three points
below before merging.
1. The Cedar principal changes on opaque-token deployments. It becomes
Client::"<upstream-subject>"instead ofClient::"<ToolHive internal UUID>". Anypolicy keyed on the UUID form stops matching. That is unlikely to be deliberate —
nobody sensibly writes a policy against a random UUID — but it is a real migration
consideration.
2. This does NOT fix every opaque-token upstream, and a release note saying
"Google and GitHub are fixed" would be half wrong. The fix needs a stored
id_token:ya29.*access token, has an id_token) — fixed.type: oauth2(opaquegho_*, never an id_token) — keeps the oldbehaviour permanently.
Identitycarries no record of an OAuth-2.0-only provider'supstream subject at all:
Subject/PlatformUserIDare the internal user ID,Name/Emailare the first upstream's mirror,Groupsis never populated bymiddleware, and
UpstreamIDTokenshas no entry. The subject exists in storage asUpstreamSubjectbut is consumed only inside the auth server and never projectedonto
Identity. Making that case work needs that projection, which is a widerchange to
pkg/authand is filed separately.So the no-id_token case is not an edge case — it is the GitHub-OAuth-app
configuration in our own operator guide's multi-upstream example. It therefore keeps
the
identity.Claimsfallback rather than failing closed: failing closed would denyevery request on those deployments with no configuration an operator could change to
recover, recreating exactly the #5916 deny-all trap #6022 removed. A rate-limited
WARN names the provider and the consequence, and the documented remedy is to request
the
openidscope for that upstream.3.
claim_client_id,claim_iss,claim_audandclaim_expare no longervisible to policies on the opaque path. The old path handed Cedar the whole
AS-issued claim set —
client_idfromsession.New, plus fosite'siss/aud/exp/iat/jti. A rule referencingclaim_client_idsilently stops matching. Thisremoves an asymmetry rather than creating one: the JWT branch never surfaced the
AS-issued
client_ideither. Worth knowing it was arguably a trap before — anRFC 9068 upstream access token can carry its own
client_id, which is the authserver's client registration at the upstream, not the calling MCP client, so on the
JWT branch a
claim_client_id == "vscode"rule could find a different value underthe same name. The opaque path was the only place that claim meant what a policy
author would assume. It fails toward deny (
principal has claim_client_idbecomesfalse), and the guide's upgrade note points at gating on the upstream subject or a
claim the upstream asserts instead.
Special notes for reviewers
Why
subis taken from the id_token here when #6036 forbids supplementing it.#6036's rule is that
submust never be supplemented — it becomes the Cedarprincipal entity ID, and Cedar offers no
has-guard there, so a missing access-tokensubmust fail closed. On this branch there is no access-tokensubto override:the claim set is replaced, not supplemented, and the source is the pinned
provider's own id_token, which is the correct provenance and the whole point of the
issue. An id_token that omits
subleaves the key absent, soAuthorizeWithJWTClaimsstill fails closed withErrMissingPrincipal— pinned by atest.
Why only
sub+profileClaimsFromIDToken, not the whole id_token body. Keepingthe same allowlist on both branches means #6049 (group claims from the id_token)
widens them together as one deliberate release decision, and nothing this PR does
grants access that #6049 is meant to decide. Taking the whole body would newly admit
groups/scopeon the opaque path only — an inconsistency in the opposite directionfrom the one this PR removes.
Interaction with #6049. That branch also touches
resolveClaims, but only itsdoc comment; the
!looksLikeJWTbranch body is untouched there, so whichever landssecond needs a comment-block rebase only.
Generated with Claude Code