exoDocs
Decisions (ADRs)

Decisions and roadmap

ADR 0009: Exo Key headless sign-in — device authorization grant and personal Exo Keys

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/code with {app, scope?} (JSON or form) returns device_code, user_code (8 characters XXXX-XXXX from the RFC 8628 §6.1 consonant alphabet BCDFGHJKLMNPQRSTVWXZ), verification_uri (https://<exo>/id/device), verification_uri_complete (…?code=XXXX-XXXX), expires_in 600 and interval 5.
  • The person opens /id/device on 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/token with grant_type=urn:ietf:params:oauth:grant-type:device_code, device_code, app. Errors are RFC 8628 §3.5: authorization_pending, slow_down (with the new interval; each one adds 5 s), access_denied, expired_token; unknown/redeemed codes or the wrong app give invalid_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 scope is 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 tenant user-<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 what read/write permit; ai/usage:read keep their platform-API meaning.
  • A key is bound to one app or to all headless apps.
  • POST {prefix}/v1/token with grant_type=urn:ietf:params:oauth:grant-type:token-exchange, subject_token=<key>, subject_token_type=urn:exo:token-type:exo-key, app, and optional scope (must be a subset of the key's scopes) returns a 15-minute access token (aud = app, sub = key owner, scope claim), issued_token_type urn: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_code and 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/code is rate-limited per client IP (rightmost X-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 the scope claim.
  • The /id/device and /id/keys pages ship with the Exo frontend. verification_uri is derived from EXO_ID_AUTHORIZE_URL (override: EXO_KEY_DEVICE_URL).
  • Follow-ups: an exo login CLI and MCP helper on top of the device flow; per-app scope catalogs; showing active device sessions on /id/keys.

Source: docs/adr/0009-exo-key-headless-sign-in.md

On this page