Repository navigation
Description
Part of #6886. Depends on #6893 (configured broker client), #6894 (authorization_details in token exchange), #6895 (grant storage), #6900 (sub_id in access tokens). Follow-ups: #6965 (kind e2e), #6966 (revocation endpoint), #6967 (profile documentation).
Problem: a configured confidential client acting for a signed-in user needs a revocable, non-rotating refresh token whose use is fixed when it is issued, for example "exchangeable only for ID-JAGs". It also needs to derive a narrower, shorter-lived token from one it holds, for work that runs without the user present. The authorization server should know nothing about the client's sessions or schedules: the client maps those onto grants.
Change: a new handler in tokenexchange, registered whenever token exchange is enabled. It claims requests whose requested_token_type or subject_token_type is the refresh-token URN; the existing handler keeps the rest.
Configuration: per configured client, refresh-token issuance is off unless set, with a maximum lifetime per output type (ID-JAG, refresh token), each at most 720h. Exposed in the CRD.
The authorization_details type https://toolhive.dev/exchange_grant: {"type", "issuable_token_types": [one URN], "expires_at"?: integer}. issuable_token_types is the ID-JAG URN or the refresh-token URN. Unknown members are refused. The type lives in a private registry used only by this handler, never by the embedder's registry or the access-token path. The server fills in expires_at when absent and caps it when too high (RFC 9396 §7.1, part of the type's definition).
Issuance from an access token:
- The client is authenticated and enabled for the requested output type.
subject_token_typeis the access-token URN.actor_token,scope,resource, andaudienceare refused. Exactly one exchange-grant entry.- Subject token: this server's own access token from a user sign-in:
- no
act,external_issuer, orno_upstream_session; - no
#insub; client_idandjtipresent;- not an ID token;
audcontains the client's API audience;client_idis the client itself or one of its allowed source clients.
- no
ExpiresAt= now + min(requestedexpires_at− now, the cap for the output type).- One grant per subject-token
jti: a reusedjtiis refused withtoken_used. No retry exception. - The grant records the subject token's
sub_idandhttps://toolhive.dev/upstream_subjectclaims when present, so a later ID-JAG does not depend on the original token.upstream_subjectcannot be derived fromsub_id: on some IdPs they name different claims. A child grant copies both from its parent.
Child grants (refresh token as subject token):
- The presented token passes the HMAC check before any lookup. An unknown token, another client's token, or a login refresh token all get the same reasonless error.
- The grant must be live, its parent (if any) live (
parent_revokedotherwise), and listrefresh_token(not_issuableotherwise). - The child lists exactly one output other than
refresh_token, configured for the client, and expires no later than its parent. Depth is 1. - A lost response leaves an unused child until its parent ends.
Response: the refresh token in access_token, token_type N_A, issued_token_type the refresh-token URN, expires_in, and the echoed entry with the granted expires_at. No refresh_token member; Cache-Control: no-store and Pragma: no-cache. These tokens are not in fosite's refresh-token store, so grant_type=refresh_token refuses them with invalid_grant. They do not rotate.
Refusals: invalid_request; invalid_scope for scope; invalid_target for resource or audience; invalid_authorization_details for a bad entry. Stable reasons, set as the whole error_description: token_used, revoked, parent_revoked, not_issuable; and for invalid_authorization_details, one of single_entry_required, malformed_entry, invalid_issuable_token_types, token_type_not_permitted, invalid_expires_at, parent_expired. A refused subject token gets no reason. A store failure is server_error.
Audit: structured log lines exchange_grant.created, exchange_grant.derived, and exchange_grant.refused, with client, sub, sub_id, source jti, parent flag, output type, expiry, and reason; never token values.
ID-JAG issuance from a grant is #6898.
Acceptance:
- each refusal has a test over HTTP, on memory and Redis;
- two concurrent requests with one
jtiyield one grant; - a requested
expires_atabove the cap is capped and echoed; - a child never outlives its parent, and a child of a revoked parent is refused with
parent_revoked; - a child cannot derive;
- an issued token is refused at
grant_type=refresh_token; - a client without the setting is refused.
Activity
[-]Session refresh tokens through token exchange[/-][+]Refresh tokens through token exchange, as generic grants[/+]jhrozek commentedon Nov 4, 2026
Status update. Issuance, storage,
authorization_detailshandling, and child-grant derivation are merged (#6990, #6895, #7003, #7007). The CRD exposure is in #7021, and review follow-ups for the derivation checks are in #7044.Refusals the server returns:
invalid_request, with reasontoken_used,revoked,parent_revoked,not_issuable,grant_invalid, or, for a refused subject token,subject_token_invalid,subject_token_audience,subject_token_source_client, orsubject_token_offline_access.invalid_scopeforscope;invalid_targetforresourceoraudience.invalid_authorization_details, with reasonsingle_entry_required,malformed_entry,invalid_issuable_token_types,token_type_not_permitted, orinvalid_expires_at.server_erroron a store failure.Dropped from scope:
exchange_grant.created,.derived,.refused).parent_expiredreason. A child of an expired parent is refused withgrant_invalid, and a parent with almost no life left getsinvalid_expires_at. Neither needs a separate reason.Still tracked separately: revocation endpoint (#6966), profile docs (#6967).
This issue stays open until #7021 merges.
jhrozek commentedon Nov 4, 2026
Let's not wait for 7021, that one was drafted as not needed at the moment past its e2e tests which were useful for development