Koder ID Token Exchange — RFC 8693 delegation grant

mandatory

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 a

    subuser / actadmin token, but is gated on the powerful user.impersonate permission 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.

  • R1grant_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's aud is 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 or client_credentials bearer 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

  • R4tenant_id is copied verbatim from the subject token. The

    exchange NEVER changes the tenant (multi-tenancy is preserved, not crossed).

  • R5sub = the subject token's sub (the end-user remains the

    principal).

  • R6act (RFC 8693 §4.1) = { "sub": "<actor client id>" }, chained if

    the subject token already carried an act. Audit trails MUST log the actor chain (reuse the impersonation issuer's act machinery).

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

  • R8aud = the requested audience (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_allowed MAY use this grant. A normal user client cannot exchange.

  • R10 — A client MAY be restricted to a set of allowed downstream

    audience values; requesting an audience outside the set → invalid_target.

  • R11invalid_request / invalid_grant / invalid_target /

    insufficient_scope errors 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 same

    tenant_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 an audience=sandbox token and presents it to services/ai/sandbox so 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.