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
- Implementing the Koder ID consent service (issue/validate/revoke)
- A Koder service gating a sensitive op on a user's explicit consent (voice clone, data export to a third party, biometric capture, …)
- Implementing or changing the Talk voice-clone consent UX
- Adding a new consent scope
- Auditing a consumer's consent validation for fail-closed behavior
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:
| Claim | Meaning |
|---|---|
iss | https://id.koder.dev (issuer URL) |
sub | subject_user_id — the consenting user (Koder user id) |
aud | the consent service / scope audience (koder-consent) |
scope | the consent scope, e.g. voice-clone (single scope per token) |
tnt | tenant id the consent is scoped to (multi-tenancy, R5) |
ref | recording_ref — opaque reference to the consented resource (R2) |
jti | unique token id — the revocation key (R4) |
iat | issued-at |
exp | expiry — 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>" }
subis taken from the authenticated user (gateway-injectedX-User-ID), never from the request body.ttl_secondsis clamped to a per-scope maximum (voice-clone default max 90 days; the UX SHOULD request the minimum it needs).- The issued
jtiis 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_atin 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
jtirevoked in a persisted revocation set; idempotent (revoking an already-revoked/expired token is still204). - After revoke,
/validatereturnsvalid=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 onexpalone 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=truewith matchingsub/scope/ref/exp. - T2 — expired token ⇒
valid=false, reason=expired. - T3 — wrong scope ⇒
valid=false, reason=wrong_scope. - T4 — revoked
jti⇒valid=false, reason=revoked; revoke is idempotent. - T5 — tampered signature / unknown
kid⇒valid=false, reason=unknown. - T6 — cross-tenant token ⇒
valid=false. - T7 —
subis the authenticated user, never the request body. - T8 — consumer denies on transport failure / non-200 (fail-closed).
References
services/foundation/id/engine/backlog/pending/188-voice-clone-consent-service.mdservices/ai/synth/backlog/pending/019-real-consent-token-validation.mdservices/ai/synth/backend/internal/voice/consent.gometa/docs/stack/policies/multi-tenant-by-default.kmdmeta/docs/stack/specs/multi-tenancy/contract.kmdmeta/docs/stack/specs/auth/oauth-flow.kmd