Add device authorization flow for TV-app-style pairing (RFC 8628)#3308
Open
tmgast wants to merge 1 commit into
Open
Add device authorization flow for TV-app-style pairing (RFC 8628)#3308tmgast wants to merge 1 commit into
tmgast wants to merge 1 commit into
Conversation
Contributor
There was a problem hiding this comment.
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
ClientTokenrecords toDevicevia newclient_tokens.device_idFK, plus device dedupe viadevices.client_device_identifier. - Update auth + downstream endpoints to infer
device_idfrom 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.
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.
c143807 to
c9fb9bb
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 shortuser_codeplus a QR-ready URL. The user scans the QR on their phone, lands on/pair/devicein 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 aClientTokenthat's bound 1:1 to a new (or deduped)Devicerecord. Downstream endpoints (/play-sessions,/sync/negotiate) inferdevice_idfrom the bound token so the client doesn't have to ship it on every call.Features
device_idinference: bound tokens surfacedevice_idonrequest.stateso downstream endpoints pick it up for freeclient_device_identifier; re-running the flow reuses the same Device rowGET /users/mereturnscurrent_device_idfor bound tokens so a device can identify itself from its token aloneslow_down/authorization_pending/expired_token/access_deniederrorswindow.close()Note
The new flow is purely additive. The existing
/client-tokenscreate + pair + exchange path from PR #3114 remains unchanged for users who want CLI/script tokens.New Models
ClientToken— added columndevice_idstr?(FK →devices.id)ON DELETE SET NULL— revoking a device unbinds rather than cascading.Device— added columnclient_device_identifierstr?DeviceAuthInitResponsedevice_codestruser_codestrverification_urlstr/pair/deviceverification_url_completestr?user_code=…appendedexpires_inintintervalintDeviceAuthPendingSchemaclient_device_identifierstrname/client/platform/client_versionstrrequested_scopeslist[str]allowed_scopeslist[str]requested ∩ user.oauth_scopes— what the UI pre-selectsexpires_atdatetimeDeviceAuthTokenResponseaccess_tokenstrrmm_-prefixed bearer tokendevice_idstrscopeslist[str]expires_atdatetime?nullfor never-expiringNew API Endpoints
All under
/api/auth/device/*.POST /api/auth/device/initOpen, per-IP rate-limited (10/60s). Initiates a pairing flow.
GET /api/auth/device/pending/{user_code}Auth required (
Scope.ME_READ). Web UI fetches pending metadata to render the approval screen.Returns
404for unknown/expired codes,410once already approved/denied.POST /api/auth/device/approveAuth required (
Scope.ME_WRITE). User confirms pairing, optionally editing name/scopes/expiry.Returns
403ifapproved_scopesexceedrequested ∩ user.oauth_scopes.POST /api/auth/device/denyAuth required (
Scope.ME_WRITE). Rejects the pairing. TTL shortened to 60s.POST /api/auth/device/tokenOpen, per-IP rate-limited (60/60s). Device polls to retrieve the credential.
Updated:
GET /api/users/meNow returns
current_device_idfor 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
HybridAuthBackendatbackend/handler/auth/hybrid_auth.pygains two behaviors when it resolves anrmm_-prefixed bearer token:conn.state.device_idfromclient_token.device_id(may benullfor legacy tokens)devices.last_seenviaDBDevicesHandler.update_last_seen_debouncedwhen the device is bound — 5-minute debounce, same cost profile as the existingupdate_last_usedfor the token itselfEndpoints that accept a
device_idin their payload (play_sessions.py,sync.py) fall back torequest.state.device_idwhen 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 deviceState transitions (Redis blob at
device_auth:dc:{device_code})pending/initauthorization_pendingapproved/approveGETDELdenied/denyaccess_deniedexpired_tokenRecommended Client Flow
Pseudocode covering the full lifecycle including expiry + restart. Implement on the device side.
For long-lived tokens (
expires_at: null), the device never needs to restart until the token is revoked server-side. When calls begin returning401, run this flow again — dedupe-by-identifier means the same Device row is reused, preserving the device's session history.Security
request.base_urlonly. Removes thejavascript:/data:XSS surface that the existing/pairpage has to defend against./init10/60s,/token60/60s). 429 on overflow.device_codepoll pacing returns RFC 8628slow_downwhen a single flow polls faster thaninterval.device_id— client supplies only the opaqueclient_device_identifier; server generates the UUID. No cross-user squatting on the primary key.approved ⊆ requested ∩ user.oauth_scopes. UI filters too, but server is the source of truth.device_code→expired_token(same response as TTL-expired). No timing oracle distinguishing "exists but pending" from "never existed."GETDELon first successful/tokenresponse; replays returnexpired_token.device_code_prefix(first 8 chars) is logged; full secret never appears.^/api/auth/device/(init|token)/?$) only on the two open endpoints. Authenticated endpoints keep CSRF protection.Frontend
New approval screen at
/pair/device/login?next=…)client_device_identifiershown in small muted monospace for user verificationallowed_scopes; scopes the device requested but the user lacks appear disabled in a distinct warning colorwindow.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.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.jsonfiles;check_i18n_locales.pypasses.Database Changes
devices.client_device_identifier+(user_id, client_device_identifier)indexclient_tokens.device_idFK todevices.id(ON DELETE SET NULL) + index. Downgrade drops the FK before its backing index (MariaDB requires this).Both use
batch_alter_tablewithif_not_exists/if_existsflags, MariaDB- and PostgreSQL-safe.Checklist