Koder ID Token Exchange — RFC 8693 delegation grant
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).
Version: 0.1.0 Status: Implemented (2026-06-22, idengine#246 — `POST /oauthv2/token grant urn:ietf:params:oauth:grant-type:token-exchange`)
Motivation
A Koder service that fronts the user (a gatewayproxyagent 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 asubuser /actadmin 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 subjecttoken's scope (R7).
actor_token/actor_token_type(OPTIONAL) — the actor's own clientcredential; when absent the authenticated client (mTLS/SVID or
client_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. Theexchange NEVER changes the tenant (multi-tenancy is preserved, not crossed).
- R5 —
sub= the subject token'ssub(the end-user remains theprincipal).
- R6 —
act(RFC 8693 §4.1) ={ "sub": "<actor client id>" }, chained ifthe subject token already carried an
act. 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 ≤ thesubject 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 theinbound user token for an
audience=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.