Capgo · Design Spec
Today the CLI only authenticates by manually pasting an API key — there is no "log in from the browser" path. Goal: capgo login opens the browser, the user picks org/app RBAC roles and authorizes, and a fresh API key is delivered back to the terminal over HTTPS. It uses the shape of the OAuth 2.0 Device Authorization Grant (RFC 8628) but hands back a normal Capgo API key — not an OAuth token.
Non-goals: no OAuth token server, no refresh tokens, no reuse of existing keys (always mint fresh), manual capgo login <key> kept as fallback. apikey v2 / RBAC only — permission is the role_name per binding; there is no read/upload/write/all mode.
The key is created at authorize-time (under the user's JWT), stored hashed (key = NULL, only key_hash), and the plaintext is held transiently on the session for the CLI to fetch once, then burned. After delivery, only the hash remains — so the key is not viewable in the dashboard.
① Authorize
JWT present. RBAC-check, create key now (hashed), store apikey_id + encrypted one-time delivery_key.
② Poll
CLI polls with device_code; backend returns the plaintext once, then nulls it and burns the session.
③ Rest
Only key_hash persists — identical to a hashed dashboard key.
Why V4 over deferring creation to the poll (“V3”): deferring would store the intent (bindings) and build the key later — a bug in that stored scope could mint a wrong-scope key within the user's rights, which the RBAC check can't catch (it only blocks escalation). V4 creates atomically under the JWT, so nothing scope-bearing is stored between steps; the only transient state is the opaque secret string, whose worst-case bug is a dud key that fails login — never wrong scope. Creation errors also surface in the browser, not on a headless poll.
Security guards
org.update_user_roles per org intrinsically — escalation impossible regardless of caller. Service-role insert without the check is banned.delivery_key is encrypted at rest (Worker secret) + short TTL + burn-on-read; the only plaintext copy, and opaque (no scope meaning).CLI Backend (CF Workers / Hono) Browser (logged in → JWT)
| POST /cli-auth/start -----> create session ---. |
| <-- device_code, user_code, URIs | |
| print user_code, open verify URL -----------------------------> /cli-login?code=user_code
| | show phrase + RBAC role picker
| POST /cli-auth/authorize <-- (JWT)
| RBAC-check + CREATE key now (hashed),
| store apikey_id + encrypted delivery_key
| POST /cli-auth/poll {device_code} (loop) -> status; when authorized:
| <-- api_key (decrypt + return once), delivery_key nulled, session burned
| save to ~/.capgo
CLI (cli/src/login.ts + new loginWeb.ts)
capgo login (no key) runs the web flow in interactive terminals. In CI / non-interactive (no TTY or CI set) it never opens a browser — it requires an explicit key (positional / --apikey) or CAPGO_TOKEN. --web forces web; capgo login <key> stays as manual fallback./cli-auth/start, prints the user_code, opens verification_uri_complete (existing open dep), then polls honoring RFC 8628 (authorization_pending / slow_down / access_denied / expired_token). No local server.authorized, saves the key once via the existing ~/.capgo / ./.capgo writer (0o600).Backend endpoints (Hono, under supabase/functions/_backend/public/cli-auth/*)
POST /cli-auth/start — minimal auth; generates device_code (32 bytes) + user_code, stores the session (only the hash of device_code), returns the device-flow payload. Rate-limited per IP.GET /cli-auth/session?user_code=… — JWT-authed; returns display info only (device name, version, status). Never returns device_code or delivery_key.POST /cli-auth/authorize — JWT-authed. Body { user_code, name, bindings, expires_at, hashed }. RBAC-check under JWT, create the key now (hashed) via the shared function, store apikey_id + encrypted delivery_key, mark authorized. Requires org.update_user_roles on every org — only org admins can authorize for that org.POST /cli-auth/poll — authenticated by the device_code bearer secret (Authorization: Bearer, not a JWT); backend hashes it and matches device_code_hash (constant-time). On first authorized poll returns the plaintext once, nulls delivery_key, burns the session. Never creates keys.Frontend (src/pages/cli-login.vue + success)
ApiKeys.vue org+role / app+role selectors (selectedOrgRole, pendingAppBindings) + expiration./cli-auth/authorize; success shows "return to your terminal"; creation errors surface here. Styled like login.vue.cli_login_sessions| column | type | notes |
|---|---|---|
id | uuid pk | |
device_code_hash | text | SHA-256 of device_code; poll matches by hashing input |
user_code | text | 5 EFF-short words, hyphenated; unique among active sessions |
device_name / client_version | text | reported by CLI |
status | enum | pending / authorized / consumed / denied / expired |
user_id | uuid null | set on authorize (the approver) |
apikey_id | uuid null | the key created at authorize (hashed) |
delivery_key | text null | encrypted JSON envelope { name, key }; transient, nulled/burned on first poll |
delivery_key_name | text null | KEK name/version used to encrypt delivery_key (enables rotation) |
expires_at | timestamptz | created_at + 10 min (session TTL) |
last_poll_at | timestamptz null | for slow_down enforcement |
Stores no scope/intent — the key (hashed) and its bindings are real apikeys / role_bindings rows created at authorize. The session holds only the device-code hash and the encrypted one-time delivery_key. Not client-accessible (no RLS path); reached only via edge functions; scheduled cleanup of expired/consumed rows.
user_code & device_codedevice_code — 32 bytes, base64url. Only its SHA-256 hash is stored. The real high-entropy poll secret ("state" anchor).user_code — EFF short wordlist (1296 words), 5 words hyphenated (~51 bits), server-generated & stored (Stripe/GitHub model), unique among active sessions. A short-lived, rate-limited handle — not the secret — so ~51 bits + rate-limit + 10-min TTL is ample (GitHub uses ~35 bits). The two tokens have separated roles: user_code is the public browser handle; device_code is the secret that authenticates the poll, so knowing user_code alone can't poll.device_code 256-bit, stored hashed; poll matches by hash.user_code ~51 bits + unique-among-active + 10-min TTL + strict rate limit on authorize/poll — mitigates guess→planted-key.org.update_user_roles per org intrinsically; service-role insert without the check is banned.apikeys.key = NULL); not viewable in dashboard. delivery_key = encrypted JSON envelope {name,key}, short-TTL, burn-on-read, opaque; delivery_key_name records the KEK version for rotation.apikey/post.ts (validation + RBAC + key gen + bindings) into one fn taking an explicit actingUserId, RBAC gate inside. Called by the public endpoint and /cli-auth/authorize. v2/RBAC only.ApiKeys.vue org+role / app+role selectors.delivery_key.--apikey / CAPGO_TOKEN), never opens a browser.public/cli-auth/* (alongside apikey).{name,key} + delivery_key_name KEK version for rotation. (Worker secret vs KMS = impl detail.)