Skip to content

Commit c9fb9bb

Browse files
committed
Add device authorization flow for TV-app-style pairing (RFC 8628)
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.
1 parent d79f2ae commit c9fb9bb

56 files changed

Lines changed: 2852 additions & 98 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Add client_device_identifier to devices
2+
3+
Revision ID: 0080_devices_client_identifier
4+
Revises: 0079_add_rom_files_rom_id_index
5+
Create Date: 2026-04-24 00:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "0080_devices_client_identifier"
14+
down_revision = "0079_add_rom_files_rom_id_index"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
with op.batch_alter_table("devices", schema=None) as batch_op:
21+
batch_op.add_column(
22+
sa.Column("client_device_identifier", sa.String(length=255), nullable=True),
23+
if_not_exists=True,
24+
)
25+
batch_op.create_index(
26+
"ix_devices_user_client_identifier",
27+
["user_id", "client_device_identifier"],
28+
unique=True,
29+
if_not_exists=True,
30+
)
31+
32+
33+
def downgrade() -> None:
34+
with op.batch_alter_table("devices", schema=None) as batch_op:
35+
batch_op.drop_index("ix_devices_user_client_identifier", if_exists=True)
36+
batch_op.drop_column("client_device_identifier", if_exists=True)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""Add device_id FK to client_tokens
2+
3+
Revision ID: 0081_client_tokens_device_id
4+
Revises: 0080_devices_client_identifier
5+
Create Date: 2026-04-24 00:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
12+
# revision identifiers, used by Alembic.
13+
revision = "0081_client_tokens_device_id"
14+
down_revision = "0080_devices_client_identifier"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
with op.batch_alter_table("client_tokens", schema=None) as batch_op:
21+
batch_op.add_column(
22+
sa.Column("device_id", sa.String(length=255), nullable=True),
23+
if_not_exists=True,
24+
)
25+
batch_op.create_foreign_key(
26+
"fk_client_tokens_device_id",
27+
"devices",
28+
["device_id"],
29+
["id"],
30+
ondelete="SET NULL",
31+
)
32+
batch_op.create_index(
33+
"ix_client_tokens_device_id",
34+
["device_id"],
35+
unique=False,
36+
if_not_exists=True,
37+
)
38+
39+
40+
def downgrade() -> None:
41+
with op.batch_alter_table("client_tokens", schema=None) as batch_op:
42+
# Drop FK before the backing index -- MariaDB refuses otherwise
43+
batch_op.drop_constraint("fk_client_tokens_device_id", type_="foreignkey")
44+
batch_op.drop_index("ix_client_tokens_device_id", if_exists=True)
45+
batch_op.drop_column("device_id", if_exists=True)

0 commit comments

Comments
 (0)