Skip to content

Scoped Consent Tokens

identity specs/identity/consent.kmd

Koder ID issues, validates, and revokes signed, scoped consent tokens. A consent token is a user's explicit, time-bounded authorization for a specific sensitive operation on their behalf — the first scope is `voice-clone` (Koder Talk records a voice clone only against a valid token). The token is a JWT signed by the id signing key (same JWKS the gateway already verifies), carrying the subject, scope, an opaque resource reference, and an expiry. Relying services validate it through the consent service and MUST fail closed. This spec fixes the wire contract so issuer (id), consumer (services/ai/synth), and the issuing UX (Talk) implement one shape.

When this pattern applies

All triggers

Specification body

Spec — Scoped Consent Tokens

Principle. A sensitive operation performed on a user's behalf by a Koder service requires the user's explicit, time-bounded, revocable consent — represented as a signed token the user mints through a consent UX, that the operating service validates before acting and the user (or the service, on deletion) can revoke. Consent is never inferred from a session or an access token; it is a separate, narrowly-scoped grant.

Scope of this spec

Defines the consent token format, the issuer endpoints on Koder ID, the validator/consumer contract, revocation semantics, and multi-tenancy. The first concrete scope is voice-clone; the shape is scope-generic so future scopes reuse it.

Out of scope: the per-service retention policy after consent is consumed (e.g. synth's "clone expires N days after last use") — that lives with the consuming service.

R1 — Token format (JWT)

A consent token is a JWT signed by the id signing key (RS256, kid in the header), verifiable against the same JWKS the gateway publishes (/oauth/v2/keys). Claims:

ClaimMeaning
isshttps://id.koder.dev (issuer URL)
subsubject_user_id — the consenting user (Koder user id)
audthe consent service / scope audience (koder-consent)
scopethe consent scope, e.g. voice-clone (single scope per token)
tnttenant id the consent is scoped to (multi-tenancy, R5)
refrecording_ref — opaque reference to the consented resource (R2)
jtiunique token id — the revocation key (R4)
iatissued-at
expexpiry — REQUIRED; consent is always time-bounded

ref is opaque to Koder ID: the consuming service defines its meaning (for voice-clone, the recording reference the clone is built from).

R2 — Issuance: POST /v1/consent (authenticated user)

The consent UX (e.g. Talk) mints a token for the acting user:

POST /v1/consent            (bearer of the consenting user)
  { "scope": "voice-clone", "recording_ref": "<ref>", "ttl_seconds": <int> }
→ 201 { "token": "<jwt>", "jti": "<id>", "expires_at": "<rfc3339>" }
  • sub is taken from the authenticated user (gateway-injected X-User-ID), never from the request body.
  • ttl_seconds is clamped to a per-scope maximum (voice-clone default max 90 days; the UX SHOULD request the minimum it needs).
  • The issued jti is persisted (R4) so it can be revoked.

R3 — Validation: POST /v1/consent/validate (service-account auth)

Consumed by relying services (the wire contract services/ai/synth already implements in internal/voice/consent.go):

POST /v1/consent/validate   (service-account bearer; scope `consent:validate`)
  { "token": "<jwt>", "scope": "voice-clone", "tenant": "<koder_user_id>" }
→ 200 { "valid": bool,
        "subject_user_id": "<id>",
        "scope": "voice-clone",
        "recording_ref": "<ref>",
        "expires_at": "<rfc3339>",
        "reason": "expired|wrong_scope|revoked|unknown"   // present when valid=false
      }

valid=true iff: signature verifies against the id JWKS and scope matches the request and exp is in the future and jti is not revoked (R4) and tnt matches the request tenant. Otherwise valid=false with reason.

Consumers MUST fail closed (R6). A consumer accepts the operation only on valid==true && scope==<expected> && expires_at in the future; any transport failure or non-200 denies the operation.

R4 — Revocation: POST /v1/consent/revoke (service-account auth)

POST /v1/consent/revoke     (service-account bearer; scope `consent:revoke`)
  { "token": "<jwt>" }   → 204
  • Marks the token's jti revoked in a persisted revocation set; idempotent (revoking an already-revoked/expired token is still 204).
  • After revoke, /validate returns valid=false, reason="revoked".
  • The user MAY also revoke their own consent via the consent UX (DELETE /v1/consent/{jti}, bearer of the subject) — same effect.
  • The revocation set retains entries until exp + a grace window, after which an expired token is denied on exp alone and the row can be pruned (an expired token needs no revocation entry to be rejected).

R5 — Multi-tenancy

Per policies/multi-tenant-by-default.kmd + specs/multi-tenancy/contract.kmd: the persisted jti/revocation rows carry tnt; /validate enforces tnt == request.tenant (a token minted for one tenant is invalid for another). Storage is RLS-scoped on the id substrate.

R6 — Fail-closed (consumers)

A relying service treats consent as deny-by-default. Cloning, exporting, or otherwise acting WITHOUT a positively-validated token is prohibited. Transport failure to the consent service ⇒ deny. This is a hard requirement, not a degradation mode.

Tests (template)

  • T1 — issue → validate round-trips valid=true with matching sub/scope/ref/exp.
  • T2 — expired token ⇒ valid=false, reason=expired.
  • T3 — wrong scope ⇒ valid=false, reason=wrong_scope.
  • T4 — revoked jtivalid=false, reason=revoked; revoke is idempotent.
  • T5 — tampered signature / unknown kidvalid=false, reason=unknown.
  • T6 — cross-tenant token ⇒ valid=false.
  • T7 — sub is the authenticated user, never the request body.
  • T8 — consumer denies on transport failure / non-200 (fail-closed).

References