Skip to content

Add device authorization flow for TV-app-style pairing (RFC 8628)#3308

Open
tmgast wants to merge 1 commit into
rommapp:masterfrom
tmgast:feature/device-flow-authorization
Open

Add device authorization flow for TV-app-style pairing (RFC 8628)#3308
tmgast wants to merge 1 commit into
rommapp:masterfrom
tmgast:feature/device-flow-authorization

Conversation

@tmgast
Copy link
Copy Markdown
Member

@tmgast tmgast commented Apr 24, 2026

Description

Adds an RFC 8628-style device authorization flow so clients (argosy-launcher and grout) can pair themselves by display rather than requiring the user to manually create a client token and copy it into the device.

A device hits an unauthenticated endpoint with its identifier, client type, and the scopes it wants. The server returns a long device_code (secret, for polling) and a short user_code plus a QR-ready URL. The user scans the QR on their phone, lands on /pair/device in the web UI, logs in if needed, and approves — optionally editing the device name, trimming scopes, and choosing an expiry. The device's next poll retrieves a ClientToken that's bound 1:1 to a new (or deduped) Device record. Downstream endpoints (/play-sessions, /sync/negotiate) infer device_id from the bound token so the client doesn't have to ship it on every call.

Features

  • Device-initiated pairing: no manual copy/paste of tokens — the device drives the handshake
  • 1:1 token↔device binding: every token issued via this flow is linked to its Device
  • Automatic device_id inference: bound tokens surface device_id on request.state so downstream endpoints pick it up for free
  • Dedupe on re-install: clients supply a stable client_device_identifier; re-running the flow reuses the same Device row
  • Editable scope approval: user sees exactly what the device requested, can uncheck items
  • Whoami recovery: GET /users/me returns current_device_id for bound tokens so a device can identify itself from its token alone
  • TV-app UX: QR + polling with RFC-compliant slow_down / authorization_pending / expired_token / access_denied errors
  • Never-expire default: suited for long-lived handhelds; 30d / 90d / 1y still selectable
  • Auto-close: approval tab counts down 3s then calls window.close()

Note

The new flow is purely additive. The existing /client-tokens create + pair + exchange path from PR #3114 remains unchanged for users who want CLI/script tokens.


New Models

ClientToken — added column

Field Type Description
device_id str? (FK → devices.id) Non-null when the token was issued via the device-auth flow. Nullable for legacy/manual tokens. ON DELETE SET NULL — revoking a device unbinds rather than cascading.

Device — added column

Field Type Description
client_device_identifier str? Client-supplied stable ID (install UUID, hardware ID). Used to dedupe re-registrations of the same device across token resets. Scoped per-user via compound index.

DeviceAuthInitResponse

Field Type Description
device_code str 64-char secret; client keeps private, uses to poll
user_code str 8-char human-friendly code from ambiguous-glyph-stripped alphabet
verification_url str Server origin + /pair/device
verification_url_complete str With ?user_code=… appended
expires_in int 600
interval int 5

DeviceAuthPendingSchema

Field Type Description
client_device_identifier str For user verification on the approval screen
name / client / platform / client_version str From the device's init call
requested_scopes list[str] Everything the device asked for
allowed_scopes list[str] requested ∩ user.oauth_scopes — what the UI pre-selects
expires_at datetime Derived from current Redis TTL

DeviceAuthTokenResponse

Field Type Description
access_token str rmm_-prefixed bearer token
device_id str Bound device's UUID
scopes list[str] Approved subset
expires_at datetime? null for never-expiring

New API Endpoints

