Fix contradictory upstream ID token exp contract - #6036
Merged
Conversation
Follow-up to #6022, addressing the three non-blocking points raised in its approval. Comments and docs only; no logic changes. The exp contract on stored ID tokens said callers MUST check `exp` before use, but #6022 made the Cedar authorizer a consumer that deliberately does not: it reads login-time identity claims rather than presenting a credential, and rejecting an expired token would flip a policy from permit to deny mid-session. Four sites stated the strict rule, and the strictest was UpstreamCredential — the doc the other three tell readers to consult, so following a cross-reference landed on the version contradicting where you came from. All four now distinguish presenting the token from reading its claims. The TODO in upstreamtoken/service.go asserted that exp enforcement belonged at the RFC 8693 consumer. That work does not exist: for claims-readers enforcement is affirmatively unwanted, and for the credential path the obliged party is the recipient IdP, so a presenter's local pre-check is a latency and error-message optimisation rather than a security control — skipping it cannot cause an expired assertion to be accepted. Replaced with a statement of that design, including what a future consumer presenting to a non-validating sink must do, since "neither consumer" is only true as a census of today's two readers. Stop spelling the Cedar profile-claim names with the auth server's constants. What this code reads is an upstream provider's id_token, where `name` and `email` are OIDC Core 5.1 wire names fixed by the spec, so anchoring them to a ToolHive constant inverted the dependency: renaming that constant would have changed which claim Cedar reads out of a third party's token. The comment also promised that a rename on either side would be a compile error, which was never true — upstream/oidc.go extracts these claims by hardcoded json tags, not via those constants, so the extraction side was never covered. Note this does not shrink the dependency tree: fosite reaches the authorizer through pkg/auth regardless, and the session package contributed a strict subset. Also trim the resolveClaims doc comment to its conclusions, keeping the widening-hazard bullets, and restore the provenance mechanism the earlier trim dropped with a pointer to where the credential bundle is split. Document the expired-id_token behaviour in the operator guide, which was the only user-visible consequence of #6022 living solely in Go comments.
JAORMX
requested review from
ChrisJBurns,
blkt,
jerm-dro,
jhrozek,
rdimitrov,
reyortiz3 and
tgrunnagle
as code owners
July 27, 2026 17:40
ChrisJBurns
approved these changes
Jul 27, 2026
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6036 +/- ##
==========================================
+ Coverage 72.16% 72.19% +0.02%
==========================================
Files 721 721
Lines 75069 75069
==========================================
+ Hits 54174 54193 +19
+ Misses 17033 17013 -20
- Partials 3862 3863 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Jul 27, 2026
JAORMX
added a commit
that referenced
this pull request
Jul 28, 2026
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
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
Follow-up to #6022, addressing the three non-blocking points @jhrozek raised in that
PR's approval. Comment and documentation only — no logic changes, no behaviour
change. Investigating them turned up two further instances of the same defect and
one overstated claim, so the diff is slightly wider than the three items; details
below.
profileClaimsFromIDTokenspelled
name/emailwithsession.NameClaimKey/session.EmailClaimKey. Whatthis code reads is an upstream provider's
id_token, where those are OIDC Core§5.1 wire names fixed by the spec — not names ToolHive chooses. Replaced with
local
oidcNameClaim/oidcEmailClaimconsts and dropped thepkg/authserver/server/sessionimport.expcontract no longer contradicts its own consumers. The docs saidcallers "MUST check
exp" before using a stored id_token, while the Cedarauthorizer is now a consumer that deliberately does not. Rewritten to distinguish
presenting the token as a credential from reading its claims, at all four
sites plus the stale TODO (see below).
resolveClaims' doc comment trimmed from ~60 to ~45 lines, keeping thewidening-hazard bullets and pointing at
Identity.UpstreamIDTokensfor theexprationale instead of restating it.
id_tokenis used even after itexpires, and why — the one user-visible consequence of Fix Cedar deny-all when upstream JWT lacks identity claims #6022 that lived only in Go
comments.
Type of change
Test plan
task test)task lint-fix)task test-e2e)No tests added or changed: the diff is comment/doc-only, so there is no new
behaviour to pin. Verification was hash-bracketed against the exact committed bytes
—
task lint-fix0 issues, codespell clean over all 5 changed files,task license-checkpass, andgo test -raceclean over./pkg/authz/... ./pkg/auth/....task testreproduces three failures that are pre-existing limitations of mysandbox and unrelated to this change:
pkg/apiTestNewServer_ReadTimeoutConfiguredand
TestSecurityHeaders(no container runtime — "no available runtime found"), andpkg/secrets/keyringTestCompositeProvider_RealProviders(no keyring). Each failson the environment rather than on any assertion about behaviour, and all three fail
identically on
origin/maincontent in a clean worktree with none of this changepresent.
One further failure, disclosed rather than filtered: an earlier suite run also
produced
pkg/vmcp/serverTestForwarding_Progress_RealBackend— a 60s contexttimeout waiting for a
notifications/progressmessage, at 1m1.1s. It did notreproduce, and is not attributable to this change:
task test, run against exactly these committed bytes (the five filehashes verified identical before and after the run), passed that test — the only
failures were the three environmental ones above. That is the strongest evidence
here because it reproduces the failing configuration: same full parallel
-racesuite, same box, same test. The isolated runs below could be waved away as "of
course it passes alone"; this one cannot.
jobs of mine. So the environment was demonstrably failing at the time — independent
of this test's own behaviour — and on a box that starves processes badly enough to
reap them, any deadline can be missed.
Its body normally takes 30.03s (measured with
go test -v; teardown is a further21ms) against a 60s
context.WithTimeout.-raceadds only ~1.0s, so the 30s isa fixed wait inside the body rather than CPU-bound work — leaving roughly 30s of
slack, which a full parallel suite on a starving box will consume. Observed at
1m1.1s, i.e. a ~2× overrun, which is what a missed deadline looks like rather than a
hang.
with and without
-race, comparingorigin/maincontent in a separate worktreeagainst this branch. Timings 30.039s–31.103s; the largest branch-minus-main
difference in either configuration is ~4ms. If this change had slowed that relay,
that is not what the numbers would look like.
import removal, a two-const block, and a
varreferencing it), and the new constvalues are byte-identical to the constants they replace. The one mechanism by which
removing an import can change runtime behaviour — losing a package's
init()— doesnot apply:
pkg/authserver/server/sessionis still in the authorizer's transitivedependency tree via
pkg/auth, so nothing left the build, and that package has noinit()regardless.CI is a better arbiter than my sandbox for a real-backend integration test needing a
quiet machine, and it passed that test on e837d69.
Changes
pkg/authz/authorizers/cedar/core.gosessionimport dropped;resolveClaimsdoc trimmed; provenance mechanism restored with code pointerpkg/auth/identity.goIdentity.UpstreamIDTokens: theexpcontract rewritten around present-as-credential vs read-its-claimspkg/auth/upstreamtoken/types.goUpstreamCredential.IDToken,TokenReader.GetAllUpstreamCredentials,Service.GetValidTokenspkg/auth/upstreamtoken/service.goGetAllUpstreamCredentials; replaced a staleTODO(auth)(see notes)docs/operator/virtualmcpserver-kubernetes-guide.mdid_tokenbehaviour documented for operatorsDoes this introduce a user-facing change?
No. Comments and one operator-guide paragraph; no code paths altered.
Special notes for reviewers
Two corrections to the reasoning in #6022's review, both measured rather than
argued. I'd have repeated both as fact otherwise:
sessionimport removes zero packages. The review estimated itpulls ~13 packages in to spell two literals. Measured directly on both sides —
go list -deps ./pkg/authz/authorizers/cedar | grep -c fositereturns 8 onorigin/maincontent and 8 on this branch (the worktree used for the "before"reading does still carry the
sessionimport, confirming it is pre-changecontent). Fosite reaches the package via
pkg/auth, which cedar must import forauth.Identity, andsessioncontributed a strict subset of 7. So thejustification is dependency direction only, not dependency count.
"a rename on either side is a compile error". But
pkg/authserver/upstream/oidc.goextracts these claims via hardcoded json tags (
json:"name",json:"email"), notvia the
sessionconstants — so a rename on the extraction side would never havebeen caught. That makes this item a correctness fix rather than a matter of taste.
I replaced a pre-existing
TODO(auth), which is the biggest liberty in this diff.service.gopreviously read: "the 'check exp' contract on UpstreamCredential.IDTokenis documented but not enforced here. Enforcement belongs at the RFC 8693
token-exchange consumer when it receives the credential." Once #6022 documented the
Cedar path as deliberately not checking
exp, that TODO asserted outstanding workwhere there is none. I replaced it with a statement of the design. The reasoning, so
you can judge it rather than take my word:
the substance of Fix Cedar deny-all when upstream JWT lacks identity claims #6022.
expis therecipient (the IdP verifying its own id_token as an RFC 8693 subject_token). A
presenter's local pre-check is a latency and error-message optimisation, not a
security control: skipping it cannot cause an expired assertion to be accepted,
because acceptance is the IdP's decision.
pkg/vmcp/auth/strategies/xaa.go:38-44documents exactly this and surfaces the resulting
invalid_grant.the IdP would accept.
UpstreamIDTokensarepkg/authz/authorizers/cedar/core.go:666andpkg/vmcp/auth/strategies/xaa.go:113. (A plain grep for the field name returns~24 hits — most are doc comments, plus the write at
pkg/auth/token.go:1278andidentity.go'sMarshalJSONloop, which reads it only to redact.) That census istrue as of this commit, which is why the replacement text carries an explicit
clause about what a future consumer presenting to a non-validating sink must do.
Please keep that clause if you edit the comment.
If you think the TODO was still live, say so and I'll restore it and flag the
contradiction another way.
identity.gois now the single home for theexprationale, with the other foursites pointing at it. Worth knowing that the canonical doc (
UpstreamCredential) wasthe last site still stating the strict rule while three docs pointed at it — so a
reader following a cross-reference landed on the version contradicting where they came
from. That's fixed; a grep for
MUST check|MUST validate|must check its|MUST honourover
pkg/authnow returns one hit, properly qualified.Generated with Claude Code