OAuth device-flow clients
This guide walks developers through implementing the OAuth 2.0 Device Authorization Grant (RFC 8628) end to end: requesting a device code, prompting the user to approve it, polling for a token, storing and refreshing it, and calling the API. It’s aimed at developers building a TV app, a CLI tool, or any other client that can’t easily present a browser-based login form.
What this flow is and isn’t
Section titled “What this flow is and isn’t”Lumastra implements the Device Authorization Grant only: no authorization-code
grant, no /.well-known/openid-configuration, no JWKS endpoint, and no client
registration or secret. See API & tokens
for the full “is/isn’t” breakdown.
The server-side prerequisites are FEATURE_DEVICE_AUTH (on by default since v0.1) and
a JWT_SECRET (derived from LUMASTRA_MASTER_KEY when unset) on the Lumastra instance. If a client gets consistent errors from
/oauth/*, that’s the first thing to check with the instance’s operator.
The whole flow at a glance
Section titled “The whole flow at a glance”Scroll, swipe, or click and drag to explore. Use the arrows to move, or fit the diagram above.
Quick start
Section titled “Quick start”POST /oauth/device→device_code+user_code+verification_uri- Show the
user_code(or a QR code) → the user approves at/devicein a browser - Poll
POST /oauth/token→access_token(JWT) +refresh_token Authorization: Bearer <token>→ call/api/v1/*POST /oauth/token(refresh) → newaccess_token+ rotatedrefresh_token
Step 1: request a device code
Section titled “Step 1: request a device code”curl -X POST https://lumastra.example.com/oauth/device \ -H "Content-Type: application/json" \ -d '{ "client_id": "my-tv-app", "device_name": "Living Room TV", "device_id": "550e8400-e29b-41d4-a716-446655440000", "scope": "media" }'Request body:
| Field | Type | Required | Description |
|---|---|---|---|
client_id | string | Yes | A free-form identifier for your app (e.g. "apple-tv-app"). No registration needed. |
device_name | string | No | Human-readable device name shown during approval. |
device_id | string | No | Generate and persist a stable identifier per physical device (a UUID works). Reauthorization can reuse that user/device’s server session; use the same identifier as playback deviceId. |
scope | string | No | One of two recognized values. "media" (the default if omitted, or if you send anything unrecognized) grants read and write. "media:read" narrows the resulting token to read-only, which cannot start playback (403 forbidden, reason: scope_insufficient for device JWTs). See What your token can do. Max 256 characters. |
Response (200 OK):
{ "device_code": "7f7e9466b069c6a497affd39abfe613217f948fa020d35ab64437e9165113b9b", "user_code": "WDJB-MJHT", "verification_uri": "https://lumastra.example.com/device", "verification_uri_complete": "https://lumastra.example.com/device?code=WDJB-MJHT", "expires_in": 600, "interval": 5}| Field | Description |
|---|---|
device_code | Secret code your client polls with. Never display it to the user. |
user_code | Short code the user enters manually. Display this prominently. |
verification_uri | The page where the user approves the request. |
verification_uri_complete | Same URL with the code pre-filled. Encode this as a QR code. |
expires_in | Seconds until both codes expire (default 600). |
interval | Minimum seconds your client must wait between polling requests. |
Both endpoints accept JSON and application/x-www-form-urlencoded, with a 256 KiB
body cap and Cache-Control: no-store responses. /oauth/device allows 10 requests
per minute per IP and at most 10 pending codes per client_id; 429 slow_down
includes Retry-After: 60. HEAD /oauth/device returns 200 when enabled and
400 when unavailable.
Step 2: prompt the user to approve
Section titled “Step 2: prompt the user to approve”Show the user how to authorize the device. Two options, and most clients offer both:
- QR code (recommended for TVs). Encode
verification_uri_completeas a QR code. Scanning it takes the user straight to a pre-filled approval page on their phone. - Manual entry. Display
verification_urianduser_codefor the user to type in on any device.
The user visits that page in a browser while signed in (via whichever authentication method the instance has enabled: forward auth, built-in login, or OIDC) and confirms the code. Nothing else is required on your client’s side during this step. Keep polling in the background (Step 3).
Approving a code from a signed-in app
Section titled “Approving a code from a signed-in app”A signed-in app can use POST /api/v1/oauth/device/approve instead of opening
the browser approval page. Send the approving user’s OAuth access token in
Authorization: Bearer <token>.
This requires a full media device grant with coarse write authority and
expanded media:write scope. The token must belong to a live, independently
signed-in device session. A session approved by another bearer session cannot
approve further devices. PATs, service tokens, read-only device tokens, stream
tokens, browser cookies, proxy identities, delegated requests, and tokens with
admin authority or admin-class scopes are refused. Self-approval by session,
device, or client ID is also refused.
Before approving, show the user the sign-in code and the target client and device.
Require explicit confirmation that the code and device match. Set approve to
true only after that confirmation. Client IDs and device names are self-reported
recognition aids, never proof of client identity.
To get the display information, call
GET /api/v1/oauth/device/approve?user_code=WDJB-MJHT with the same bearer token.
It returns { "clientId": "my-tv-app", "deviceName": "Living Room TV" };
deviceName is optional. clientId is capped at 128 characters. The device name
is capped at 64 characters, with an ellipsis if truncated. This lookup uses the
same authentication checks and rate limits as a decision.
Send a JSON body with these two required fields. No other fields are defined. The body limit is 1,024 bytes.
| Field | Type | Meaning |
|---|---|---|
user_code | string | Eight letters or digits, optionally separated by a dash after the fourth character. Case-insensitive. Send the displayed user code, never the secret device_code. |
approve | boolean | true approves the sign-in; false denies it. |
curl -X POST https://lumastra.example.com/api/v1/oauth/device/approve \ -H "Authorization: Bearer APPROVING_USER_ACCESS_TOKEN" \ -H "Content-Type: application/json" \ -d '{"user_code":"WDJB-MJHT","approve":true}'| HTTP | Response or meaning |
|---|---|
| 200 | { "approved": true } for approval, or { "approved": false } for denial. |
| 401 | Authentication required, invalid credentials, or account disabled or deleted. |
| 403 | Insufficient scope, refused credential or session, or self-approval. |
| 404 | Unknown, expired, already decided, or consumed code; also returned when device authorization is disabled. |
| 413 | Body exceeds 1,024 bytes. |
| 422 | Invalid body, including malformed JSON, an invalid code format, or a missing/non-boolean approve. |
| 429 | Shared approval budget exceeded. Honour Retry-After in seconds. |
| 500 / 503 | Internal error / database or account check unavailable. |
Already-decided codes return 404, not 409. Invalid input returns 422, not
400. Errors use the API’s application/problem+json format. Responses are not
cached.
Browser approval and bearer lookup/decision requests share three one-minute
budgets: 20 requests per IP, 10 per user, and 10 per user and normalized
code pair. Lookups and decisions both count. A 429 rate_limited response gives
the remaining wait in Retry-After; it is not a fixed 60-second delay.
The approving token’s user becomes the grant owner. The TV still exchanges its
own device_code at /oauth/token to obtain its own device session, subject
to that user’s age and library restrictions. That session can be revoked
independently. Revoking the approving session also revokes sessions it approved.
The approver’s token is never transferred to the TV.
Lumastra’s Apple apps use this for Set up Apple TV with your iPhone, with local pairing that is internal to the Apple apps.
Step 3: poll for the token
Section titled “Step 3: poll for the token”While the approval screen is displayed, poll the token endpoint at the
interval returned in Step 1:
curl -X POST https://lumastra.example.com/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "urn:ietf:params:oauth:grant-type:device_code", "device_code": "7f7e9466b069c6a497affd39abfe613217f948fa020d35ab64437e9165113b9b", "client_id": "my-tv-app" }'application/x-www-form-urlencoded is also accepted, for off-the-shelf OAuth
libraries that assume it.
Polling rules:
- Wait at least
intervalseconds between requests. - Stop polling once
expires_inseconds have elapsed since Step 1. Restart the flow from scratch if you need a new code. - Branch on
errorand, when supplied, the stablecodeextension.error_descriptionis display text; never parse it to make decisions.
Responses:
| HTTP | error | Meaning | What to do |
|---|---|---|---|
| 200 | (none) | Success | Store the tokens (Step 4) and stop polling. |
| 400 | authorization_pending | User hasn’t approved yet | Keep polling at the same interval. |
| 400 | slow_down | Polling too frequently for this device code | Add 5 seconds to your interval and keep polling. |
| 429 | slow_down | Per-IP rate limit (30 requests/minute on /oauth/token) | Honour Retry-After (60 s) before polling again. |
| 400 | access_denied | User rejected the request | Stop polling; offer to restart the flow. |
| 400 | expired_token | An expired device-code record was found | Stop polling; request a new device code. |
| 400 | invalid_grant | Unknown, cleaned-up expired, or consumed code; mismatched client | Stop polling; check code and restart authorization if needed. |
| 500 | server_error | Transient server failure | Retry with backoff within the code’s lifetime. |
Example pending / success bodies:
{ "error": "authorization_pending", "error_description": "Authorization pending" }{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "a1b2c3d4e5f6...", "scope": "media"}/oauth/* responses use the plain RFC 8628 { error, error_description }
shape rather than the application/problem+json format used elsewhere on the
API, because off-the-shelf OAuth client libraries hard-code those field
names.
Step 4: store tokens securely
Section titled “Step 4: store tokens securely”| Token | Lifetime | Purpose |
|---|---|---|
access_token | ~1 hour (JWT_ACCESS_TOKEN_TTL_SECONDS, instance-configurable) | Bearer credential for /api/v1/* requests. Signed JWT; device-session revocation and account disable can invalidate it before expiry. |
refresh_token | 90 days by default (JWT_REFRESH_TOKEN_TTL_DAYS), rotated on every use | Used to mint a new access token without re-running the device flow. |
- Store both in secure, platform-appropriate storage (Keychain,
EncryptedSharedPreferences, or equivalent), never in plain application preferences or logs. - Persist each returned access/refresh pair atomically before considering refresh complete.
- Serialize refresh calls per device. The presented token is superseded rather than immediately revoked so a lost response can be retried. A 90-second replay cache can return the same replacement; a cache miss can issue another valid replacement. A later rotation retires older superseded tokens. Reusing an old token does not revoke the entire device session.
POST /api/v1/auth/logoutrevokes the current device session. Account disable and explicit device revocation also end access. Do not rely on JWT expiry alone.
Step 5: make API requests
Section titled “Step 5: make API requests”Use the access token as a standard bearer credential against the /api/v1/*
token surface:
curl https://lumastra.example.com/api/v1/me \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."Player surfaces that can’t set request headers (some native HLS players) don’t
put the token in the URL. They start playback with POST /api/v1/play-sessions and
open the returned delivery.url, which carries its own scoped ?grant=. See
Play media from your own
client.
The full route catalog (catalog browsing, playback, watch state, playlists,
notifications, and more) lives in API & tokens and the
interactive API reference. Third-party integrations should
target endpoints marked x-stability: stable in the OpenAPI spec.
Step 6: refresh the access token
Section titled “Step 6: refresh the access token”Refresh before the access token expires (or reactively on a 401):
curl -X POST https://lumastra.example.com/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "refresh_token", "client_id": "my-tv-app", "refresh_token": "a1b2c3d4e5f6..." }'Response:
{ "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "NEW_REFRESH_TOKEN_HERE", "scope": "media"}Save the returned refresh token and use it for the next refresh. client_id is optional
for older clients; send it so the server can verify the binding. A mismatch returns
400 invalid_grant, code: client_mismatch.
On the refresh path, invalid_grant with refresh_token_invalid,
refresh_token_revoked or refresh_token_expired is terminal: request device approval
again. Refresh expiry uses invalid_grant, not expired_token. Keep stored credentials
on a network failure, 429 rate_limited or 500 server_error; retry with backoff and
honour Retry-After (seconds). Never loop indefinitely after an API 401, and never
refresh in response to a 403 permission failure.
Complete example (TypeScript)
Section titled “Complete example (TypeScript)”This example handles code expiry, polling backoff, one refresh at a time, and one
retry after 401. Supply a secure store that persists both tokens together. RequestInit
bodies must be replayable strings or buffers; do not use a consumed stream as a body.
Use your generated types or validate response JSON at your app’s boundary.
interface Tokens { access_token: string; refresh_token: string; expires_in: number; token_type: string;}interface DeviceCode { device_code: string; user_code: string; verification_uri: string; verification_uri_complete: string; expires_in: number; interval: number;}interface OAuthError { error: string; error_description?: string; code?: string;}interface SecureStore { load(): Promise<Tokens | undefined>; save(tokens: Tokens): Promise<void>; // atomically, in platform secure storage}const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
class LumastraClient { private refreshing?: Promise<void>; constructor( private origin: string, private clientId: string, private store: SecureStore, ) {}
private oauth(path: string, fields: Record<string, string>): Promise<Response> { return fetch(new URL(path, this.origin), { method: 'POST', body: new URLSearchParams(fields), }); }
async authorize( deviceId: string, showCode: (code: DeviceCode) => void, signal?: AbortSignal, ): Promise<void> { const issuedAt = Date.now(); const issued = await this.oauth('/oauth/device', { client_id: this.clientId, device_id: deviceId, device_name: 'Living Room TV', scope: 'media', }); if (!issued.ok) throw new Error(`Device authorization failed: ${issued.status}`); const code: DeviceCode = await issued.json(); showCode(code); // Display only user_code / verification URIs. const deadline = issuedAt + code.expires_in * 1000; let intervalMs = code.interval * 1000; let waitMs = intervalMs; while (Date.now() + waitMs < deadline) { signal?.throwIfAborted(); await sleep(waitMs); signal?.throwIfAborted(); let response: Response; try { response = await this.oauth('/oauth/token', { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: code.device_code, client_id: this.clientId, }); } catch { waitMs = Math.min(Math.max(waitMs * 2, intervalMs), 60000); continue; } if (response.ok) { await this.store.save(await response.json()); return; } const failure: OAuthError = await response.json(); if (response.status === 429) { waitMs = Math.max(intervalMs, (Number(response.headers.get('Retry-After')) || 60) * 1000); } else if (response.status >= 500) { waitMs = Math.min(Math.max(waitMs * 2, intervalMs), 60000); } else if (failure.error === 'authorization_pending') { waitMs = intervalMs; } else if (failure.error === 'slow_down') { intervalMs += 5000; waitMs = intervalMs; } else { throw new Error(failure.code ?? failure.error); } } throw new Error('Device code expired; request a new code'); }
private refresh(): Promise<void> { if (this.refreshing) return this.refreshing; this.refreshing = (async () => { const tokens = await this.store.load(); if (!tokens) throw new Error('Device approval required'); const response = await this.oauth('/oauth/token', { grant_type: 'refresh_token', client_id: this.clientId, refresh_token: tokens.refresh_token, }); if (!response.ok) { // Let the caller distinguish terminal invalid_grant from retryable // network/429/5xx errors. Do not erase credentials here. const failure: OAuthError = await response.json(); throw Object.assign(new Error(failure.code ?? failure.error), { status: response.status, oauth: failure, retryAfter: response.headers.get('Retry-After'), }); } await this.store.save(await response.json()); })().finally(() => { this.refreshing = undefined; }); return this.refreshing; }
async api(path: string, options: RequestInit = {}): Promise<Response> { const previous = await this.store.load(); if (!previous) throw new Error('Device approval required'); const send = (tokens: Tokens) => { const headers = new Headers(options.headers); headers.set('Authorization', `Bearer ${tokens.access_token}`); return fetch(new URL(`/api/v1${path}`, this.origin), { ...options, headers }); }; const response = await send(previous); if (response.status !== 401) return response; // caller handles 204 and errors const latest = await this.store.load(); // A concurrent call may already have refreshed this credential. if (latest?.access_token === previous.access_token) await this.refresh(); const refreshed = await this.store.load(); if (!refreshed) throw new Error('Device approval required'); return send(refreshed); // one retry only; never recurse on another 401 }}Pass only a trusted server origin and relative API paths to this client. For a server
mounted below a path prefix, resolve the discovery links returned by /server instead
of using these root-relative example paths. Production apps should also refresh before
expiry using the issuance time plus expires_in, and bound requests with timeouts.
Error reference
Section titled “Error reference”OAuth errors use this envelope; /oauth/token also supplies a stable code extension:
{ "error": "invalid_grant", "error_description": "Invalid or expired refresh token", "code": "refresh_token_invalid" }error | HTTP | Meaning |
|---|---|---|
invalid_request | 400 | Invalid device request, or disabled device authorization. |
invalid_request | 413 | Body exceeds 256 KiB (code: payload_too_large on /oauth/token). |
invalid_grant | 400 | Invalid/consumed device code, client mismatch, or invalid/expired/revoked refresh token. Inspect code. |
authorization_pending | 400 | User hasn’t approved yet. Keep polling. |
slow_down | 400 | Polling too fast for this device code. Increase your interval. |
slow_down | 429 | Per-IP rate limit exceeded. Back off for Retry-After seconds (60). |
access_denied | 400 | User denied the request. |
expired_token | 400 | Device-code expiry when an expired record is found. Refresh-token expiry uses invalid_grant. |
unsupported_grant_type | 400 | No valid grant body matched; this also covers missing required token-grant fields. |
server_error | 500 | Internal error. Safe to retry with backoff. |
What your token can do
Section titled “What your token can do”The grant is fixed by the server, keyed off the scope you requested in Step 1,
never off who approved it. Two closed-form grants exist:
Requested scope | Resulting grant |
|---|---|
"media" (or omitted, or anything unrecognized) | Read and write |
"media:read" | Read only. Cannot start playback (403 forbidden, reason: scope_insufficient for device JWTs) |
A device-flow token from either grant carries:
| Scope | In media grant | In media:read grant | What it covers |
|---|---|---|---|
media:read | ✓ | ✓ | Browse the library, read metadata and images |
media:write | ✓ | — | Ratings, watchlist, playback progress |
sessions:read | ✓ | ✓ | Read your own play sessions |
requests:read | ✓ | ✓ | See your own media requests |
If your client only ever reads (a companion “now playing” display, a read-only
dashboard), request "media:read". The resulting token cannot write even if the
approving account could. The read-only method gate allows GET/HEAD only, so it
also rejects POST probes such as /media/{id}/playability, even though that endpoint
declares a read scope. A player should request media.
A device token is never an admin token
Section titled “A device token is never an admin token”Even when the approving user is a server administrator, the issued token
carries no administrative capability, regardless of the scope you requested. This
is deliberate: a TV is a shared-room device, frequently left signed in, with no lock
screen. Nobody should be able to walk up to a living-room remote and rescan libraries
or edit access groups.
Concretely, these all fail for a device-flow token regardless of the user’s role:
| Request | Response |
|---|---|
POST /api/v1/libraries/scan | 403 scope_required (tasks:run) |
POST /api/v1/libraries/ingest | 403 forbidden |
/api/v1/access/groups (CRUD) | 403 scope_required (access:admin) |
| Any other admin endpoint | 403 forbidden |
The approval screen shows the user what your client requested, what the token will actually be granted, and an explicit “no administrative access” note, before they authorize.
Not everything is gated by scope
Section titled “Not everything is gated by scope”Some endpoints are gated by a per-user permission that is independent of token scopes, so you cannot predict access from the scope list alone.
Submitting media requests is the common case. POST /api/v1/requests and
POST /api/v1/requests/{id}/dismissal require an authenticated user and are
not scope-gated: there is no requests:write scope, and the requests:read
in your grant is not what permits them. Request submission is gated by
canRequest in the permissions object of GET /api/v1/me. A device token can submit requests whenever that user
could from the web UI.
When it is denied you get:
{ "code": "forbidden", "status": 403, "error": "You do not have permission to submit media requests. Contact an administrator." }Note this is a per-user condition, not a per-token one: re-authorizing or
requesting different scopes will not change it, and the same user will be
denied in every client. Surface it as “your account can’t request media”, not
as a device or session problem. ?scope=all on GET /requests is the only
part of this area that is scope-gated (requests:admin), and a
device token will never have it.
Treat administrative capability failures as permanent for this credential.
Per-user permissions can change when the operator updates the account: do not retry, and do not trigger re-authorization.
If your app needs an administrative action, it needs a different credential (a
pat_ or svc_ token), not a different scope on this one.
GET /api/v1/events is not an admin endpoint despite delivering some
admin-ish frames. It is the caller’s own SSE stream and is available to
device tokens. It is where device commands (device.command) arrive.
Token kinds you may encounter
Section titled “Token kinds you may encounter”Device-flow JWTs aren’t the only credential accepted on /api/v1/*. If your
client interoperates with tokens created elsewhere (e.g. a companion web
session, or a token a user pastes in from Settings), you may see:
| Kind | Prefix | Typical origin |
|---|---|---|
| Device-flow access token | (none, compact JWT) | This flow. |
| Personal Access Token | pat_ | Created by a user in the Settings UI. |
| Service token | svc_ | Created by an admin for server-to-server automation. |
| Playback grant | stream_ | Issued with each playback session by POST /api/v1/play-sessions. Accepted only on media delivery URLs (?grant=), never on JSON calls. |
All non-JWT tokens (pat_, svc_) are shown to the user once
at creation time and stored server-side only as SHA-256 hashes. There’s no
way to retrieve the plaintext value later. See API & tokens
for the full breakdown of scopes and lifetimes for each kind.