Skip to content

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

AspectAPI keyDevice Flow
Setup frictionCopy sk_live_... into configOne-time browser dance
Auth surfaceLong-lived secret on diskRefresh token (rotated)
Per-device revocationAll-or-nothingYes
User MFAN/ANative (browser handles)
Best forScripts, headless serversCLI tools, desktop apps

Endpoints

EndpointDescription
POST /v1/oauth/device_authorizationGet 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/verifyUser-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 authorization
const authReq = await flow.requestAuthorization()
// 2. Show / open the verification URI for the user
console.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 calls
const 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_idPurposeDefault scopes
picora_desktop_official_morayaMoraya desktop appimage:write video:write kb:write mcp:invoke
picora_cli_photo_importer@picora/photo-importer CLIimage: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:

ErrorMeaningClient action
authorization_pendingUser hasn’t approved yetKeep polling at interval sec
slow_downPolling too fastAdd 5 sec to interval, retry
access_deniedUser clicked DenyStop polling, show error
expired_tokendevice_code past expires_in (10 min)Restart from device_authorization
invalid_grantdevice_code consumed / unknownRestart from device_authorization

@picora/oauth-device-flow.pollForTokens() handles all of these — you only see DeviceFlowError thrown on terminal errors.

Security

  • user_code is 8 alphanumeric characters (excluding ambiguous 0/O/1/I), entropy ≈ 41 bits → unguessable in the 10 min window
  • device_code is 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