Reject malformed prior act claims at token exchange - #6114
Merged
Conversation
RFC 8693 §4.1 requires the act claim to be a JSON object, but the token exchange handler copied a subject token's prior act into the newly minted token without checking its shape. The depth gate did not catch it either: actChainDepth returned 0 for any non-map, making "absent" and "malformed" indistinguishable, so a subject token carrying act: "some-agent" produced a minted token asserting a delegation chain we ourselves would flag as non-conformant on the consuming side. Reuse the shared audit-side parser adopted in #6107 rather than teaching the bespoke walker about shapes: it reports the depth and the conformance verdict in one pass, so the two cannot disagree. The exchange is now rejected with invalid_grant, consistent with the adjacent depth, consent, and expiry gates. The new gate is monotonically stricter than the old one: a well-formed chain yields len(Chain) == min(depth, cap), so the depth boundary is unchanged and nothing previously rejected is newly accepted. Closes #6093 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jhrozek
requested review from
ChrisJBurns,
JAORMX,
rdimitrov and
tgrunnagle
as code owners
July 28, 2026 15:31
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6114 +/- ##
==========================================
- Coverage 72.43% 72.42% -0.02%
==========================================
Files 734 733 -1
Lines 75871 75826 -45
==========================================
- Hits 54960 54916 -44
+ Misses 17019 17017 -2
- Partials 3892 3893 +1 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The gate rejects on every reason ParseDelegationChain reports and surfaces the reason in the client-visible hint, but only three of the four were covered. Add the non-string iss case so the fourth reason and its hint are pinned too. The hop keeps a valid sub: the parser reports the first violation it finds and checks iss before sub, so a hop with both non-string would report only iss_not_string and leave this branch unpinned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JAORMX
approved these changes
Jul 28, 2026
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
RFC 8693 §4.1 requires the
actclaim to be a JSON object. The token exchangehandler copied a subject token's prior
actinto the newly minted token withoutchecking its shape, and the depth gate could not catch it either:
actChainDepthreturned
0for anything that was not a map, making "absent" and "malformed"indistinguishable. A subject token carrying
"act": "some-agent"therefore madeus mint
act: {"sub": "<actor>", "act": "some-agent"}— a spec violation emittedby us, and one the consuming side has flagged since #6046 (
DelegationChain.Malformed),so the symptom was already visible in audit logs while the cause sat on the
issuance side.
bespoke walker about shapes. It reports the depth and the conformance verdict
in one pass, so the two can no longer disagree — which is what the bug was.
invalid_grantwhen the prior chain is malformed, consistent withthe adjacent depth, consent, and expiry gates in the same handler.
actChainDepth; the replaced call site was its only user.Closes #6093
Type of change
Test plan
task test)task test-e2e)task lint-fix)Scoped the unit run to the touched package with the Taskfile's flags
(
go test -ldflags=-extldflags=-Wl,-w -race ./pkg/authserver/server/tokenexchange/...) —green. The pre-existing depth cases pass unmodified, which is the regression
proof that swapping the walker did not move the depth boundary.
Manual verification that the new tests pin the fix and not merely the parser:
disabled only the new
chain.Malformedgate and re-ran. Exactly the three rejectcases fail, and both accept cases plus the depth cases still pass:
Changes
pkg/authserver/server/tokenexchange/handler.goactoncoreaudit.ParseDelegationChain; deleteactChainDepthpkg/authserver/server/tokenexchange/handler_test.goTestActChainDepth; add 5 table cases + anactFormValueshelperDoes this introduce a user-facing change?
No. A token exchange that would previously have minted a non-conformant
actclaim is now rejected with
invalid_grant, but no such exchange is reachabletoday:
NewMultiIssuerTokenValidatorhas no non-test callers, and a self-issuedsubject token's
actcan only have been minted by this same handler as amap[string]any. This is a conformance fix landing ahead of the multi-issuerpath (
TODO(#5989)) that would make it reachable.Special notes for reviewers
The new gate is monotonically stricter, not merely equivalent. With
maxDepth == maxDelegationDepththe parser appends exactly one hop per level, solen(chain.Chain) == min(depth, 10)for a well-formed chain andlen(chain.Chain) >= 10 ⟺ actChainDepth(...) >= 10. A new accept would require!Malformed && len < 10, reachable only via the same absent-nested-actexit atthe same level the old walker returned from — so nothing previously rejected is
newly accepted. Two inputs change which rejection fires (never accept↔reject):
an over-cap chain with a non-object
actpast the cap, and an over-cap chainwith a non-string
subin the first 10 hops, both now reported as malformedrather than too deep.
Three deliberate decisions worth a second opinion:
invalid_grant, notinvalid_request. RFC 8693 §2.2.2 nominally says abad
subject_tokenMUST yieldinvalid_request, but the same section allowsthat "other error codes may also be used, as appropriate".
invalid_grantmatches the three adjacent gates in this handler and the issue's own
recommendation. Noted in a code comment so it does not get "fixed" later.
Heads-up that the sibling malformed-
may_actrejection surfaces asinvalid_request— reconciling the two is Harden act/may_act handling on the external-issuer token exchange path #6113's business.act;Malformedalso covers a non-stringsub/isson a hop. Rejecting those toocomes free from the parser, is the same class of violation (RFC 7519 §4.1.2
fixes
subas a string), and avoids maintaining a reason allowlist. It isstricter than any consumer needs, since §4.1 makes prior hops informational
only — but we are the issuer and would be re-signing the violation.
MalformedReasonis surfaced in the client-visible hint. It is a closedfour-value enum written only from constants, so no token-derived data can
reach it; the raw offending values stay in core's unexported per-hop map. The
code comment says never to interpolate claim contents there.
Two tolerated shapes are now pinned by tests rather than left implicit:
act: {}and a hop with"act": nullare both accepted, because §4.1 onlyrequires an object and
subis conventional rather than mandatory. If core'sparser tightens, a test breaks instead of a user.
Follow-ups filed as #6113 (sub-issue of #5194), deliberately out of scope
here:
may_actshape validation missing on the external-issuer path (the directsibling of this bug, on the receiving side); our own minted hop carrying no
iss; and theactsubtree being depth-capped but not size-capped.Generated with Claude Code