All under /api/auth/device/*.

POST /api/auth/device/init

Open, per-IP rate-limited (10/60s). Initiates a pairing flow.

REQUEST
{
  "client_device_identifier": "argosy-abc123",
  "name": "AYN Thor",
  "client": "argosy-launcher",
  "platform": "Android",
  "client_version": "0.16.0",
  "requested_scopes": [
    "me.read", "me.write",
    "platforms.read", "roms.read",
    "assets.read", "assets.write",
    "roms.user.read", "roms.user.write",
    "collections.read", "collections.write"
  ]
}

RESPONSE (201)
{
  "device_code": "<64 hex chars>",
  "user_code": "ABCD1234",
  "verification_url": "https://romm.example/pair/device",
  "verification_url_complete": "https://romm.example/pair/device?user_code=ABCD1234",
  "expires_in": 600,
  "interval": 5
}

GET /api/auth/device/pending/{user_code}

Auth required (Scope.ME_READ). Web UI fetches pending metadata to render the approval screen.

Returns 404 for unknown/expired codes, 410 once already approved/denied.

POST /api/auth/device/approve

Auth required (Scope.ME_WRITE). User confirms pairing, optionally editing name/scopes/expiry.

REQUEST
{
  "user_code": "ABCD1234",
  "approved_scopes": ["roms.read", "roms.user.write"],
  "device_name": "Retroid Pocket 6",
  "expires_in": "never"
}

RESPONSE (200)
{ "device_id": "uuid-...", "device_name": "Retroid Pocket 6" }

Returns 403 if approved_scopes exceed requested ∩ user.oauth_scopes.

POST /api/auth/device/deny

Auth required (Scope.ME_WRITE). Rejects the pairing. TTL shortened to 60s.

POST /api/auth/device/token

Open, per-IP rate-limited (60/60s). Device polls to retrieve the credential.

REQUEST { "device_code": "..." }

RESPONSE on approval (200, one-shot):
{
  "access_token": "rmm_...",
  "device_id": "uuid-...",
  "scopes": ["roms.read", "roms.user.write"],
  "expires_at": null
}

RESPONSE otherwise (400):
{ "detail": "authorization_pending" | "slow_down" | "access_denied" | "expired_token" }

Updated: GET /api/users/me

Now returns current_device_id for callers authenticated via a device-bound client token (in addition to the existing web-session source). Lets a device identify itself from its token alone.


Auth Integration

The existing HybridAuthBackend at backend/handler/auth/hybrid_auth.py gains two behaviors when it resolves an rmm_-prefixed bearer token:

  1. Sets conn.state.device_id from client_token.device_id (may be null for legacy tokens)
  2. Bumps devices.last_seen via DBDevicesHandler.update_last_seen_debounced when the device is bound — 5-minute debounce, same cost profile as the existing update_last_used for the token itself

Endpoints that accept a device_id in their payload (play_sessions.py, sync.py) fall back to request.state.device_id when the caller omits it. Explicit payload values always win for back-compat.


Device Flow Details

sequenceDiagram
    participant D as Device (argosy-launcher / grout)
    participant S as RomM Server
    participant R as Redis
    participant U as User on phone

    D->>S: POST /api/auth/device/init
    Note over S: Rate limit 10/IP/60s
    S->>R: SETEX device_auth:dc:{code} 600s
    S->>R: SETEX device_auth:uc:{user_code} 600s
    S-->>D: {device_code, user_code, verification_url_complete, expires_in, interval}

    D->>D: render QR of verification_url_complete

    loop poll while pending
        D->>S: POST /api/auth/device/token {device_code}
        S->>R: GET device_auth:dc:{code}
        R-->>S: {status: pending}
        S-->>D: 400 {detail: "authorization_pending"}
        D->>D: sleep(interval)
    end

    U->>U: scan QR, phone browser opens verification_url_complete
    U->>S: GET /pair/device?user_code=...
    Note over U,S: Vue view, login bounce if needed
    U->>S: GET /api/auth/device/pending/{user_code}
    S-->>U: {name, client, requested_scopes, allowed_scopes, ...}
    U->>U: edit name/scopes/expiry, click Approve
    U->>S: POST /api/auth/device/approve
    Note over S: Scope clamp: approved in (requested intersect user.oauth_scopes)
    S->>S: find-or-create Device by (user_id, client_device_identifier)
    S->>S: create ClientToken bound to device
    S->>R: SETEX device_auth:dc:{code} {status: approved, raw_token, device_id}
    S->>R: DEL device_auth:uc:{user_code}
    S-->>U: {device_id, device_name}
    U->>U: countdown 3s, window.close()

    D->>S: POST /api/auth/device/token {device_code} (next poll)
    S->>R: GETDEL device_auth:dc:{code}
    R-->>S: {status: approved, raw_token, ...}
    S-->>D: {access_token, device_id, scopes, expires_at}

    D->>S: POST /api/play-sessions (no device_id in payload)
    Note over S: HybridAuthBackend sets request.state.device_id
    S-->>D: 201 — session attached to bound device
Loading

State transitions (Redis blob at device_auth:dc:{device_code})

State Entered by Response on poll Retention
pending /init authorization_pending 600s (10 min hard ceiling)
approved /approve 200 once, then deleted via GETDEL Same TTL; wiped on first successful poll
denied /deny access_denied Shortened to 60s
(gone) TTL or one-shot read expired_token n/a

Recommended Client Flow

Pseudocode covering the full lifecycle including expiry + restart. Implement on the device side.

loop forever:
    # 1. Request a code
    init = POST /api/auth/device/init {
        client_device_identifier: stable_install_id(),
        name: device_display_name(),
        client: "argosy-launcher",
        platform: "Android",
        client_version: "0.16.0",
        requested_scopes: [
            "me.read", "me.write",
            "platforms.read", "roms.read",
            "assets.read", "assets.write",
            "roms.user.read", "roms.user.write",
            "collections.read", "collections.write",
        ],
    }

    display_qr(init.verification_url_complete)
    interval = init.interval
    deadline = now() + init.expires_in

    # 2. Poll for the token
    while now() < deadline:
        sleep(interval)
        resp = POST /api/auth/device/token { device_code: init.device_code }
        match resp.status, resp.detail:
            200, _:                       break   # 200 -> token in hand
            400, "authorization_pending": continue
            400, "slow_down":             interval += 5; continue
            400, "access_denied":         show_user_denied(); return
            400, "expired_token":         break   # restart from step 1

    if got_token:
        store_token(resp.access_token)
        store_device_id(resp.device_id)
        return
    else:
        # Expired or denied -> user can retry
        show_user_retry_prompt()

For long-lived tokens (expires_at: null), the device never needs to restart until the token is revoked server-side. When calls begin returning 401, run this flow again — dedupe-by-identifier means the same Device row is reused, preserving the device's session history.


Security

  • No user-supplied callback URLs — verification URLs are built from request.base_url only. Removes the javascript: / data: XSS surface that the existing /pair page has to defend against.
  • Per-IP rate limits on both open endpoints (/init 10/60s, /token 60/60s). 429 on overflow.
  • Per-device_code poll pacing returns RFC 8628 slow_down when a single flow polls faster than interval.
  • Server-owned device_id — client supplies only the opaque client_device_identifier; server generates the UUID. No cross-user squatting on the primary key.
  • Scope clamping enforced server-side as approved ⊆ requested ∩ user.oauth_scopes. UI filters too, but server is the source of truth.
  • Unknown device_codeexpired_token (same response as TTL-expired). No timing oracle distinguishing "exists but pending" from "never existed."
  • One-shot approved state — atomic GETDEL on first successful /token response; replays return expired_token.
  • 10-minute hard ceiling — no state persists past the original Redis TTL, regardless of transitions.
  • Log hygiene — only device_code_prefix (first 8 chars) is logged; full secret never appears.
  • CSRF exempt (anchored ^/api/auth/device/(init|token)/?$) only on the two open endpoints. Authenticated endpoints keep CSRF protection.

Frontend

New approval screen at /pair/device

  • Auth-gated (global guard bounces unauthenticated users through /login?next=…)
  • Editable device name field, pre-filled from the init request
  • Client/platform chips (read-only)
  • client_device_identifier shown in small muted monospace for user verification
  • Scopes as filter-chips pre-selected with server-computed allowed_scopes; scopes the device requested but the user lacks appear disabled in a distinct warning color
  • Expiry dropdown: 30d / 90d / 1y / Never (defaults to Never)
  • On approve: 3-second countdown then window.close(). Degrades gracefully when the tab wasn't opened by script (typical for camera-scanned QRs) — the success message + countdown read naturally even without the close firing.
  • No callback/redirect URL parameters accepted; the device polls independently.

Settings → Client API Tokens

Existing tokens list now shows a "linked device" chip next to each bound token. Manual tokens render exactly as before.

i18n

20 new keys added to all 17 locale settings.json files; check_i18n_locales.py passes.


Database Changes

  • Migration 0080_devices_client_identifierdevices.client_device_identifier + (user_id, client_device_identifier) index
  • Migration 0081_client_tokens_device_idclient_tokens.device_id FK to devices.id (ON DELETE SET NULL) + index. Downgrade drops the FK before its backing index (MariaDB requires this).

Both use batch_alter_table with if_not_exists / if_exists flags, MariaDB- and PostgreSQL-safe.


AI Disclosure
Planning and review assisted by Claude Code

Checklist

  • I've tested the changes locally (flow walked end-to-end against a fresh dev stack)
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes (52 new tests across 13 classes; full suite 1333 passed)

Copilot AI review requested due to automatic review settings April 24, 2026 13:47
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an RFC 8628-style device authorization (QR + short-code + polling) pairing flow, issuing client tokens bound 1:1 to Devices and enabling implicit device_id inference for downstream endpoints.

Changes:

  • Add /api/auth/device/* endpoints with Redis-backed state machine, rate limiting, and scope/expiry approval.
  • Bind issued ClientToken records to Device via new client_tokens.device_id FK, plus device dedupe via devices.client_device_identifier.
  • Update auth + downstream endpoints to infer device_id from bound tokens; add frontend pairing screen and token list “linked device” chip.

Reviewed changes

Copilot reviewed 43 out of 56 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
frontend/src/views/Auth/DevicePair.vue New approval UI for device pairing (/pair/device)
frontend/src/services/api/device-auth.ts Frontend API wrapper for device-auth endpoints
frontend/src/services/api/client-token.ts Expose device_id on token schema
frontend/src/plugins/router.ts Add /pair/device route
frontend/src/locales/zh_TW/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/zh_CN/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/ru_RU/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/ro_RO/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/pt_BR/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/pl_PL/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/ko_KR/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/ja_JP/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/it_IT/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/hu_HU/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/fr_FR/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/es_ES/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/en_US/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/en_GB/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/de_DE/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/cs_CZ/settings.json Add device-auth + cleanup i18n keys
frontend/src/locales/bg_BG/settings.json Add device-auth + cleanup i18n keys
frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue Show linked device chip for device-bound tokens
frontend/src/generated/models/DeviceSchema.ts Include client_device_identifier in device schema
frontend/src/generated/models/DeviceAuthTokenResponse.ts Generated types for token polling response
frontend/src/generated/models/DeviceAuthTokenPayload.ts Generated types for token polling payload
frontend/src/generated/models/DeviceAuthPendingSchema.ts Generated types for pending approval metadata
frontend/src/generated/models/DeviceAuthInitResponse.ts Generated types for init response
frontend/src/generated/models/DeviceAuthInitPayload.ts Generated types for init payload
frontend/src/generated/models/DeviceAuthDenyPayload.ts Generated types for deny payload
frontend/src/generated/models/DeviceAuthApproveResponse.ts Generated types for approve response
frontend/src/generated/models/DeviceAuthApprovePayload.ts Generated types for approve payload
frontend/src/generated/models/ClientTokenSchema.ts Generated token schema includes device_id
frontend/src/generated/models/ClientTokenCreateSchema.ts Generated token create schema includes device_id
frontend/src/generated/models/ClientTokenAdminSchema.ts Generated admin token schema includes device_id
frontend/src/generated/index.ts Export generated device-auth models
backend/utils/device_auth.py Redis state + rate limiting helpers for device flow
backend/utils/client_tokens.py Include device_id in token schemas
backend/tests/handler/database/test_devices_handler.py Tests for device dedupe + last_seen debounce
backend/tests/handler/auth/test_auth.py Tests for auth state device_id + last_seen bump
backend/tests/endpoints/test_device_auth.py End-to-end tests for device auth flow
backend/tests/endpoints/test_device.py Ensure device schema exposes client_device_identifier
backend/tests/endpoints/test_client_tokens.py Assert manual tokens are unbound (device_id null)
backend/models/device.py Add client_device_identifier column
backend/models/client_token.py Add device_id FK + relationship
backend/main.py Register device-auth router + CSRF exemptions for open endpoints
backend/handler/database/devices_handler.py Add dedupe lookup + debounced last_seen update
backend/handler/auth/hybrid_auth.py Populate conn.state.device_id from bound tokens + bump device last_seen
backend/endpoints/sync.py Allow implicit device_id via bound token; enforce if missing
backend/endpoints/responses/identity.py Surface current_device_id from token-bound state or session
backend/endpoints/responses/device_auth.py Pydantic request/response models for device auth
backend/endpoints/responses/device.py Add client_device_identifier to API schema
backend/endpoints/responses/client_token.py Add device_id to token schema
backend/endpoints/play_sessions.py Infer device_id from bound token when omitted
backend/endpoints/device_auth.py New /api/auth/device/* endpoints implementing RFC8628-like flow
backend/alembic/versions/0081_client_tokens_device_id.py Migration: add client_tokens.device_id FK + index
backend/alembic/versions/0080_devices_client_device_identifier.py Migration: add devices.client_device_identifier + compound index
Files not reviewed (13)
  • frontend/src/generated/index.ts: Language not supported
  • frontend/src/generated/models/ClientTokenAdminSchema.ts: Language not supported
  • frontend/src/generated/models/ClientTokenCreateSchema.ts: Language not supported
  • frontend/src/generated/models/ClientTokenSchema.ts: Language not supported
  • frontend/src/generated/models/DeviceAuthApprovePayload.ts: Language not supported
  • frontend/src/generated/models/DeviceAuthApproveResponse.ts: Language not supported
  • frontend/src/generated/models/DeviceAuthDenyPayload.ts: Language not supported
  • frontend/src/generated/models/DeviceAuthInitPayload.ts: Language not supported
  • frontend/src/generated/models/DeviceAuthInitResponse.ts: Language not supported
  • frontend/src/generated/models/DeviceAuthPendingSchema.ts: Language not supported
  • frontend/src/generated/models/DeviceAuthTokenPayload.ts: Language not supported
  • frontend/src/generated/models/DeviceAuthTokenResponse.ts: Language not supported
  • frontend/src/generated/models/DeviceSchema.ts: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread backend/alembic/versions/0080_devices_client_device_identifier.py Outdated
Comment thread frontend/src/views/Auth/DevicePair.vue Outdated
Comment thread frontend/src/views/Auth/DevicePair.vue
Comment thread backend/endpoints/device_auth.py Outdated
Implements RFC 8628-style device authorization so clients
(argosy-launcher, grout) can pair by display instead of manually
copying tokens. Device posts to an open /api/auth/device/init with
its identifier and requested scopes; the server returns device_code
+ user_code + QR URL. User scans QR, lands at /pair/device, approves
(optionally editing name/scopes/expiry); the device's next poll on
/api/auth/device/token returns a ClientToken bound 1:1 to a newly-
created (or deduped) Device record. Downstream endpoints
(/play-sessions, /sync/negotiate) infer device_id from the bound
token so the client doesn't have to ship it on every call.

- Migrations 0080/0081: devices.client_device_identifier (unique
  per user) and client_tokens.device_id FK (ON DELETE SET NULL)
- Five new endpoints under /api/auth/device (init/pending/approve/
  deny/token) with Redis-backed state, per-IP rate limits, and
  RFC-compliant error codes (authorization_pending, slow_down,
  expired_token, access_denied)
- HybridAuthBackend surfaces bound device_id on request.state and
  bumps devices.last_seen with a 5-minute debounce
- /api/users/me returns current_device_id for bound tokens so a
  device can identify itself from its token alone
- Frontend approval screen at /pair/device with editable scopes/
  name/expiry (defaults to Never), 3s auto-close countdown
- ClientApiTokens settings list shows bound-device chip
- 20 i18n keys added to all 17 locales; generated models updated
- 52 new tests across 13 classes; full suite 1334 passed

Planning and review assisted by Claude Code.
@tmgast tmgast force-pushed the feature/device-flow-authorization branch from c143807 to c9fb9bb Compare April 24, 2026 14:11
@gantoine gantoine self-requested a review April 25, 2026 14:46
@gantoine gantoine added the on-hold Pending further research or blocked by another issue label Apr 25, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

on-hold Pending further research or blocked by another issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants