Decisions and roadmap
ADR 0009: Exo Key headless sign-in — device authorization grant and personal Exo Keys
- Status: proposed
- Date: 2026-10-07
- Extends: 0004-exo-id-central-identity-provider.md, ADR 0008 (Exo Platform API, Exo Key API tokens; PR #145)
Context
Owner decision: Construct and Exo must be usable headlessly from agents (Hermes, Claude Code,
Codex, T3 Code) through MCP servers and CLIs. Sign-in lives only at Exo (Exo Key); apps trust
Exo-issued RS256 JWTs (aud = app). ADR 0004 covers browser apps (authorization code + PKCE with
registered redirect URIs). A CLI or agent has no redirect URI it can register, and a script or
server must not hold a person's browser session.
Decision
Exo Key adds two headless paths. Both end in the same access token apps already verify (issuer
${EXO_CONVEX_SITE}/exo-id, aud = app, RS256, 15 minutes), plus a scope claim.
1. Device authorization grant (RFC 8628), for interactive CLIs and agents
POST {prefix}/v1/device/codewith{app, scope?}(JSON or form) returnsdevice_code,user_code(8 charactersXXXX-XXXXfrom the RFC 8628 §6.1 consonant alphabetBCDFGHJKLMNPQRSTVWXZ),verification_uri(https://<exo>/id/device),verification_uri_complete(…?code=XXXX-XXXX),expires_in600 andinterval5.- The person opens
/id/deviceon the Exo frontend, signs in to Exo if needed (returning to the same URL), sees the app and requested scope, and approves or denies. Approval binds the grant to the signed-in Exo user. - The CLI polls
POST {prefix}/v1/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_code,device_code,app. Errors are RFC 8628 §3.5:authorization_pending,slow_down(with the newinterval; each one adds 5 s),access_denied,expired_token; unknown/redeemed codes or the wrong app giveinvalid_grant. An approved grant returns the same body as the authorization-code grant (access token, rotating refresh token, user) and is single-use. - The granted
scopeis stored on the refresh-token family and carried to every refreshed access token.
2. Personal Exo Keys + token exchange (RFC 8693), for scripts, servers and unattended agents
- A signed-in Exo user creates, lists and revokes their own Exo Keys (
exoKeyTokens.createMine,listMine,revokeMine; UI at/id/keys). They reuse the ADR 0008 phase 1 table and format (exo_tk_<prefix>_<secret>, SHA-256 at rest, prefix as the public handle, 1–365 day TTL) and are owned by the user (userId) under the user's personal tenantuser-<userId>. The secret is returned once. Up to 10 live keys per user; creation is rate-limited per user. - Scopes:
read(default),write,ai,usage:read. Apps define whatread/writepermit;ai/usage:readkeep their platform-API meaning. - A key is bound to one app or to all headless apps.
POST {prefix}/v1/tokenwithgrant_type=urn:ietf:params:oauth:grant-type:token-exchange,subject_token=<key>,subject_token_type=urn:exo:token-type:exo-key,app, and optionalscope(must be a subset of the key's scopes) returns a 15-minute access token (aud= app,sub= key owner,scopeclaim),issued_token_typeurn:ietf:params:oauth:token-type:access_token, and no refresh token: the key is the long-lived credential and every exchange re-checks it. Errors:invalid_grant(unknown, revoked, expired, or an operator tenant token with no user),invalid_target(the key is bound to another app, or the app has not opted in),invalid_scope.- Operator-minted tenant tokens (ADR 0008, no
userId) are never exchangeable: they identify a tenant, not a person.
Registry opt-in
convex/lib/exoIdApps.ts gains label and deviceFlow. Only apps with deviceFlow: true
accept device grants and app-bound personal keys. Enabled: construct, and exo (new; Exo's own
API and CLI, aud exo, no redirect URIs so the browser flow can never target it).
Security
device_codeand Exo Keys are stored only as SHA-256. Comparisons happen through hash-index lookups; the existing constant-time compare covers PKCE and refresh successors.- User codes are short, so they are only resolvable by a signed-in Exo user, through a rate-limited mutation (10/min per user). That is about 2.6e10 codes against at most a handful of pending grants per 10 minutes. A decided or expired code no longer resolves.
device/codeis rate-limited per client IP (rightmostX-Forwarded-For, as for/v1/token), and every token request still hits the existing per-IP limit. Exchange is additionally limited per key.- The consent screen tells the person to approve only a code they started.
Consequences
- Agents get two documented options: device flow for a person at a terminal, an Exo Key for unattended use. Neither needs a redirect URI or a browser cookie.
- Apps change nothing to accept these tokens. To act on
scope, they read thescopeclaim. - The
/id/deviceand/id/keyspages ship with the Exo frontend.verification_uriis derived fromEXO_ID_AUTHORIZE_URL(override:EXO_KEY_DEVICE_URL). - Follow-ups: an
exo loginCLI and MCP helper on top of the device flow; per-app scope catalogs; showing active device sessions on/id/keys.