C

Capgo · Design Spec

CLI Web Login — Device Flow

Lifecycle V4 Draft for review · 3 June 2026 · apikey v2 / RBAC only

Problem & goal

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.

Key lifecycle — V4 (create at authorize, hashed, deliver once)

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

Architecture & components

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)

Backend endpoints (Hono, under supabase/functions/_backend/public/cli-auth/*)

Frontend (src/pages/cli-login.vue + success)

Data model — cli_login_sessions

columntypenotes
iduuid pk
device_code_hashtextSHA-256 of device_code; poll matches by hashing input
user_codetext5 EFF-short words, hyphenated; unique among active sessions
device_name / client_versiontextreported by CLI
statusenumpending / authorized / consumed / denied / expired
user_iduuid nullset on authorize (the approver)
apikey_iduuid nullthe key created at authorize (hashed)
delivery_keytext nullencrypted JSON envelope { name, key }; transient, nulled/burned on first poll
delivery_key_nametext nullKEK name/version used to encrypt delivery_key (enables rotation)
expires_attimestamptzcreated_at + 10 min (session TTL)
last_poll_attimestamptz nullfor 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_code

Security checklist

Reuse & testing

Open decisions

  1. Key-at-rest / when to createResolved (V4): create at authorize, hashed, deliver via encrypted transient delivery_key.
  2. Web defaultResolved: default in interactive terminals; CI/non-interactive requires an explicit key (--apikey / CAPGO_TOKEN), never opens a browser.
  3. Route mountResolved: under public/cli-auth/* (alongside apikey).
  4. delivery_key encryptionResolved (envelope): encrypted JSON {name,key} + delivery_key_name KEK version for rotation. (Worker secret vs KMS = impl detail.)
  5. CLI certificate pinning — now or follow-up.