Skip to content

Derive child exchange grants from refresh tokens - #7007

Open
jhrozek wants to merge 5 commits into
mainfrom
exchange-derive-grants
Open

jhrozek wants to merge 5 commits into
mainfrom
exchange-derive-grants

Conversation

@jhrozek

@jhrozek jhrozek commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Summary

#7003 issues exchange grants whose single output can be refresh_token, but nothing can use such a grant yet: every refresh-token subject_token_type is 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.

  • Token exchange with the refresh-token URN as both subject_token_type and requested_token_type, from a client with exchange_refresh_tokens, now derives a child grant. The child allows one other token type (today id-jag), copies the parent's client, sub, sub_id and upstream_subject, and expires no later than both the parent and the client's cap for the child's type.
  • Depth is 1. A child never lists refresh_token, and a grant that has a parent is refused as a parent.
  • The presented token is checked in a fixed order that reveals nothing before the client check: HMAC (no store call), lookup, same client, not expired, not revoked, parent live, allows refresh_token. A forged, unknown, other-client, login or expired refresh token all get one identical grant_invalid error. Later refusals use the exact reasons revoked, parent_revoked and not_issuable.
  • An expired grant is answered like an unknown one, whatever its revocation, so revoked is reported only while the grant has not expired.
  • Every other refresh-token subject, and every refresh-token subject from a client without exchange_refresh_tokens, keeps the unsupported subject_token_type refusal 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

  • New feature

Test plan

  • Unit tests, scoped: go test on pkg/authserver, pkg/authserver/server/tokenexchange and pkg/authserver/storage (the integration flows run on memory and on Redis through miniredis). I did not run the full task test suite.
  • E2E tests (task test-e2e)
  • Linting (task lint-fix)
  • Generated docs: task docs-verify passes.

Changes

File Change
tokenexchange/refresh_token_handler.go decide routes a refresh-token subject to decideChild; presentedGrant holds the ordered checks; issue logs "Exchange grant derived" for a child.
pkg/authserver/config.go, docs/server/*, sdk/go/* Field doc: deriving also needs id_jag_max_lifetime. Regenerated.
docs/arch/17-token-exchange-delegation.md New "Deriving a child token" section; the error table, routing, logging, accepted limits and before/after paragraphs updated.
tests Handler unit tests for the check order and failure paths; integration tests for derive flows on memory and Redis.

Does this introduce a user-facing change?

Yes.

  • Before: subject_token_type=urn:ietf:params:oauth:token-type:refresh_token was always refused with invalid_request and "The 'subject_token_type' value ... is not supported. Use ... or ...".
  • After: the same request with requested_token_type=urn:ietf:params:oauth:token-type:refresh_token, from a client with exchange_refresh_tokens, succeeds and returns a child grant: access_token holds the child token, issued_token_type is the refresh-token URN, token_type is N_A, and authorization_details echoes the granted entry. Every other refresh-token subject gets the same refusal as before.
  • New refusals on a derive, all invalid_request with the whole error_description: grant_invalid, revoked, parent_revoked, not_issuable. A bad child entry gets invalid_authorization_details with token_type_not_permitted, invalid_expires_at or parent_expired.

Special notes for reviewers

  • Standard messages, our own grant profile. Every request and response member is RFC 8693 / RFC 9396: the refresh-token URN is a defined token type, the issued token sits in access_token with token_type=N_A (RFC 8693 section 2.2.1), and an unusable subject token is invalid_request (section 2.2.2). The parent/child hierarchy and the https://toolhive.dev/exchange_grant semantics 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, same sub, never outlive the parent) mirror the replacement rules in draft-ietf-oauth-transaction-tokens section 13.15.
  • Deliberate deviation: the reasons are stable codes in error_description, which RFC 6749 section 5.2 describes as human-readable text. A stock client sees a plain invalid_request; a client that wants to react to revoked or parent_revoked matches the code. This follows what Issue refresh tokens through token exchange #7003 does for subject_token_invalid.
  • Accepted trade-offs, documented in docs/arch/17:
    • A child has no replay marker. Two derives give two independent children, and a lost response leaves an unused child until its parent ends.
    • There is no bound on children per parent; each lives at most as long as its parent.
    • A parent revoked between the second check and the write still yields a child, but that child is unusable because every use checks the parent.
    • Body, status and headers are identical for the grant_invalid cases; timing is not (a forged token causes no store call). The caller already holds the token, so this tells it nothing new.
  • A child narrows output and lifetime, not identity. Parent and child are bound to the same 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.
  • Requirement on later work: only the derive path reads a parent today. ID-JAG issuance from a grant (ID-JAG issuance through token exchange #6898) must also check the parent's liveness, or revoking a parent will not stop its children. The doc says so. That step should also re-check the user rather than only replay the copied sub, sub_id and upstream_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).
  • Config: a client with only refresh_token_max_lifetime passes startup validation but can never derive, because the child's cap comes from id_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.

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

codecov Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.58333% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.46%. Comparing base (a9b6d1f) to head (89843d4).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...rver/server/tokenexchange/refresh_token_handler.go 89.47% 10 Missing ⚠️
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.
📢 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.

@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 Oct 30, 2026
ChrisJBurns
ChrisJBurns previously approved these changes Oct 30, 2026

@ChrisJBurns ChrisJBurns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 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.

Base automatically changed from exchange-issue-refresh-tokens to main October 30, 2026 16:12
@jhrozek
jhrozek dismissed ChrisJBurns’s stale review October 30, 2026 16:12

The base branch was changed.

@github-actions github-actions Bot added size/XL Extra large PR: 1000+ lines changed and removed size/L Large PR: 600-999 lines changed labels Oct 30, 2026
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.
@jhrozek
jhrozek force-pushed the exchange-derive-grants branch from 1b904b5 to 89843d4 Compare November 1, 2026 20:04
@github-actions github-actions Bot added size/L Large PR: 600-999 lines changed and removed size/XL Extra large PR: 1000+ lines changed labels Nov 1, 2026

This branch has not been deployed

No deployments
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.

2 participants