Skip to content

Refresh tokens through token exchange, as generic grants #6897

Closed

Description

@jhrozek

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_type is the access-token URN. actor_token, scope, resource, and audience are refused. Exactly one exchange-grant entry.
  • Subject token: this server's own access token from a user sign-in:
    • no act, external_issuer, or no_upstream_session;
    • no # in sub;
    • client_id and jti present;
    • not an ID token;
    • aud contains the client's API audience;
    • client_id is the client itself or one of its allowed source clients.
  • ExpiresAt = now + min(requested expires_at − now, the cap for the output type).
  • One grant per subject-token jti: a reused jti is refused with token_used. No retry exception.
  • The grant records the subject token's sub_id and https://toolhive.dev/upstream_subject claims when present, so a later ID-JAG does not depend on the original token. upstream_subject cannot be derived from sub_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_revoked otherwise), and list refresh_token (not_issuable otherwise).
  • 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 jti yield one grant;
  • a requested expires_at above 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

  1. removed
    needs-triageIssue needs initial triage by a maintainer
    on Oct 24, 2026
  2. self-assigned this
    on Oct 24, 2026
  3. changed the title [-]Session refresh tokens through token exchange[/-] [+]Refresh tokens through token exchange, as generic grants[/+] on Oct 27, 2026
  4. jhrozek commented on Nov 4, 2026

    @jhrozek
    ContributorAuthor

    Status update. Issuance, storage, authorization_details handling, 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 reason token_used, revoked, parent_revoked, not_issuable, grant_invalid, or, for a refused subject token, subject_token_invalid, subject_token_audience, subject_token_source_client, or subject_token_offline_access.
    • invalid_scope for scope; invalid_target for resource or audience.
    • invalid_authorization_details, with reason single_entry_required, malformed_entry, invalid_issuable_token_types, token_type_not_permitted, or invalid_expires_at.
    • server_error on a store failure.

    Dropped from scope:

    • Audit log lines (exchange_grant.created, .derived, .refused).
    • The parent_expired reason. A child of an expired parent is refused with grant_invalid, and a parent with almost no life left gets invalid_expires_at. Neither needs a separate reason.

    Still tracked separately: revocation endpoint (#6966), profile docs (#6967).

    This issue stays open until #7021 merges.

  5. jhrozek commented on Nov 4, 2026

    @jhrozek
    ContributorAuthor

    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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions