mirror of
https://github.com/rommapp/romm.git
synced 2026-07-01 08:16:21 +00:00
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.
74 lines
2.4 KiB
Python
74 lines
2.4 KiB
Python
from __future__ import annotations
|
|
|
|
import enum
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import TYPE_CHECKING
|
|
|
|
from sqlalchemy import JSON, TIMESTAMP, Boolean, Enum, ForeignKey, String
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from models.base import BaseModel
|
|
|
|
if TYPE_CHECKING:
|
|
from models.device_save_sync import DeviceSaveSync
|
|
from models.user import User
|
|
|
|
|
|
class SyncMode(enum.StrEnum):
|
|
API = "api"
|
|
FILE_TRANSFER = "file_transfer"
|
|
PUSH_PULL = "push_pull"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DeviceType:
|
|
"""A preconfigured device type with its client identifier and sync mode."""
|
|
|
|
platform: str
|
|
client: str
|
|
sync_mode: SyncMode
|
|
|
|
|
|
KNOWN_DEVICES: dict[str, DeviceType] = {
|
|
"web": DeviceType(platform="Web", client="web", sync_mode=SyncMode.API),
|
|
"grout": DeviceType(platform="muOS", client="grout", sync_mode=SyncMode.API),
|
|
"argosy-launcher": DeviceType(
|
|
platform="Android", client="argosy-launcher", sync_mode=SyncMode.API
|
|
),
|
|
}
|
|
|
|
|
|
class Device(BaseModel):
|
|
__tablename__ = "devices"
|
|
__table_args__ = {"extend_existing": True}
|
|
|
|
id: Mapped[str] = mapped_column(String(255), primary_key=True)
|
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
|
|
|
name: Mapped[str | None] = mapped_column(String(255))
|
|
platform: Mapped[str | None] = mapped_column(String(50))
|
|
client: Mapped[str | None] = mapped_column(String(50))
|
|
client_version: Mapped[str | None] = mapped_column(String(50))
|
|
|
|
ip_address: Mapped[str | None] = mapped_column(String(45))
|
|
mac_address: Mapped[str | None] = mapped_column(String(17))
|
|
hostname: Mapped[str | None] = mapped_column(String(255))
|
|
|
|
# Stable identifier supplied by the client itself (install UUID, hardware ID),
|
|
# used to dedupe re-registrations of the same device across token resets.
|
|
client_device_identifier: Mapped[str | None] = mapped_column(String(255))
|
|
|
|
sync_mode: Mapped[SyncMode] = mapped_column(Enum(SyncMode), default=SyncMode.API)
|
|
sync_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
|
sync_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
|
|
last_seen: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
|
|
|
user: Mapped[User] = relationship(lazy="joined")
|
|
save_syncs: Mapped[list[DeviceSaveSync]] = relationship(
|
|
back_populates="device",
|
|
cascade="all, delete-orphan",
|
|
lazy="raise",
|
|
)
|