Koder ID Token Exchange — RFC 8693 delegation grant
auth specs/auth/token-exchange.kmd
Normative contract for the OAuth 2.0 Token Exchange grant (RFC 8693) on Koder ID. Lets a Koder service (the actor) exchange an end-user access token for a NEW token scoped to a downstream audience, preserving the user's tenant and recording the actor in the `act` claim. This is the canonical way a multi-tenant gateway/proxy calls a second Koder service ON BEHALF OF the authenticated user without holding broad impersonation rights or forwarding the raw user token. Motivating consumer: Koder Kli (products/dev/kli) driving per-tenant sandbox sessions in services/ai/sandbox (agent-console-RFC-001).
Quando esta spec se aplica
Triggers primários
- A Koder service must call a downstream Koder service on behalf of the end-user
Todos os triggers
- A Koder service must call a second Koder service on behalf of the end-user (per-tenant downstream credential)
- Implement or change the token-exchange grant on the Koder ID token endpoint
- Mint an audience-scoped, actor-stamped token instead of forwarding the user's token
Corpo da especificação
Spec — Koder ID Token Exchange (RFC 8693)
Version: 0.1.0
Status: Implemented (2026-06-22, id/engine#246 — POST /oauth/v2/token grant urn:ietf:params:oauth:grant-type:token-exchange)
Motivation
A Koder service that fronts the user (a gateway/proxy/agent driver) often
must call a second Koder service as that user — e.g. Koder Kli
allocating a services/ai/sandbox session that must land in the end-user's
tenant (multi-tenant-by-default), not the calling service's tenant.
Two non-solutions, and why this grant exists:
- Token forwarding — the actor replays the user's own access token
downstream. Works only while the downstream skips audience checks; it is a
confused-deputy hazard (a token minted for audience A is presented to
audience B) and breaks the moment the downstream enforces
aud. - Admin impersonation (
POST /v1/admin/users/{id}/impersonate) — issues asub=user /act=admin token, but is gated on the powerfuluser.impersonatepermission and is semantically admin-only. Wrong primitive for a service minting per-request delegated tokens.
Token Exchange is the correct primitive: a scoped, audience-bound, actor-stamped delegation token, with downscoping only (never escalation).
Grant
Koder ID's token endpoint (POST /oauth/v2/token) gains a fifth grant
alongside authorization_code / client_credentials / refresh_token /
device_code.
- R1 —
grant_type=urn:ietf:params:oauth:grant-type:token-exchange. - R2 — Request params (RFC 8693 §2.1):
subject_token(REQUIRED) — the user access token being exchanged.subject_token_type(REQUIRED) —urn:ietf:params:oauth:token-type:access_token.audience(REQUIRED in Koder profile) — the target service identifier (e.g.sandbox). The minted token'saudis set to exactly this.scope(OPTIONAL) — requested downscope; MUST be a subset of the subject token's scope (R7).actor_token/actor_token_type(OPTIONAL) — the actor's own client credential; when absent the authenticated client (mTLS/SVID orclient_credentialsbearer on the request) is the actor.
- R3 — Response (RFC 8693 §2.2):
access_token,issued_token_type=...:access_token,token_type=Bearer,expires_in. No refresh token.
Claims of the issued token
- R4 —
tenant_idis copied verbatim from the subject token. The exchange NEVER changes the tenant (multi-tenancy is preserved, not crossed). - R5 —
sub= the subject token'ssub(the end-user remains the principal). - R6 —
act(RFC 8693 §4.1) ={ "sub": "<actor client id>" }, chained if the subject token already carried anact. Audit trails MUST log the actor chain (reuse the impersonation issuer'sactmachinery). - R7 — Scope of the issued token =
min(requested_scope, subject_scope). Escalation is impossible: a scope not present in the subject token is dropped, never granted. - R8 —
aud= the requestedaudience(single value in v0). TTL ≤ the subject token's remaining lifetime and ≤ a configured ceiling (default 15 min, matching impersonation).
Authorization policy
- R9 — Only registered service clients (client-credentials clients,
id#115) flagged
token_exchange_allowedMAY use this grant. A normal user client cannot exchange. - R10 — A client MAY be restricted to a set of allowed downstream
audiencevalues; requesting an audience outside the set →invalid_target. - R11 —
invalid_request/invalid_grant/invalid_target/insufficient_scopeerrors per RFC 8693 §2.2.2, mapped to Koder error IDs.
Security constraints
- R12 — No privilege escalation: the issued token is a strict downscope of the subject token (R7) with an unchanged tenant (R4) and a recorded actor (R6). It cannot be exchanged again for a broader token.
- R13 — Exchanges are audited unconditionally (actor, subject sub, tenant, audience, granted scope).
Tests (outline)
- T1 — exchange a user token for
audience=sandbox→ issued token has sametenant_id+sub,aud=sandbox,act.sub=<client>, scope ⊆ subject. - T2 — requesting a scope absent from the subject token → it is dropped (not granted).
- T3 — a non-service / non-flagged client →
unauthorized_client. - T4 — requesting an audience outside the client's allowlist →
invalid_target. - T5 — issued TTL ≤ subject remaining TTL and ≤ ceiling.
Consumers
- Koder Kli (
products/dev/kli, KLI-001/1e-ii): klid exchanges the inbound user token for anaudience=sandboxtoken and presents it toservices/ai/sandboxso each session runs in the user's tenant. - Any future Koder gateway/proxy that fans a user request into downstream Koder services.
Prior art in-tree
services/foundation/id/engine/services/admin/internal/handler/impersonation_http.go
already issues short-lived act-stamped tokens — the token-exchange grant
generalizes that issuer to a self-service, scope-downscoping, audience-bound,
non-admin path.
Referências
specs/auth/oauth-flow.kmdspecs/multi-tenancy/contract.kmdrfcs/id-RFC-004-oauth2-oidc-service.mdregistries/koder-id-auth-coverage.mdservices/foundation/id/engine/services/admin/internal/handler/impersonation_http.go