Skip to content

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.

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.

OAuth device authorization

Scroll, swipe, or click and drag to explore. Use the arrows to move, or fit the diagram above.

OAuth device authorization TV app / CLI client requests + polls POST /oauth/device issues device_code ·user_code User's browser opens /device Lumastra consent screen · recordsapproval POST /oauth/token polled at `interval` /api/v1 surface Bearer JWT · scope-gated 1. client_id · scope device_code · user_code 2. shows user_code / QR sign in + approve 3. poll device_code approval recorded access_token + refresh_token 4. Bearer <access_token>
  1. POST /oauth/device → device_code + user_code + verification_uri
  2. Show the user_code (or a QR code) → the user approves at /device in a browser
  3. Poll POST /oauth/token → access_token (JWT) + refresh_token
  4. Authorization: Bearer <token> → call /api/v1/*
  5. POST /oauth/token (refresh) → new access_token + rotated refresh_token
Terminal window
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:

FieldTypeRequiredDescription
client_idstringYesA free-form identifier for your app (e.g. "apple-tv-app"). No registration needed.
device_namestringNoHuman-readable device name shown during approval.
device_idstringNoGenerate 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.
scopestringNoOne 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
}
FieldDescription
device_codeSecret code your client polls with. Never display it to the user.
user_codeShort code the user enters manually. Display this prominently.
verification_uriThe page where the user approves the request.
verification_uri_completeSame URL with the code pre-filled. Encode this as a QR code.
expires_inSeconds until both codes expire (default 600).
intervalMinimum 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.


Show the user how to authorize the device. Two options, and most clients offer both:

  • QR code (recommended for TVs). Encode verification_uri_complete as a QR code. Scanning it takes the user straight to a pre-filled approval page on their phone.
  • Manual entry. Display verification_uri and user_code for 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).

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.

FieldTypeMeaning
user_codestringEight letters or digits, optionally separated by a dash after the fourth character. Case-insensitive. Send the displayed user code, never the secret device_code.
approvebooleantrue approves the sign-in; false denies it.
Terminal window
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}'
HTTPResponse or meaning
200{ "approved": true } for approval, or { "approved": false } for denial.
401Authentication required, invalid credentials, or account disabled or deleted.
403Insufficient scope, refused credential or session, or self-approval.
404Unknown, expired, already decided, or consumed code; also returned when device authorization is disabled.
413Body exceeds 1,024 bytes.
422Invalid body, including malformed JSON, an invalid code format, or a missing/non-boolean approve.
429Shared approval budget exceeded. Honour Retry-After in seconds.
500 / 503Internal 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.


While the approval screen is displayed, poll the token endpoint at the interval returned in Step 1:

Terminal window
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 interval seconds between requests.
  • Stop polling once expires_in seconds have elapsed since Step 1. Restart the flow from scratch if you need a new code.
  • Branch on error and, when supplied, the stable code extension. error_description is display text; never parse it to make decisions.

Responses:

HTTPerrorMeaningWhat to do
200(none)SuccessStore the tokens (Step 4) and stop polling.
400authorization_pendingUser hasn’t approved yetKeep polling at the same interval.
400slow_downPolling too frequently for this device codeAdd 5 seconds to your interval and keep polling.
429slow_downPer-IP rate limit (30 requests/minute on /oauth/token)Honour Retry-After (60 s) before polling again.
400access_deniedUser rejected the requestStop polling; offer to restart the flow.
400expired_tokenAn expired device-code record was foundStop polling; request a new device code.
400invalid_grantUnknown, cleaned-up expired, or consumed code; mismatched clientStop polling; check code and restart authorization if needed.
500server_errorTransient server failureRetry 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.


TokenLifetimePurpose
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_token90 days by default (JWT_REFRESH_TOKEN_TTL_DAYS), rotated on every useUsed 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/logout revokes the current device session. Account disable and explicit device revocation also end access. Do not rely on JWT expiry alone.

Use the access token as a standard bearer credential against the /api/v1/* token surface:

Terminal window
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.


Refresh before the access token expires (or reactively on a 401):

Terminal window
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.


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.


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" }
errorHTTPMeaning
invalid_request400Invalid device request, or disabled device authorization.
invalid_request413Body exceeds 256 KiB (code: payload_too_large on /oauth/token).
invalid_grant400Invalid/consumed device code, client mismatch, or invalid/expired/revoked refresh token. Inspect code.
authorization_pending400User hasn’t approved yet. Keep polling.
slow_down400Polling too fast for this device code. Increase your interval.
slow_down429Per-IP rate limit exceeded. Back off for Retry-After seconds (60).
access_denied400User denied the request.
expired_token400Device-code expiry when an expired record is found. Refresh-token expiry uses invalid_grant.
unsupported_grant_type400No valid grant body matched; this also covers missing required token-grant fields.
server_error500Internal error. Safe to retry with backoff.

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 scopeResulting 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:

ScopeIn media grantIn media:read grantWhat 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.

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:

RequestResponse
POST /api/v1/libraries/scan403 scope_required (tasks:run)
POST /api/v1/libraries/ingest403 forbidden
/api/v1/access/groups (CRUD)403 scope_required (access:admin)
Any other admin endpoint403 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.

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.


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:

KindPrefixTypical origin
Device-flow access token(none, compact JWT)This flow.
Personal Access Tokenpat_Created by a user in the Settings UI.
Service tokensvc_Created by an admin for server-to-server automation.
Playback grantstream_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.