Appearance
Signing in a device without a browser
A terminal, an editor or a headless box signs in as a person with a short code approved in the web app. The API brokers the flow and ends it by issuing an mw_ API key for that person. The device sends the key as its bearer from then on.
The flow
The device starts the flow (no auth; the body is optional and names the device on the approval page and in the key's name):
bashcurl -s -X POST https://<host>/api/auth/device -H "Content-Type: application/json" -d '{"client_name": "matterwave-tui on laptop"}'json{"device_code": "Zq3...", "user_code": "WDJB-MJHT", "verification_uri": "https://<host>/device", "verification_uri_complete": "https://<host>/device?user_code=WDJB-MJHT", "expires_in": 600, "interval": 5}The device shows
user_codeandverification_uri(orverification_uri_completeas a link or QR code). The person opens/devicein the web app, signed in as usual, checks the code and approves or denies it. Approving offers a name for the key (prefilled withclient_name, editable); the request isPOST /api/auth/device/approve {"user_code", "name"?}.Opened signed out,
/device?user_code=...sends the browser to sign in with the way back in?return=;/api/auth/login?return=<path>keeps it beside the login state and the callback lands there instead of/, so the code is not lost. Only paths under/deviceare honoured -- no scheme, host,//or backslash -- and anything else lands on/as before.The device polls, no faster than every
intervalseconds:bashcurl -s -X POST https://<host>/api/auth/device/token \ -H "Content-Type: application/json" -d '{"device_code": "Zq3..."}'Until the person acts, the answer is a
400with{"error", "error_description", "interval"}and one of these codes:errorMeaning What the device does authorization_pendingnot approved yet wait interval, poll againslow_downpolled too fast use the intervalin the answer (5 s longer), poll againexpired_tokenexpires_inpassed, the code is unknown, or the key was already deliveredstart over at step 1 access_deniedthe person denied it stop Once approved, exactly one poll gets
200with the key, shown this once:json{"token_type": "Bearer", "access_token": "mw_xxxxxxxx_...", "scopes": ["chat:read", "chat:write"], "expires_in": null}The device sends
Authorization: Bearer mw_...on every request and keeps the key in the OS keyring, not in a file.
The key it gets
- Scopes
chat:readandchat:write, nothing else. It acts as the person who approved it, so conversations made on the device show in the web app and the other way round. - It is named
Device: <client_name>, marked"kind": "device"(api_keys.kind, migration 0054; a key from the Settings form or the SDK is"user"), and listed in Settings → API keys like any other key; revoking it there signs the device out on its next request. - On
/api/keysit sees its own row and may revoke itself (how an editor signs out); withoutkeys:manageit cannot list the org's other keys, mint one or rotate one. - API-key scopes are enforced on
/api/conversations*:chat:readfor the reads,chat:writefor every POST, PUT and DELETE. A read-only key gets403on a send. A browser session is unaffected.
Plans and billing
Device login works on every plan. It is how a person signs a terminal or an editor in, the way the browser signs in, so the api_keys plan feature (plans.limits.features.api_keys) does not apply to it. That feature governs POST /api/keys only: a key a person mints on the Settings form or through the SDK ("kind": "user"). It is true on every plan, Free included (migration 0055: plans differ on the allowance and the rate limits, not on API access), and stays a switch an operator may turn off per plan; a device key ("kind": "device") is minted whatever the switch says.
What a device spends is the org's use. The key acts as the person who approved it in that person's org, so every turn made through it is metered against the org's token allowance and budget exactly as a turn from the web app is; there is no second allowance for devices.
What still refuses an approval, in approve_device_login after the session and rate-limit checks and before the code is claimed (so a refused approval leaves the code pending):
- Payment overdue with new keys paused (
require_paid_up_for_keys):409with the dunning message and anX-MatterWave-Billing-Stageheader./deviceshows the message in place and keeps the Approve and Deny buttons; the device keeps gettingauthorization_pendinguntil the code expires or is denied. The same pause holds the Settings form and key rotation; existing keys, device keys among them, keep working.
Under MW_AUTH_MODE=none the dev principal is exempt (gates_apply_to); that mode is refused in production.
Clients that use this flow: the terminal client and the VS Code extension.