Repository navigation
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #7007 +/- ##
==========================================
+ Coverage 80.43% 80.46% +0.02%
==========================================
Files 850 852 +2
Lines 86943 87121 +178
==========================================
+ Hits 69935 70100 +165
- Misses 17003 17016 +13
Partials 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ChrisJBurns
left a comment
There was a problem hiding this comment.
-
medium · correctness — decideChildEntry indexes IssuableTokenTypes[0] even when validation failed (
pkg/authserver/server/tokenexchange/refresh_token_handler.go)The return evaluates entry.IssuableTokenTypes[0] whatever err holds. If decodeExchangeGrantEntry accepts an empty issuable_token_types array, a client-controlled request panics the token handler instead of being refused.
-
low · security — Derive gate checks only that exchange_refresh_tokens is non-empty, not that refresh_token is enabled (
docs/arch/17-token-exchange-delegation.md,pkg/authserver/server/tokenexchange/refresh_token_handler.go)A client whose operator removed refresh_token_max_lifetime but kept id_jag_max_lifetime can still derive children from parents it already holds. Operators may expect removing the refresh_token cap to stop this. It is documented but not enforced.
-
low · security — Revoking a parent only stops children on paths that re-read the parent (
pkg/authserver/server/tokenexchange/refresh_token_handler.go)Nothing in the child grant or the store makes parent liveness mandatory. A future ID-JAG issuance path (#6898) that forgets to call the parent check will keep honouring children of revoked parents.
-
low · intent — PR description still lists parent_expired, but the diff removes that reason (
pkg/authserver/server/tokenexchange/exchange_grant.go)The description says a bad child entry can get parent_expired. The diff deletes reasonParentExpired and its tests, and an expired parent now gets grant_invalid. Client authors reading the PR will expect a code that no longer exists.
-
low · maintainability — Integration test sleeps 4s on whole-second expiry (
pkg/authserver/exchange_refresh_token_derive_integration_test.go)The expired-grant subtest sleeps for real time against whole-second expires_at, on both the memory and Redis runs. This slows the suite and is timing-sensitive on loaded CI runners.
-
low · maintainability — HMAC-only check depends on fosite skipping expiry when the session has none (
pkg/authserver/server/tokenexchange/refresh_token_handler.go)presentedGrant passes a DefaultSession with no refresh expiry so that ValidateRefreshToken checks only the HMAC. A fosite upgrade or a different strategy could start rejecting or accepting tokens differently, with no compile-time signal.
A client holding a grant that allows refresh_token had no way to hand out narrower credentials. Token exchange with a refresh-token subject and a refresh-token requested type now derives a child grant that allows one other token type (id-jag), copies the parent's client and subject, and never outlives the parent or the client's cap for the child's type. This replaces the blanket refusal of refresh-token subjects for clients with exchange_refresh_tokens. A refresh-token subject that asks for any other requested_token_type, and every refresh-token subject from a client without exchange_refresh_tokens, keep the unsupported subject_token_type refusal. The presented token is checked in a fixed order that reveals nothing before the client check: HMAC only (no store call), lookup, same client, the grant's own liveness, the parent's liveness, then the refresh_token type. A forged, unknown, other-client or login refresh token all get one invalid_request with the stable description grant_invalid, so a caller learns nothing about which case applied. Later refusals use the exact reasons revoked, parent_revoked and not_issuable. An expired grant is not found in the store, so it gets grant_invalid too. A child lists no refresh_token, so it cannot be derived from (depth 1). A derive logs "Exchange grant derived" at debug level with has_parent=true. A child has no replay marker, so a derive can be repeated, and a lost response leaves an orphan child until its parent ends; this is documented.
A client learns when a grant ends from the expires_at echoed in the response that issued it, so a separate expired reason tells it nothing. Both stores already hide expired records, so an expired grant reached the handler only when the server and store clocks disagreed. An expired grant is now answered like an unknown grant: the same grant_invalid error and the same log reason, whichever clock it is checked against. The stable grant reasons are token_used, revoked, parent_revoked and not_issuable. revoked is reported only while the grant has not expired. An expired parent keeps the name parent_revoked. A client handles both the same way: the child is dead and the client gets a new grant.
A later ID-JAG issuance from a derived grant must put the user's https://toolhive.dev/upstream_subject claim into the token after the original access token is gone. A child grant has no subject token of its own, so it copies the claim from its parent, as it already does for sub_id. An integration test derives a child from a grant that has both claims and checks that the child record holds the same values.
The expiry check pushed presentedGrant over the gocyclo limit of 15. The checks that run after the client check (own liveness, parent liveness, refresh_token type) move to checkParentGrant, in the same order and with the same refusals.
A presented grant with under one whole second left got parent_expired, a reason the client's broker does not know, while an expired grant got grant_invalid. expires_at is whole seconds, so the two cases should get one answer. A parent with no whole second left is now refused with grant_invalid, the same as an expired grant, and parent_expired is gone.
1b904b5 to
89843d4
Compare
Summary
#7003 issues exchange grants whose single output can be
refresh_token, but nothing can use such a grant yet: every refresh-tokensubject_token_typeis refused. The use case in #6897 needs it. A client holds one long-lived grant for a user and, for each unattended run, gets a short-lived child that can only produce ID-JAGs, so the run never holds the long-lived credential.subject_token_typeandrequested_token_type, from a client withexchange_refresh_tokens, now derives a child grant. The child allows one other token type (todayid-jag), copies the parent's client,sub,sub_idandupstream_subject, and expires no later than both the parent and the client's cap for the child's type.refresh_token, and a grant that has a parent is refused as a parent.refresh_token. A forged, unknown, other-client, login or expired refresh token all get one identicalgrant_invaliderror. Later refusals use the exact reasonsrevoked,parent_revokedandnot_issuable.revokedis reported only while the grant has not expired.exchange_refresh_tokens, keeps the unsupportedsubject_token_typerefusal with its text unchanged.Stacked on #7003 ("Issue refresh tokens through token exchange"); review only the top three commits. Part of #6897.
Type of change
Test plan
go testonpkg/authserver,pkg/authserver/server/tokenexchangeandpkg/authserver/storage(the integration flows run on memory and on Redis through miniredis). I did not run the fulltask testsuite.task test-e2e)task lint-fix)task docs-verifypasses.Changes
tokenexchange/refresh_token_handler.godecideroutes a refresh-token subject todecideChild;presentedGrantholds the ordered checks;issuelogs "Exchange grant derived" for a child.pkg/authserver/config.go,docs/server/*,sdk/go/*id_jag_max_lifetime. Regenerated.docs/arch/17-token-exchange-delegation.mdDoes this introduce a user-facing change?
Yes.
subject_token_type=urn:ietf:params:oauth:token-type:refresh_tokenwas always refused withinvalid_requestand "The 'subject_token_type' value ... is not supported. Use ... or ...".requested_token_type=urn:ietf:params:oauth:token-type:refresh_token, from a client withexchange_refresh_tokens, succeeds and returns a child grant:access_tokenholds the child token,issued_token_typeis the refresh-token URN,token_typeisN_A, andauthorization_detailsechoes the granted entry. Every other refresh-token subject gets the same refusal as before.invalid_requestwith the wholeerror_description:grant_invalid,revoked,parent_revoked,not_issuable. A bad child entry getsinvalid_authorization_detailswithtoken_type_not_permitted,invalid_expires_atorparent_expired.Special notes for reviewers
access_tokenwithtoken_type=N_A(RFC 8693 section 2.2.1), and an unusable subject token isinvalid_request(section 2.2.2). The parent/child hierarchy and thehttps://toolhive.dev/exchange_grantsemantics are ToolHive's own; no spec defines refresh-token hierarchies. The overall flow matches ID-JAG draft -04 sections 4.3 and 4.5 (a refresh token as the subject for ID-JAGs), and the child rules (narrow only, samesub, never outlive the parent) mirror the replacement rules in draft-ietf-oauth-transaction-tokens section 13.15.error_description, which RFC 6749 section 5.2 describes as human-readable text. A stock client sees a plaininvalid_request; a client that wants to react torevokedorparent_revokedmatches the code. This follows what Issue refresh tokens through token exchange #7003 does forsubject_token_invalid.docs/arch/17:grant_invalidcases; timing is not (a forged token causes no store call). The caller already holds the token, so this tells it nothing new.client_id. A component holding the client credentials and a child can use that child until it expires; one holding the parent can derive more children. The child does not identify which run is acting, because there is no per-run or actor identity. Run isolation needs agent workload identity, which is later work.sub,sub_idandupstream_subject(ID-JAG draft -04 section 4.3.3; see also Re-check the user's upstream refresh token when issuing an ID-JAG #6978).refresh_token_max_lifetimepasses startup validation but can never derive, because the child's cap comes fromid_jag_max_lifetime. This is documented on the field rather than rejected, to keep the validation in Issue refresh tokens through token exchange #7003 unchanged.