OAuth 2.0 Device Authorization Grant (RFC 8628)
CLI tools and desktop apps don’t have an embedded browser. The right pattern is Device Authorization Grant (RFC 8628): the app shows a short code, the user opens the URL in their normal browser, and the app polls until tokens arrive.
Picora supports Device Flow as of v0.57.0. The reference client is the @picora/oauth-device-flow npm package.
When to use this vs API key
| Aspect | API key | Device Flow |
|---|---|---|
| Setup friction | Copy sk_live_... into config | One-time browser dance |
| Auth surface | Long-lived secret on disk | Refresh token (rotated) |
| Per-device revocation | All-or-nothing | Yes |
| User MFA | N/A | Native (browser handles) |
| Best for | Scripts, headless servers | CLI tools, desktop apps |
Endpoints
| Endpoint | Description |
|---|---|
POST /v1/oauth/device_authorization | Get device_code + user_code |
POST /oauth/token (grant_type=urn:ietf:params:oauth:grant-type:device_code) | Exchange device_code for tokens |
POST /v1/oauth/device/verify | User-side approve/deny (called by center.picora.me/device) |
Flow
┌────────┐ ┌─────────────┐ ┌────────┐│ App │ │ Picora API │ │ User │└────────┘ └─────────────┘ └────────┘ │ │ │ │ POST /v1/oauth/ │ │ │ device_authorization │ │ ├───────────────────────────────►│ │ │ ◄─── { user_code: ABCD-EFGH, │ │ │ verification_uri, │ │ │ device_code, interval }─┤ │ │ │ │ │ "Open https://center... and │ │ │ enter ABCD-EFGH" │ │ ├────────────────────────────────────────────────────────────►│ │ │ │ │ │ Opens browser, logs in, │ │ │ enters code, clicks │ │ │ Approve │ │ ◄────────────────────────────┤ │ │ │ │ POST /oauth/token grant=device │ │ │ (polls every interval sec) │ │ ├───────────────────────────────►│ │ │ ◄── access_token + refresh ───┤ │ │ │ │TypeScript reference client
import { DeviceFlow } from '@picora/oauth-device-flow'
const flow = new DeviceFlow({ apiBase: 'https://api.picora.me', clientId: 'picora_desktop_official_moraya', // see "Pre-approved clients" scopes: 'image:write video:write',})
// 1. Request authorizationconst authReq = await flow.requestAuthorization()
// 2. Show / open the verification URI for the userconsole.log(`Open ${authReq.verification_uri} and enter: ${authReq.user_code}`)
// 3. Poll until user authorizes// (handles `authorization_pending` and `slow_down` internally)const tokens = await flow.pollForTokens(authReq)console.log('Got tokens', tokens.access_token)
// 4. Use access_token in API callsconst res = await fetch('https://api.picora.me/v1/images', { method: 'POST', headers: { Authorization: `Bearer ${tokens.access_token}` }, body: form,})
// 5. Refresh when access_token expires (15 min)const refreshed = await flow.refreshToken(tokens.refresh_token!)Pre-approved (first-party) clients
These client_id values are pre-approved and don’t require user registration:
client_id | Purpose | Default scopes |
|---|---|---|
picora_desktop_official_moraya | Moraya desktop app | image:write video:write kb:write mcp:invoke |
picora_cli_photo_importer | @picora/photo-importer CLI | image:write video:write |
To register a new first-party client, file a PR against packages/db/scripts/seed-first-party-clients.ts.
Third-party clients can still use Device Flow but must register via POST /oauth/register first (and may be subject to approval queue).
Error codes (RFC 8628 §3.5)
The /oauth/token endpoint returns standard error codes when polling:
| Error | Meaning | Client action |
|---|---|---|
authorization_pending | User hasn’t approved yet | Keep polling at interval sec |
slow_down | Polling too fast | Add 5 sec to interval, retry |
access_denied | User clicked Deny | Stop polling, show error |
expired_token | device_code past expires_in (10 min) | Restart from device_authorization |
invalid_grant | device_code consumed / unknown | Restart from device_authorization |
@picora/oauth-device-flow.pollForTokens() handles all of these — you only see DeviceFlowError thrown on terminal errors.
Security
user_codeis 8 alphanumeric characters (excluding ambiguous 0/O/1/I), entropy ≈ 41 bits → unguessable in the 10 min windowdevice_codeis 32 nanoid characters; never displayed to user- Same
client_id+ IP combination limited to 10 device_authorization requests / minute - Refresh tokens rotate on every use (Picora detects replay and revokes the chain)
See also
@picora/oauth-device-flownpm package- Picora OAuth 2.0 / OIDC overview (Authorization Code Grant for web apps)
- RFC 8628 (official spec)