diff --git a/backend/alembic/versions/0080_devices_client_device_identifier.py b/backend/alembic/versions/0080_devices_client_device_identifier.py new file mode 100644 index 0000000000..a75b03ae23 --- /dev/null +++ b/backend/alembic/versions/0080_devices_client_device_identifier.py @@ -0,0 +1,36 @@ +"""Add client_device_identifier to devices + +Revision ID: 0080_devices_client_identifier +Revises: 0079_add_rom_files_rom_id_index +Create Date: 2026-04-24 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0080_devices_client_identifier" +down_revision = "0079_add_rom_files_rom_id_index" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("devices", schema=None) as batch_op: + batch_op.add_column( + sa.Column("client_device_identifier", sa.String(length=255), nullable=True), + if_not_exists=True, + ) + batch_op.create_index( + "ix_devices_user_client_identifier", + ["user_id", "client_device_identifier"], + unique=True, + if_not_exists=True, + ) + + +def downgrade() -> None: + with op.batch_alter_table("devices", schema=None) as batch_op: + batch_op.drop_index("ix_devices_user_client_identifier", if_exists=True) + batch_op.drop_column("client_device_identifier", if_exists=True) diff --git a/backend/alembic/versions/0081_client_tokens_device_id.py b/backend/alembic/versions/0081_client_tokens_device_id.py new file mode 100644 index 0000000000..b3204d0867 --- /dev/null +++ b/backend/alembic/versions/0081_client_tokens_device_id.py @@ -0,0 +1,45 @@ +"""Add device_id FK to client_tokens + +Revision ID: 0081_client_tokens_device_id +Revises: 0080_devices_client_identifier +Create Date: 2026-04-24 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0081_client_tokens_device_id" +down_revision = "0080_devices_client_identifier" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("client_tokens", schema=None) as batch_op: + batch_op.add_column( + sa.Column("device_id", sa.String(length=255), nullable=True), + if_not_exists=True, + ) + batch_op.create_foreign_key( + "fk_client_tokens_device_id", + "devices", + ["device_id"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_index( + "ix_client_tokens_device_id", + ["device_id"], + unique=False, + if_not_exists=True, + ) + + +def downgrade() -> None: + with op.batch_alter_table("client_tokens", schema=None) as batch_op: + # Drop FK before the backing index -- MariaDB refuses otherwise + batch_op.drop_constraint("fk_client_tokens_device_id", type_="foreignkey") + batch_op.drop_index("ix_client_tokens_device_id", if_exists=True) + batch_op.drop_column("device_id", if_exists=True) diff --git a/backend/endpoints/device_auth.py b/backend/endpoints/device_auth.py new file mode 100644 index 0000000000..a302c168db --- /dev/null +++ b/backend/endpoints/device_auth.py @@ -0,0 +1,336 @@ +"""Device authorization flow endpoints (RFC 8628-style, tailored for RomM). + +A device POSTs to /authorize with its metadata and requested scopes; the server +returns a device_code (secret, for polling) and a user_code (short, for QR). +The user scans the QR on their phone, lands on /pair/device in the web UI, +approves (possibly editing scopes and device name), and the device's next poll +on /token returns a ClientToken bound 1:1 to a Device record. + +The flow is unauthenticated on /authorize and /token (the whole point — the +device has no credentials yet). State lives exclusively in Redis with a hard +10-minute TTL ceiling. +""" + +import uuid +from datetime import datetime, timezone + +from fastapi import HTTPException, Request, status + +from decorators.auth import protected_route +from endpoints.responses.device_auth import ( + DeviceAuthApprovePayload, + DeviceAuthApproveResponse, + DeviceAuthDenyPayload, + DeviceAuthInitPayload, + DeviceAuthInitResponse, + DeviceAuthPendingSchema, + DeviceAuthTokenPayload, + DeviceAuthTokenResponse, +) +from handler.auth import auth_handler +from handler.auth.constants import Scope +from handler.database import db_client_token_handler, db_device_handler +from logger.logger import log +from models.client_token import ClientToken +from models.device import Device, SyncMode +from utils.client_tokens import parse_expiry +from utils.device_auth import ( + PENDING_TTL_SECONDS, + POLL_DEFAULT_INTERVAL_SECONDS, + FlowStatus, + build_verification_urls, + check_authorize_rate_limit, + check_token_poll_rate_limit, + consume_approved, + generate_device_code, + generate_user_code, + load_pending, + mark_approved, + mark_denied, + normalize_user_code, + pending_expires_at, + polled_too_fast, + resolve_device_code_from_user_code, + store_pending, +) +from utils.router import APIRouter + +router = APIRouter(prefix="/auth/device", tags=["device-auth"]) + + +def _device_code_prefix(device_code: str) -> str: + """Short prefix for log lines — never log the full secret.""" + return device_code[:8] + + +@router.post("/init", status_code=status.HTTP_201_CREATED) +def device_auth_init( + request: Request, payload: DeviceAuthInitPayload +) -> DeviceAuthInitResponse: + """Device-initiated: start a new pairing flow. Open endpoint, rate-limited.""" + check_authorize_rate_limit(request) + + device_code = generate_device_code() + user_code = generate_user_code() + + store_pending( + device_code, + user_code, + { + "client_device_identifier": payload.client_device_identifier, + "name": payload.name, + "client": payload.client, + "platform": payload.platform, + "client_version": payload.client_version, + "requested_scopes": payload.requested_scopes, + "interval": POLL_DEFAULT_INTERVAL_SECONDS, + }, + ) + + verification_url, verification_url_complete = build_verification_urls( + request, user_code + ) + + log.info( + f"device_auth.init client={payload.client} " + f"identifier={payload.client_device_identifier} " + f"device_code_prefix={_device_code_prefix(device_code)}" + ) + + return DeviceAuthInitResponse( + device_code=device_code, + user_code=user_code, + verification_url=verification_url, + verification_url_complete=verification_url_complete, + expires_in=PENDING_TTL_SECONDS, + interval=POLL_DEFAULT_INTERVAL_SECONDS, + ) + + +@protected_route(router.get, "/pending/{user_code}", [Scope.ME_READ]) +def get_pending(request: Request, user_code: str) -> DeviceAuthPendingSchema: + """Fetch a pending request's metadata for the approval screen.""" + normalized = normalize_user_code(user_code) + device_code = resolve_device_code_from_user_code(normalized) + if device_code is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown or expired code", + ) + + data = load_pending(device_code) + if data is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown or expired code", + ) + if data.get("status") != FlowStatus.PENDING: + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail=f"Code already {data.get('status')}", + ) + + user_scopes = {str(s) for s in request.user.oauth_scopes} + requested = list(data.get("requested_scopes", [])) + allowed = sorted(set(requested) & user_scopes) + + return DeviceAuthPendingSchema( + client_device_identifier=data["client_device_identifier"], + name=data["name"], + client=data["client"], + platform=data.get("platform"), + client_version=data.get("client_version"), + requested_scopes=sorted(requested), + allowed_scopes=allowed, + expires_at=pending_expires_at(device_code), + ) + + +@protected_route(router.post, "/approve", [Scope.ME_WRITE]) +def approve( + request: Request, payload: DeviceAuthApprovePayload +) -> DeviceAuthApproveResponse: + """Create the Device + bound ClientToken and mark the request approved.""" + normalized = normalize_user_code(payload.user_code) + device_code = resolve_device_code_from_user_code(normalized) + if device_code is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown or expired code", + ) + + data = load_pending(device_code) + if data is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown or expired code", + ) + if data.get("status") != FlowStatus.PENDING: + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail=f"Code already {data.get('status')}", + ) + + user_scopes = {str(s) for s in request.user.oauth_scopes} + requested = set(data.get("requested_scopes", [])) + allowed = requested & user_scopes + approved_set = set(payload.approved_scopes) + if not approved_set or not approved_set.issubset(allowed): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Approved scopes exceed what's allowed for this user", + ) + + expires_at = parse_expiry(payload.expires_in) + + now = datetime.now(timezone.utc) + device_name = payload.device_name or data["name"] + + existing = db_device_handler.get_device_by_client_identifier( + user_id=request.user.id, + client_device_identifier=data["client_device_identifier"], + ) + if existing is not None: + update_data = { + "name": device_name, + "last_seen": now, + } + client_version = data.get("client_version") + if client_version is not None: + update_data["client_version"] = client_version + db_device_handler.update_device( + device_id=existing.id, + user_id=request.user.id, + data=update_data, + ) + device = existing + else: + device = db_device_handler.add_device( + Device( + id=str(uuid.uuid4()), + user_id=request.user.id, + name=device_name, + client=data.get("client"), + platform=data.get("platform"), + client_version=data.get("client_version"), + client_device_identifier=data["client_device_identifier"], + sync_mode=SyncMode.API, + last_seen=now, + ) + ) + + raw_token = auth_handler.generate_client_token() + token = db_client_token_handler.add_token( + ClientToken( + user_id=request.user.id, + name=device_name, + hashed_token=auth_handler.hash_client_token(raw_token), + scopes=" ".join(sorted(approved_set)), + expires_at=expires_at, + device_id=device.id, + ) + ) + + mark_approved( + device_code, + raw_token=raw_token, + device_id=device.id, + scopes=sorted(approved_set), + expires_at=token.expires_at, + ) + + log.info( + f"device_auth.approve user_id={request.user.id} " + f"device_id={device.id} token_id={token.id} " + f"device_code_prefix={_device_code_prefix(device_code)} " + f"scopes={' '.join(sorted(approved_set))}" + ) + + return DeviceAuthApproveResponse(device_id=device.id, device_name=device_name) + + +@protected_route(router.post, "/deny", [Scope.ME_WRITE]) +def deny(request: Request, payload: DeviceAuthDenyPayload) -> None: + normalized = normalize_user_code(payload.user_code) + device_code = resolve_device_code_from_user_code(normalized) + if device_code is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown or expired code", + ) + + data = load_pending(device_code) + if data is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Unknown or expired code", + ) + if data.get("status") != FlowStatus.PENDING: + raise HTTPException( + status_code=status.HTTP_410_GONE, + detail=f"Code already {data.get('status')}", + ) + + mark_denied(device_code) + log.info( + f"device_auth.deny user_id={request.user.id} " + f"device_code_prefix={_device_code_prefix(device_code)}" + ) + + +@router.post("/token") +def token(request: Request, payload: DeviceAuthTokenPayload) -> DeviceAuthTokenResponse: + """Device-facing polling endpoint. Open, rate-limited per-IP and per-code.""" + check_token_poll_rate_limit(request) + + data = load_pending(payload.device_code) + if data is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="expired_token", + ) + + state = data.get("status") + + if state == FlowStatus.PENDING: + interval = int(data.get("interval", POLL_DEFAULT_INTERVAL_SECONDS)) + if polled_too_fast(payload.device_code, interval): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="slow_down", + ) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="authorization_pending", + ) + + if state == FlowStatus.DENIED: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="access_denied", + ) + + if state == FlowStatus.APPROVED: + approved = consume_approved(payload.device_code) + if approved is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="expired_token", + ) + expires_at_raw = approved.get("expires_at") + expires_at = datetime.fromisoformat(expires_at_raw) if expires_at_raw else None + log.info( + f"device_auth.token_issued device_id={approved['device_id']} " + f"device_code_prefix={_device_code_prefix(payload.device_code)}" + ) + return DeviceAuthTokenResponse( + access_token=approved["raw_token"], + device_id=approved["device_id"], + scopes=list(approved.get("scopes", [])), + expires_at=expires_at, + ) + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="expired_token", + ) diff --git a/backend/endpoints/play_sessions.py b/backend/endpoints/play_sessions.py index fccda0086d..dd845fe08c 100644 --- a/backend/endpoints/play_sessions.py +++ b/backend/endpoints/play_sessions.py @@ -62,6 +62,8 @@ def ingest_play_sessions( detail=f"Batch size exceeds maximum of {MAX_BATCH_SIZE}", ) + device_id = payload.device_id or getattr(request.state, "device_id", None) + summary = _ingest( user_id=request.user.id, username=request.user.username, @@ -75,7 +77,7 @@ def ingest_play_sessions( } for s in payload.sessions ], - device_id=payload.device_id, + device_id=device_id, ) return PlaySessionIngestResponse( @@ -103,10 +105,11 @@ def get_play_sessions( limit: int = 50, offset: int = 0, ) -> list[PlaySessionSchema]: + effective_device_id = device_id or getattr(request.state, "device_id", None) sessions = db_play_session_handler.get_sessions( user_id=request.user.id, rom_id=rom_id, - device_id=device_id, + device_id=effective_device_id, start_after=start_after, end_before=end_before, limit=limit if start_after is None and end_before is None else None, diff --git a/backend/endpoints/responses/client_token.py b/backend/endpoints/responses/client_token.py index 5107e060cf..9e6f293a27 100644 --- a/backend/endpoints/responses/client_token.py +++ b/backend/endpoints/responses/client_token.py @@ -13,6 +13,7 @@ class ClientTokenSchema(BaseModel): last_used_at: UTCDatetime | None created_at: UTCDatetime user_id: int + device_id: str | None = None class ClientTokenCreateSchema(ClientTokenSchema): diff --git a/backend/endpoints/responses/device.py b/backend/endpoints/responses/device.py index 9ac0226fd6..bf495e8f2b 100644 --- a/backend/endpoints/responses/device.py +++ b/backend/endpoints/responses/device.py @@ -31,6 +31,7 @@ class DeviceSchema(BaseModel): ip_address: str | None mac_address: str | None hostname: str | None + client_device_identifier: str | None sync_mode: SyncMode sync_enabled: bool sync_config: dict | None diff --git a/backend/endpoints/responses/device_auth.py b/backend/endpoints/responses/device_auth.py new file mode 100644 index 0000000000..5441645c28 --- /dev/null +++ b/backend/endpoints/responses/device_auth.py @@ -0,0 +1,59 @@ +from pydantic import Field + +from .base import BaseModel, UTCDatetime + + +class DeviceAuthInitPayload(BaseModel): + client_device_identifier: str = Field(min_length=1, max_length=255) + name: str = Field(min_length=1, max_length=255) + client: str = Field(min_length=1, max_length=50) + platform: str | None = Field(default=None, max_length=50) + client_version: str | None = Field(default=None, max_length=50) + requested_scopes: list[str] = Field(min_length=1) + + +class DeviceAuthInitResponse(BaseModel): + device_code: str + user_code: str + verification_url: str + verification_url_complete: str + expires_in: int + interval: int + + +class DeviceAuthPendingSchema(BaseModel): + client_device_identifier: str + name: str + client: str + platform: str | None + client_version: str | None + requested_scopes: list[str] + allowed_scopes: list[str] + expires_at: UTCDatetime + + +class DeviceAuthApprovePayload(BaseModel): + user_code: str = Field(min_length=1, max_length=32) + approved_scopes: list[str] = Field(min_length=1) + device_name: str | None = Field(default=None, max_length=255) + expires_in: str | None = None + + +class DeviceAuthApproveResponse(BaseModel): + device_id: str + device_name: str | None + + +class DeviceAuthDenyPayload(BaseModel): + user_code: str = Field(min_length=1, max_length=32) + + +class DeviceAuthTokenPayload(BaseModel): + device_code: str = Field(min_length=1, max_length=128) + + +class DeviceAuthTokenResponse(BaseModel): + access_token: str + device_id: str + scopes: list[str] + expires_at: UTCDatetime | None diff --git a/backend/endpoints/responses/identity.py b/backend/endpoints/responses/identity.py index 29a9b504ed..7422caa22d 100644 --- a/backend/endpoints/responses/identity.py +++ b/backend/endpoints/responses/identity.py @@ -43,7 +43,9 @@ def from_orm_with_request( return None schema = cls.model_validate(db_user) - schema.current_device_id = request.session.get("device_id") + schema.current_device_id = getattr( + request.state, "device_id", None + ) or request.session.get("device_id") return schema diff --git a/backend/endpoints/sync.py b/backend/endpoints/sync.py index 564ae75424..e640545f23 100644 --- a/backend/endpoints/sync.py +++ b/backend/endpoints/sync.py @@ -50,7 +50,7 @@ class ClientSaveState(BaseModel): class SyncNegotiatePayload(BaseModel): - device_id: str + device_id: str | None = None saves: list[ClientSaveState] @@ -86,13 +86,23 @@ def negotiate_sync( The client sends its current save state, and the server returns a list of operations (upload, download, conflict, no_op) to bring both sides in sync. """ - device = db_device_handler.get_device( - device_id=payload.device_id, user_id=request.user.id + device_id: str | None = payload.device_id or getattr( + request.state, "device_id", None ) + if not device_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + "device_id is required (either in the request payload or " + "implicit via a device-bound client token)" + ), + ) + + device = db_device_handler.get_device(device_id=device_id, user_id=request.user.id) if not device: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Device with ID {payload.device_id} not found", + detail=f"Device with ID {device_id} not found", ) if not device.sync_enabled: diff --git a/backend/handler/auth/hybrid_auth.py b/backend/handler/auth/hybrid_auth.py index 5ec3923a97..31db6fb2d3 100644 --- a/backend/handler/auth/hybrid_auth.py +++ b/backend/handler/auth/hybrid_auth.py @@ -6,7 +6,11 @@ from config import KIOSK_MODE from handler.auth import auth_handler, oauth_handler -from handler.database import db_client_token_handler, db_user_handler +from handler.database import ( + db_client_token_handler, + db_device_handler, + db_user_handler, +) from models.user import User from utils.datetime import to_utc @@ -66,6 +70,11 @@ async def authenticate( db_client_token_handler.update_last_used(client_token.id) user.set_last_active() conn.state.client_token_id = client_token.id + conn.state.device_id = client_token.device_id + if client_token.device_id: + db_device_handler.update_last_seen_debounced( + client_token.device_id + ) return (AuthCredentials(effective_scopes), user) # OAuth JWT bearer tokens diff --git a/backend/handler/database/devices_handler.py b/backend/handler/database/devices_handler.py index 6be412fadc..4286b4c0fa 100644 --- a/backend/handler/database/devices_handler.py +++ b/backend/handler/database/devices_handler.py @@ -1,14 +1,17 @@ from collections.abc import Sequence -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from sqlalchemy import delete, select, update from sqlalchemy.orm import Session from decorators.database import begin_session from models.device import Device, SyncMode +from utils.datetime import to_utc from .base_handler import DBBaseHandler +LAST_SEEN_DEBOUNCE = timedelta(minutes=5) + class DBDevicesHandler(DBBaseHandler): @begin_session @@ -74,6 +77,25 @@ def get_device_by_id( """Get a device by ID without user filtering (for server-side operations).""" return session.scalar(select(Device).filter_by(id=device_id).limit(1)) + @begin_session + def get_device_by_client_identifier( + self, + user_id: int, + client_device_identifier: str, + session: Session = None, # type: ignore + ) -> Device | None: + """Find a device by its client-supplied stable identifier, scoped to a user.""" + if not client_device_identifier: + return None + return session.scalar( + select(Device) + .filter_by( + user_id=user_id, + client_device_identifier=client_device_identifier, + ) + .limit(1) + ) + @begin_session def get_devices( self, @@ -123,6 +145,32 @@ def update_last_seen( .execution_options(synchronize_session="evaluate") ) + @begin_session + def update_last_seen_debounced( + self, + device_id: str, + session: Session = None, # type: ignore + ) -> None: + """Bump last_seen on the device, skipping if updated within the debounce window. + + Intended for the auth hot path -- called on every authenticated client-token + request. Mirrors the debounce used by ClientToken.update_last_used. + """ + now = datetime.now(timezone.utc) + device = session.get(Device, device_id) + if device is None: + return + + if device.last_seen and (now - to_utc(device.last_seen)) < LAST_SEEN_DEBOUNCE: + return + + session.execute( + update(Device) + .where(Device.id == device_id) + .values(last_seen=now) + .execution_options(synchronize_session="evaluate") + ) + @begin_session def delete_device( self, diff --git a/backend/main.py b/backend/main.py index 9430b51d24..344a01a2c8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,6 +30,7 @@ from endpoints.collections import router as collections_router from endpoints.configs import router as configs_router from endpoints.device import router as device_router +from endpoints.device_auth import router as device_auth_router from endpoints.export import router as export_router from endpoints.feeds import router as feeds_router from endpoints.firmware import router as firmware_router @@ -102,6 +103,8 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: re.compile(r"^/api/token.*"), re.compile(r"^/api/client-tokens/exchange"), re.compile(r"^/api/client-tokens/pair/.+/status"), + re.compile(r"^/api/auth/device/init/?$"), + re.compile(r"^/api/auth/device/token/?$"), re.compile(r"^/ws"), re.compile(r"^/netplay"), ], @@ -129,6 +132,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: app.include_router(user_router, prefix="/api") app.include_router(client_tokens_router, prefix="/api") app.include_router(device_router, prefix="/api") +app.include_router(device_auth_router, prefix="/api") app.include_router(play_sessions_router, prefix="/api") app.include_router(platform_router, prefix="/api") app.include_router(rom_router, prefix="/api") diff --git a/backend/models/client_token.py b/backend/models/client_token.py index e6747bde89..cebd334986 100644 --- a/backend/models/client_token.py +++ b/backend/models/client_token.py @@ -9,6 +9,7 @@ from models.base import BaseModel if TYPE_CHECKING: + from models.device import Device from models.user import User @@ -24,4 +25,9 @@ class ClientToken(BaseModel): expires_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) last_used_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + device_id: Mapped[str | None] = mapped_column( + ForeignKey("devices.id", ondelete="SET NULL"), nullable=True + ) + user: Mapped[User] = relationship(lazy="joined", back_populates="client_tokens") + device: Mapped[Device | None] = relationship(lazy="select") diff --git a/backend/models/device.py b/backend/models/device.py index fc3c663a1a..0ca7a7b04d 100644 --- a/backend/models/device.py +++ b/backend/models/device.py @@ -55,6 +55,10 @@ class Device(BaseModel): 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) diff --git a/backend/tests/endpoints/test_client_tokens.py b/backend/tests/endpoints/test_client_tokens.py index 9d1d00103f..97ebd87fe3 100644 --- a/backend/tests/endpoints/test_client_tokens.py +++ b/backend/tests/endpoints/test_client_tokens.py @@ -42,6 +42,8 @@ def test_create_token(self, client, access_token, admin_user): assert set(body["scopes"]) == {"roms.read", "assets.read"} assert body["expires_at"] is not None assert body["user_id"] == admin_user.id + # Manually-created tokens are unbound until a device-flow binds them + assert body["device_id"] is None def test_create_token_minimal(self, client, access_token, admin_user): response = client.post( @@ -76,6 +78,7 @@ def test_list_tokens(self, client, access_token, admin_user): assert names == {"Token A", "Token B"} for t in tokens: assert "raw_token" not in t + assert t["device_id"] is None def test_delete_token(self, client, access_token, admin_user): create_resp = client.post( diff --git a/backend/tests/endpoints/test_device.py b/backend/tests/endpoints/test_device.py index eaee16dfb5..2c318ae254 100644 --- a/backend/tests/endpoints/test_device.py +++ b/backend/tests/endpoints/test_device.py @@ -150,6 +150,26 @@ def test_delete_device(self, client, access_token: str, admin_user: User): ) assert get_response.status_code == status.HTTP_404_NOT_FOUND + def test_get_device_exposes_client_device_identifier( + self, client, access_token: str, admin_user: User + ): + device = db_device_handler.add_device( + Device( + id="test-device-cid", + user_id=admin_user.id, + name="CID Device", + client_device_identifier="install-uuid-abc123", + ) + ) + + response = client.get( + f"/api/devices/{device.id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.json()["client_device_identifier"] == "install-uuid-abc123" + class TestDeviceUserIsolation: def test_list_devices_only_returns_own_devices( diff --git a/backend/tests/endpoints/test_device_auth.py b/backend/tests/endpoints/test_device_auth.py new file mode 100644 index 0000000000..2899030cc6 --- /dev/null +++ b/backend/tests/endpoints/test_device_auth.py @@ -0,0 +1,909 @@ +"""End-to-end tests for the device authorization flow endpoints.""" + +import json +from datetime import datetime, timedelta, timezone + +from fastapi import status +from fastapi.testclient import TestClient +from httpx import Response + +from handler.database import db_client_token_handler, db_device_handler +from handler.redis_handler import sync_cache +from models.user import User +from utils import device_auth as df + + +def _recent_times(minutes_ago_start: int = 30, duration_minutes: int = 15) -> dict: + """Build start/end times anchored to now() so play-session ingest accepts them.""" + start = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago_start) + end = start + timedelta(minutes=duration_minutes) + return { + "start_time": start.isoformat().replace("+00:00", "Z"), + "end_time": end.isoformat().replace("+00:00", "Z"), + "duration_ms": duration_minutes * 60 * 1000, + } + + +AUTHORIZE_PAYLOAD = { + "client_device_identifier": "install-uuid-abc", + "name": "Pete muOS", + "client": "grout", + "platform": "muOS", + "client_version": "1.4.0", + "requested_scopes": ["roms.read", "roms.user.write", "devices.write"], +} + + +def _authorize(client: TestClient, payload: dict | None = None) -> dict: + resp = client.post("/api/auth/device/init", json=payload or AUTHORIZE_PAYLOAD) + assert resp.status_code == status.HTTP_201_CREATED + return resp.json() + + +def _approve( + client: TestClient, + access_token: str, + user_code: str, + approved_scopes: list[str], + device_name: str | None = None, + expires_in: str | None = None, +) -> Response: + body: dict = {"user_code": user_code, "approved_scopes": approved_scopes} + if device_name is not None: + body["device_name"] = device_name + if expires_in is not None: + body["expires_in"] = expires_in + return client.post( + "/api/auth/device/approve", + json=body, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + +def _poll_token(client: TestClient, device_code: str) -> Response: + return client.post("/api/auth/device/token", json={"device_code": device_code}) + + +class TestAuthorize: + def test_valid_request_returns_all_fields(self, client): + body = _authorize(client) + + assert len(body["device_code"]) == df.DEVICE_CODE_BYTES * 2 + assert len(body["user_code"]) == df.USER_CODE_LENGTH + assert body["verification_url"].endswith("/pair/device") + assert body["user_code"] in body["verification_url_complete"] + assert body["expires_in"] == df.PENDING_TTL_SECONDS + assert body["interval"] == df.POLL_DEFAULT_INTERVAL_SECONDS + + def test_stores_both_redis_keys(self, client): + body = _authorize(client) + + dc_raw = sync_cache.get(f"device_auth:dc:{body['device_code']}") + uc_raw = sync_cache.get(f"device_auth:uc:{body['user_code']}") + assert dc_raw is not None + assert uc_raw is not None + + stored = json.loads(dc_raw) + assert stored["status"] == df.FlowStatus.PENDING + assert stored["client"] == "grout" + assert stored["client_device_identifier"] == "install-uuid-abc" + assert sorted(stored["requested_scopes"]) == sorted( + AUTHORIZE_PAYLOAD["requested_scopes"] + ) + + def test_rate_limit_returns_429(self, client): + # 10 allowed, 11th blocked. Using a fresh client_device_identifier each + # loop isn't necessary — the rate key is IP-scoped. + for _ in range(df.AUTHORIZE_RATE_LIMIT): + r = client.post("/api/auth/device/init", json=AUTHORIZE_PAYLOAD) + assert r.status_code == status.HTTP_201_CREATED + + resp = client.post("/api/auth/device/init", json=AUTHORIZE_PAYLOAD) + assert resp.status_code == status.HTTP_429_TOO_MANY_REQUESTS + + def test_empty_scopes_rejected(self, client): + payload = {**AUTHORIZE_PAYLOAD, "requested_scopes": []} + resp = client.post("/api/auth/device/init", json=payload) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_missing_required_fields_rejected(self, client): + resp = client.post( + "/api/auth/device/init", + json={"client_device_identifier": "x"}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +class TestPending: + def test_auth_required(self, client): + body = _authorize(client) + resp = client.get(f"/api/auth/device/pending/{body['user_code']}") + assert resp.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) + + def test_unknown_user_code_returns_404(self, client, access_token: str): + resp = client.get( + "/api/auth/device/pending/NOPECODE", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + def test_returns_intersected_allowed_scopes( + self, client, access_token: str, admin_user: User + ): + body = _authorize(client) + + resp = client.get( + f"/api/auth/device/pending/{body['user_code']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_200_OK + data = resp.json() + + user_scopes = set(admin_user.oauth_scopes) + requested = set(AUTHORIZE_PAYLOAD["requested_scopes"]) + expected_allowed = sorted(requested & user_scopes) + + assert data["allowed_scopes"] == expected_allowed + assert sorted(data["requested_scopes"]) == sorted(requested) + + def test_user_without_a_scope_gets_it_stripped_from_allowed( + self, client, editor_access_token: str, editor_user: User + ): + # Request a scope the editor doesn't have + body = _authorize( + client, + payload={ + **AUTHORIZE_PAYLOAD, + "requested_scopes": ["roms.read", "users.write"], + }, + ) + + resp = client.get( + f"/api/auth/device/pending/{body['user_code']}", + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + assert resp.status_code == status.HTTP_200_OK + data = resp.json() + assert "users.write" not in data["allowed_scopes"] + assert "roms.read" in data["allowed_scopes"] + + def test_already_approved_returns_410(self, client, access_token: str): + body = _authorize(client) + approve = _approve( + client, + access_token, + body["user_code"], + approved_scopes=["roms.read"], + ) + assert approve.status_code == status.HTTP_200_OK + + # After approval the user_code pointer is deleted, so pending lookup + # returns 404 — not 410. 410 covers the narrower race where status + # transitions without the user_code being cleaned up; it's validated + # in test_denied_returns_410_on_pending below. + resp = client.get( + f"/api/auth/device/pending/{body['user_code']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + def test_expired_pending_returns_404(self, client, access_token: str): + body = _authorize(client) + # Simulate TTL expiry by deleting both Redis entries + sync_cache.delete(f"device_auth:dc:{body['device_code']}") + sync_cache.delete(f"device_auth:uc:{body['user_code']}") + + resp = client.get( + f"/api/auth/device/pending/{body['user_code']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + def test_user_code_normalization(self, client, access_token: str): + body = _authorize(client) + # Inject a hyphen mid-code and lowercase — server must normalize + raw = body["user_code"] + typed = (raw[:4] + "-" + raw[4:]).lower() + + resp = client.get( + f"/api/auth/device/pending/{typed}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_200_OK + + +class TestApprove: + def test_creates_device_and_bound_token( + self, client, access_token: str, admin_user: User + ): + body = _authorize(client) + + approve = _approve( + client, + access_token, + body["user_code"], + approved_scopes=["roms.read", "roms.user.write"], + ) + assert approve.status_code == status.HTTP_200_OK + data = approve.json() + assert data["device_id"] + assert data["device_name"] == "Pete muOS" + + # Device was created with the supplied identifier + metadata + device = db_device_handler.get_device( + device_id=data["device_id"], user_id=admin_user.id + ) + assert device is not None + assert device.client_device_identifier == "install-uuid-abc" + assert device.client == "grout" + assert device.platform == "muOS" + assert device.client_version == "1.4.0" + + # ClientToken is bound to the device + tokens = db_client_token_handler.get_tokens_by_user(admin_user.id) + bound = [t for t in tokens if t.device_id == device.id] + assert len(bound) == 1 + assert set(bound[0].scopes.split()) == {"roms.read", "roms.user.write"} + + def test_redis_flipped_to_approved_with_token_payload( + self, client, access_token: str + ): + body = _authorize(client) + approve = _approve( + client, + access_token, + body["user_code"], + approved_scopes=["roms.read"], + ) + assert approve.status_code == status.HTTP_200_OK + + raw = sync_cache.get(f"device_auth:dc:{body['device_code']}") + assert raw is not None + stored = json.loads(raw) + assert stored["status"] == df.FlowStatus.APPROVED + assert stored["raw_token"].startswith("rmm_") + assert stored["device_id"] + # user_code pointer is cleaned up + assert sync_cache.get(f"device_auth:uc:{body['user_code']}") is None + + def test_scope_clamping_rejects_scopes_above_allowed( + self, client, editor_access_token: str + ): + body = _authorize( + client, + payload={ + **AUTHORIZE_PAYLOAD, + "requested_scopes": ["roms.read", "users.write"], + }, + ) + # editor lacks users.write; attempting to approve it is 403 + resp = _approve( + client, + editor_access_token, + body["user_code"], + approved_scopes=["roms.read", "users.write"], + ) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + def test_scope_clamping_rejects_scopes_above_requested( + self, client, access_token: str + ): + # Requested only roms.read — approving additional scopes is invalid + body = _authorize( + client, + payload={**AUTHORIZE_PAYLOAD, "requested_scopes": ["roms.read"]}, + ) + resp = _approve( + client, + access_token, + body["user_code"], + approved_scopes=["roms.read", "roms.user.write"], + ) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + def test_empty_approved_scopes_rejected(self, client, access_token: str): + body = _authorize(client) + resp = client.post( + "/api/auth/device/approve", + json={"user_code": body["user_code"], "approved_scopes": []}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_reapprove_response_reflects_new_device_name( + self, client: TestClient, access_token: str, admin_user: User + ): + # First approval names the device "Original" + body1 = _authorize(client) + r1 = _approve( + client, + access_token, + body1["user_code"], + ["roms.read"], + device_name="Original", + ) + assert r1.json()["device_name"] == "Original" + + # Second flow with same identifier but a different user-edited name -- + # the approve response must reflect the newly-edited name, not a + # stale value from the pre-update in-memory Device instance. + body2 = _authorize(client) + r2 = _approve( + client, + access_token, + body2["user_code"], + ["roms.read"], + device_name="Renamed", + ) + assert r2.json()["device_name"] == "Renamed" + + # And the DB row matches + device = db_device_handler.get_device( + device_id=r2.json()["device_id"], user_id=admin_user.id + ) + assert device is not None + assert device.name == "Renamed" + + def test_device_dedupe_on_same_client_identifier( + self, client, access_token: str, admin_user: User + ): + body1 = _authorize(client) + approve1 = _approve(client, access_token, body1["user_code"], ["roms.read"]) + device_id_1 = approve1.json()["device_id"] + + # Second flow with the same client_device_identifier + body2 = _authorize(client) + approve2 = _approve( + client, + access_token, + body2["user_code"], + ["roms.read"], + ) + device_id_2 = approve2.json()["device_id"] + + assert device_id_1 == device_id_2, "Same identifier must reuse device" + + # Two tokens exist, both bound to the same device + tokens = db_client_token_handler.get_tokens_by_user(admin_user.id) + bound = [t for t in tokens if t.device_id == device_id_1] + assert len(bound) == 2 + + def test_user_edited_device_name_persists( + self, client, access_token: str, admin_user: User + ): + body = _authorize(client) + approve = _approve( + client, + access_token, + body["user_code"], + approved_scopes=["roms.read"], + device_name="Pete's Handheld", + ) + assert approve.status_code == status.HTTP_200_OK + data = approve.json() + + device = db_device_handler.get_device( + device_id=data["device_id"], user_id=admin_user.id + ) + assert device is not None + assert device.name == "Pete's Handheld" + + def test_expiry_respected(self, client, access_token: str, admin_user: User): + body = _authorize(client) + approve = _approve( + client, + access_token, + body["user_code"], + approved_scopes=["roms.read"], + expires_in="30d", + ) + assert approve.status_code == status.HTTP_200_OK + + tokens = db_client_token_handler.get_tokens_by_user(admin_user.id) + assert any(t.expires_at is not None for t in tokens) + + def test_never_expires(self, client, access_token: str, admin_user: User): + body = _authorize(client) + approve = _approve( + client, + access_token, + body["user_code"], + approved_scopes=["roms.read"], + expires_in="never", + ) + assert approve.status_code == status.HTTP_200_OK + + tokens = db_client_token_handler.get_tokens_by_user(admin_user.id) + assert any(t.expires_at is None and t.device_id for t in tokens) + + def test_already_approved_returns_410_or_404(self, client, access_token: str): + body = _authorize(client) + _approve(client, access_token, body["user_code"], ["roms.read"]) + # Second approve on same code: user_code pointer is cleaned up on + # the first approval, so 404 here. + resp = _approve(client, access_token, body["user_code"], ["roms.read"]) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + def test_unknown_user_code_returns_404(self, client, access_token: str): + resp = _approve(client, access_token, "BOGUS123", ["roms.read"]) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +class TestDeny: + def test_deny_marks_state_and_token_poll_reports_access_denied( + self, client, access_token: str + ): + body = _authorize(client) + + deny = client.post( + "/api/auth/device/deny", + json={"user_code": body["user_code"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert deny.status_code == status.HTTP_200_OK + + poll = _poll_token(client, body["device_code"]) + assert poll.status_code == status.HTTP_400_BAD_REQUEST + assert poll.json()["detail"] == "access_denied" + + def test_denied_returns_410_on_pending(self, client, access_token: str): + body = _authorize(client) + # Manually flip state to denied while keeping the user_code pointer + # alive (simulating an internal inconsistency we should still defend) + df.mark_denied(body["device_code"]) + # mark_denied cleans up the uc key, so now the pending lookup is 404 + resp = client.get( + f"/api/auth/device/pending/{body['user_code']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + def test_unknown_user_code_returns_404(self, client, access_token: str): + resp = client.post( + "/api/auth/device/deny", + json={"user_code": "BOGUS123"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +class TestToken: + def test_pending_returns_authorization_pending(self, client): + body = _authorize(client) + resp = _poll_token(client, body["device_code"]) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json()["detail"] == "authorization_pending" + + def test_approved_returns_credentials(self, client, access_token: str): + body = _authorize(client) + approve = _approve( + client, + access_token, + body["user_code"], + approved_scopes=["roms.read", "roms.user.write"], + ) + assert approve.status_code == status.HTTP_200_OK + + resp = _poll_token(client, body["device_code"]) + assert resp.status_code == status.HTTP_200_OK + data = resp.json() + assert data["access_token"].startswith("rmm_") + assert data["device_id"] == approve.json()["device_id"] + assert set(data["scopes"]) == {"roms.read", "roms.user.write"} + + def test_one_shot_second_poll_returns_expired_token( + self, client, access_token: str + ): + body = _authorize(client) + _approve(client, access_token, body["user_code"], ["roms.read"]) + + first = _poll_token(client, body["device_code"]) + assert first.status_code == status.HTTP_200_OK + + second = _poll_token(client, body["device_code"]) + assert second.status_code == status.HTTP_400_BAD_REQUEST + assert second.json()["detail"] == "expired_token" + + def test_denied_returns_access_denied(self, client, access_token: str): + body = _authorize(client) + client.post( + "/api/auth/device/deny", + json={"user_code": body["user_code"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + resp = _poll_token(client, body["device_code"]) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json()["detail"] == "access_denied" + + def test_unknown_device_code_returns_expired_token(self, client): + resp = _poll_token(client, "a" * 64) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert resp.json()["detail"] == "expired_token" + + def test_slow_down_on_fast_consecutive_polls(self, client): + body = _authorize(client) + + # First poll: no prior record, always passes (authorization_pending) + first = _poll_token(client, body["device_code"]) + assert first.status_code == status.HTTP_400_BAD_REQUEST + assert first.json()["detail"] == "authorization_pending" + + # Immediate second poll — inside the 5s interval → slow_down + second = _poll_token(client, body["device_code"]) + assert second.status_code == status.HTTP_400_BAD_REQUEST + assert second.json()["detail"] == "slow_down" + + def test_per_ip_rate_limit(self, client): + # Per-IP cap is 60/60s on /token. Use a single device_code with fast + # polls — once the per-IP counter trips, 429 (not slow_down). + body = _authorize(client) + + last_status = None + for _ in range(df.TOKEN_POLL_RATE_LIMIT + 1): + r = _poll_token(client, body["device_code"]) + last_status = r.status_code + + assert last_status == status.HTTP_429_TOO_MANY_REQUESTS + + +class TestVerificationUrls: + def test_verification_url_is_server_origin(self, client): + # Never echoes client-supplied callback data — verification_url is + # always the server's own origin + /pair/device. This is the XSS + # surface removal the plan leans on. + body = _authorize( + client, + payload={**AUTHORIZE_PAYLOAD, "name": "javascript:alert(1)"}, + ) + assert body["verification_url"].endswith("/pair/device") + assert "javascript:" not in body["verification_url"] + assert "javascript:" not in body["verification_url_complete"] + + +class TestHelperFunctions: + def test_normalize_user_code_strips_hyphens_and_uppercases(self): + assert df.normalize_user_code("ab-cd-12") == "ABCD12" + assert df.normalize_user_code("a b c") == "ABC" + + def test_generate_codes_have_expected_shape(self): + dc = df.generate_device_code() + uc = df.generate_user_code() + assert len(dc) == df.DEVICE_CODE_BYTES * 2 + assert len(uc) == df.USER_CODE_LENGTH + # user_code chars must be in the allowed alphabet + from utils.client_tokens import PAIR_ALPHABET + + assert all(c in PAIR_ALPHABET for c in uc) + + +class TestBoundTokenInference: + """After a device-auth approval, subsequent API calls with the returned + access_token have their device_id inferred automatically when the payload + omits it.""" + + def _run_flow( + self, client: TestClient, access_token: str, scopes: list[str] + ) -> dict: + init = _authorize( + client, + payload={**AUTHORIZE_PAYLOAD, "requested_scopes": scopes}, + ) + approve = _approve(client, access_token, init["user_code"], scopes) + assert approve.status_code == status.HTTP_200_OK + token_resp = _poll_token(client, init["device_code"]) + assert token_resp.status_code == status.HTTP_200_OK + return token_resp.json() + + def test_play_session_inferred_from_bound_token( + self, client: TestClient, access_token: str + ): + creds = self._run_flow( + client, access_token, ["roms.user.write", "roms.user.read"] + ) + + # POST /play-sessions without device_id — server infers from token + resp = client.post( + "/api/play-sessions", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + json={"sessions": [{"rom_id": 1, **_recent_times(30, 15)}]}, + ) + assert resp.status_code == status.HTTP_201_CREATED + + # Session was attached to the bound device + list_resp = client.get( + f"/api/play-sessions?device_id={creds['device_id']}", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + ) + assert list_resp.status_code == status.HTTP_200_OK + sessions = list_resp.json() + assert any(s.get("device_id") == creds["device_id"] for s in sessions) + + def test_explicit_device_id_wins_over_bound( + self, + client: TestClient, + access_token: str, + admin_user: User, + ): + from models.device import Device + + creds = self._run_flow( + client, access_token, ["roms.user.write", "roms.user.read"] + ) + other = db_device_handler.add_device( + Device( + id="explicit-wins-device", + user_id=admin_user.id, + name="Second", + ) + ) + + resp = client.post( + "/api/play-sessions", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + json={ + "device_id": other.id, + "sessions": [{"rom_id": 1, **_recent_times(60, 15)}], + }, + ) + assert resp.status_code == status.HTTP_201_CREATED + + list_resp = client.get( + f"/api/play-sessions?device_id={other.id}", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + ) + assert list_resp.status_code == status.HTTP_200_OK + assert len(list_resp.json()) >= 1 + + def test_unbound_legacy_token_attaches_sessions_with_null_device( + self, + client: TestClient, + admin_user: User, + ): + # Simulate the legacy manual token path + from handler.auth import auth_handler + from models.client_token import ClientToken + + raw = auth_handler.generate_client_token() + db_client_token_handler.add_token( + ClientToken( + user_id=admin_user.id, + name="legacy", + hashed_token=auth_handler.hash_client_token(raw), + scopes="roms.user.write roms.user.read", + device_id=None, + ) + ) + + resp = client.post( + "/api/play-sessions", + headers={"Authorization": f"Bearer {raw}"}, + json={"sessions": [{"rom_id": 1, **_recent_times(90, 15)}]}, + ) + # Existing behavior: device_id is None on the created session + assert resp.status_code == status.HTTP_201_CREATED + + +class TestEndToEndHappyPath: + """One narrative test that walks the full flow the way a real device would.""" + + def test_grout_pairs_ingests_play_session( + self, client: TestClient, access_token: str, admin_user: User + ): + # --- 1. Device initiates --- + start_resp = client.post( + "/api/auth/device/init", + json={ + "client_device_identifier": "grout-e2e-001", + "name": "Pete's muOS handheld", + "client": "grout", + "platform": "muOS", + "client_version": "1.4.0", + "requested_scopes": [ + "roms.read", + "roms.user.read", + "roms.user.write", + ], + }, + ) + assert start_resp.status_code == status.HTTP_201_CREATED + init = start_resp.json() + assert init["verification_url"].endswith("/pair/device") + + # Device displays QR from verification_url_complete; user scans; user + # is routed to /pair/device?user_code=... and authenticates. + + # --- 2. Web UI fetches pending metadata --- + pending_resp = client.get( + f"/api/auth/device/pending/{init['user_code']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert pending_resp.status_code == status.HTTP_200_OK + pending = pending_resp.json() + assert pending["name"] == "Pete's muOS handheld" + assert pending["client"] == "grout" + assert set(pending["allowed_scopes"]).issubset(set(pending["requested_scopes"])) + + # --- 3. Device poll while pending returns authorization_pending --- + poll = client.post( + "/api/auth/device/token", json={"device_code": init["device_code"]} + ) + assert poll.status_code == status.HTTP_400_BAD_REQUEST + assert poll.json()["detail"] == "authorization_pending" + + # --- 4. User approves with a slight name edit and 30d expiry --- + approve_resp = client.post( + "/api/auth/device/approve", + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "user_code": init["user_code"], + "approved_scopes": pending["allowed_scopes"], + "device_name": "Pete's handheld", + "expires_in": "30d", + }, + ) + assert approve_resp.status_code == status.HTTP_200_OK + approve = approve_resp.json() + assert approve["device_name"] == "Pete's handheld" + + # --- 5. Device polls and gets its credentials --- + token_resp = client.post( + "/api/auth/device/token", json={"device_code": init["device_code"]} + ) + assert token_resp.status_code == status.HTTP_200_OK + creds = token_resp.json() + assert creds["access_token"].startswith("rmm_") + assert creds["device_id"] == approve["device_id"] + assert creds["expires_at"] is not None + + # --- 6. Device uses its bound token to ingest a play session WITHOUT + # passing device_id — server infers from the bound token --- + ingest = client.post( + "/api/play-sessions", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + json={"sessions": [{"rom_id": 1, **_recent_times(45, 30)}]}, + ) + assert ingest.status_code == status.HTTP_201_CREATED + assert ingest.json()["created_count"] == 1 + + # --- 7. Listing the device via /devices shows the auto-created record + # with the right metadata from the pairing request --- + device_resp = client.get( + f"/api/devices/{approve['device_id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert device_resp.status_code == status.HTTP_200_OK + device_body = device_resp.json() + assert device_body["name"] == "Pete's handheld" + assert device_body["client"] == "grout" + assert device_body["platform"] == "muOS" + assert device_body["client_device_identifier"] == "grout-e2e-001" + assert device_body["user_id"] == admin_user.id + + # --- 8. Re-running the flow with the same client_device_identifier + # yields the SAME device_id back --- + restart = client.post( + "/api/auth/device/init", + json={ + "client_device_identifier": "grout-e2e-001", + "name": "Pete's muOS handheld", + "client": "grout", + "platform": "muOS", + "requested_scopes": ["roms.read"], + }, + ) + assert restart.status_code == status.HTTP_201_CREATED + restart_body = restart.json() + reapprove = client.post( + "/api/auth/device/approve", + headers={"Authorization": f"Bearer {access_token}"}, + json={ + "user_code": restart_body["user_code"], + "approved_scopes": ["roms.read"], + }, + ) + assert reapprove.status_code == status.HTTP_200_OK + assert reapprove.json()["device_id"] == approve["device_id"] + + +class TestWhoAmIForBoundToken: + """/api/users/me must surface the bound device_id so a device can + identify itself (not just its user) from its token alone.""" + + def test_bound_token_me_returns_current_device_id( + self, client: TestClient, access_token: str + ): + init = _authorize( + client, + payload={**AUTHORIZE_PAYLOAD, "requested_scopes": ["me.read"]}, + ) + approve = _approve(client, access_token, init["user_code"], ["me.read"]) + assert approve.status_code == status.HTTP_200_OK + creds = _poll_token(client, init["device_code"]).json() + + resp = client.get( + "/api/users/me", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["current_device_id"] == creds["device_id"] + + def test_unbound_legacy_token_me_returns_null_device_id( + self, + client: TestClient, + admin_user: User, + ): + from handler.auth import auth_handler + from models.client_token import ClientToken + + raw = auth_handler.generate_client_token() + db_client_token_handler.add_token( + ClientToken( + user_id=admin_user.id, + name="legacy", + hashed_token=auth_handler.hash_client_token(raw), + scopes="me.read", + device_id=None, + ) + ) + + resp = client.get("/api/users/me", headers={"Authorization": f"Bearer {raw}"}) + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["current_device_id"] is None + + +class TestSyncNegotiateBoundTokenInference: + def test_negotiate_without_device_id_uses_bound_device( + self, client: TestClient, access_token: str + ): + init = _authorize( + client, + payload={ + **AUTHORIZE_PAYLOAD, + "requested_scopes": ["assets.read", "devices.read"], + }, + ) + _approve( + client, + access_token, + init["user_code"], + ["assets.read", "devices.read"], + ) + token_resp = _poll_token(client, init["device_code"]) + assert token_resp.status_code == status.HTTP_200_OK + creds = token_resp.json() + + resp = client.post( + "/api/sync/negotiate", + headers={"Authorization": f"Bearer {creds['access_token']}"}, + json={"saves": []}, + ) + # No device_id in payload — inferred from bound token + assert resp.status_code == status.HTTP_200_OK + + def test_negotiate_without_bound_or_payload_device_id_is_400( + self, client: TestClient, admin_user: User + ): + from handler.auth import auth_handler + from models.client_token import ClientToken + + raw = auth_handler.generate_client_token() + db_client_token_handler.add_token( + ClientToken( + user_id=admin_user.id, + name="legacy-sync", + hashed_token=auth_handler.hash_client_token(raw), + scopes="assets.read devices.read", + device_id=None, + ) + ) + + resp = client.post( + "/api/sync/negotiate", + headers={"Authorization": f"Bearer {raw}"}, + json={"saves": []}, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST diff --git a/backend/tests/handler/auth/test_auth.py b/backend/tests/handler/auth/test_auth.py index 08fc7a37ff..194bbcdb92 100644 --- a/backend/tests/handler/auth/test_auth.py +++ b/backend/tests/handler/auth/test_auth.py @@ -10,7 +10,13 @@ from handler.auth import auth_handler, oauth_handler from handler.auth.constants import EDIT_SCOPES from handler.auth.hybrid_auth import HybridAuthBackend -from handler.database import db_user_handler +from handler.database import ( + db_client_token_handler, + db_device_handler, + db_user_handler, +) +from models.client_token import ClientToken +from models.device import Device from models.user import User @@ -239,3 +245,70 @@ def __init__(self): assert user.id == editor_user.id assert set(creds.scopes).issubset(editor_user.oauth_scopes) assert set(creds.scopes).issubset(scopes) + + +def _issue_client_token(user: User, device_id: str | None = None) -> str: + raw_token = auth_handler.generate_client_token() + hashed = auth_handler.hash_client_token(raw_token) + db_client_token_handler.add_token( + ClientToken( + user_id=user.id, + name="test-token", + hashed_token=hashed, + scopes=" ".join(user.oauth_scopes[:3]), + device_id=device_id, + ) + ) + return raw_token + + +async def test_hybrid_auth_client_token_unbound_sets_device_id_none( + editor_user: User, +): + raw_token = _issue_client_token(editor_user) + + class MockConnection(HTTPConnection): + def __init__(self): + self.scope: dict[str, dict] = {"session": {}, "state": {}} + self._headers = {"Authorization": f"Bearer {raw_token}"} + + backend = HybridAuthBackend() + conn = MockConnection() + + result = await backend.authenticate(conn) + assert result is not None + _, user = result + assert user.id == editor_user.id + assert conn.scope["state"].get("device_id") is None + + +async def test_hybrid_auth_client_token_bound_sets_device_id_and_bumps_last_seen( + editor_user: User, +): + device = db_device_handler.add_device( + Device( + id="bound-device-1", + user_id=editor_user.id, + name="Bound Device", + client_device_identifier="cid-bound-1", + ) + ) + raw_token = _issue_client_token(editor_user, device_id=device.id) + + class MockConnection(HTTPConnection): + def __init__(self): + self.scope: dict[str, dict] = {"session": {}, "state": {}} + self._headers = {"Authorization": f"Bearer {raw_token}"} + + backend = HybridAuthBackend() + conn = MockConnection() + + result = await backend.authenticate(conn) + assert result is not None + assert conn.scope["state"].get("device_id") == device.id + + refreshed = db_device_handler.get_device( + device_id=device.id, user_id=editor_user.id + ) + assert refreshed is not None + assert refreshed.last_seen is not None diff --git a/backend/tests/handler/database/test_devices_handler.py b/backend/tests/handler/database/test_devices_handler.py new file mode 100644 index 0000000000..5a66e0db1a --- /dev/null +++ b/backend/tests/handler/database/test_devices_handler.py @@ -0,0 +1,132 @@ +from datetime import datetime, timedelta, timezone + +from handler.database import db_device_handler +from models.device import Device +from models.user import User +from utils.datetime import to_utc + + +class TestGetDeviceByClientIdentifier: + def test_returns_device_for_matching_user_and_identifier(self, admin_user: User): + db_device_handler.add_device( + Device( + id="cid-device-1", + user_id=admin_user.id, + name="A", + client_device_identifier="install-abc", + ) + ) + + found = db_device_handler.get_device_by_client_identifier( + user_id=admin_user.id, + client_device_identifier="install-abc", + ) + + assert found is not None + assert found.id == "cid-device-1" + + def test_returns_none_for_unknown_identifier(self, admin_user: User): + db_device_handler.add_device( + Device( + id="cid-device-2", + user_id=admin_user.id, + client_device_identifier="install-abc", + ) + ) + + found = db_device_handler.get_device_by_client_identifier( + user_id=admin_user.id, + client_device_identifier="does-not-exist", + ) + + assert found is None + + def test_scopes_by_user(self, admin_user: User, editor_user: User): + db_device_handler.add_device( + Device( + id="cid-device-admin", + user_id=admin_user.id, + client_device_identifier="shared-identifier", + ) + ) + db_device_handler.add_device( + Device( + id="cid-device-editor", + user_id=editor_user.id, + client_device_identifier="shared-identifier", + ) + ) + + admin_found = db_device_handler.get_device_by_client_identifier( + user_id=admin_user.id, + client_device_identifier="shared-identifier", + ) + editor_found = db_device_handler.get_device_by_client_identifier( + user_id=editor_user.id, + client_device_identifier="shared-identifier", + ) + + assert admin_found is not None + assert editor_found is not None + assert admin_found.id == "cid-device-admin" + assert editor_found.id == "cid-device-editor" + + def test_empty_identifier_returns_none(self, admin_user: User): + db_device_handler.add_device( + Device( + id="cid-device-3", + user_id=admin_user.id, + client_device_identifier=None, + ) + ) + + found = db_device_handler.get_device_by_client_identifier( + user_id=admin_user.id, + client_device_identifier="", + ) + assert found is None + + +class TestUpdateLastSeenDebounced: + def test_bumps_when_last_seen_is_null(self, admin_user: User): + device = db_device_handler.add_device( + Device(id="debounce-fresh", user_id=admin_user.id, last_seen=None) + ) + + db_device_handler.update_last_seen_debounced(device_id=device.id) + + refreshed = db_device_handler.get_device_by_id(device.id) + assert refreshed is not None + assert refreshed.last_seen is not None + + def test_bumps_when_last_seen_is_old(self, admin_user: User): + old = datetime.now(timezone.utc) - timedelta(minutes=10) + device = db_device_handler.add_device( + Device(id="debounce-old", user_id=admin_user.id, last_seen=old) + ) + + db_device_handler.update_last_seen_debounced(device_id=device.id) + + refreshed = db_device_handler.get_device_by_id(device.id) + assert refreshed is not None + assert refreshed.last_seen is not None + # MariaDB returns naive datetimes; normalize to UTC for comparison + assert to_utc(refreshed.last_seen) > old + + def test_skips_when_last_seen_within_debounce_window(self, admin_user: User): + recent = datetime.now(timezone.utc) - timedelta(minutes=1) + device = db_device_handler.add_device( + Device(id="debounce-recent", user_id=admin_user.id, last_seen=recent) + ) + + db_device_handler.update_last_seen_debounced(device_id=device.id) + + refreshed = db_device_handler.get_device_by_id(device.id) + assert refreshed is not None + # Unchanged: still within the 5-minute debounce window + assert refreshed.last_seen is not None + assert abs((to_utc(refreshed.last_seen) - recent).total_seconds()) < 1 + + def test_noop_on_missing_device(self): + # Should not raise + db_device_handler.update_last_seen_debounced(device_id="does-not-exist") diff --git a/backend/utils/client_tokens.py b/backend/utils/client_tokens.py index 7804b0c75a..72a251a0c0 100644 --- a/backend/utils/client_tokens.py +++ b/backend/utils/client_tokens.py @@ -79,6 +79,7 @@ def build_create_schema(token: ClientToken, raw_token: str) -> ClientTokenCreate last_used_at=token.last_used_at, created_at=token.created_at, user_id=token.user_id, + device_id=token.device_id, raw_token=raw_token, ) @@ -92,6 +93,7 @@ def build_schema(token: ClientToken) -> ClientTokenSchema: last_used_at=token.last_used_at, created_at=token.created_at, user_id=token.user_id, + device_id=token.device_id, ) @@ -104,6 +106,7 @@ def build_admin_schema(token: ClientToken) -> ClientTokenAdminSchema: last_used_at=token.last_used_at, created_at=token.created_at, user_id=token.user_id, + device_id=token.device_id, username=token.user.username, ) diff --git a/backend/utils/device_auth.py b/backend/utils/device_auth.py new file mode 100644 index 0000000000..2e9c5cec0c --- /dev/null +++ b/backend/utils/device_auth.py @@ -0,0 +1,220 @@ +"""Redis-backed state, codes, and rate limits for the device authorization flow. + +RFC 8628-style: a client polls a token endpoint while the user approves the +pairing out-of-band through the web UI. State lives entirely in Redis; nothing +about a pending request touches the database until the user approves. +""" + +import json +import secrets +from datetime import datetime, timedelta, timezone +from typing import Any, Final + +from fastapi import HTTPException, Request, status + +from handler.redis_handler import sync_cache +from utils.client_tokens import PAIR_ALPHABET + +DEVICE_CODE_BYTES: Final[int] = 32 # -> 64 hex chars +USER_CODE_LENGTH: Final[int] = 8 + +PENDING_TTL_SECONDS: Final[int] = 600 # 10-minute hard ceiling +DENIED_TTL_SECONDS: Final[int] = 60 # shrink window after explicit deny +POLL_DEFAULT_INTERVAL_SECONDS: Final[int] = 5 + +AUTHORIZE_RATE_LIMIT: Final[int] = 10 +TOKEN_POLL_RATE_LIMIT: Final[int] = 60 +RATE_LIMIT_WINDOW_SECONDS: Final[int] = 60 + +_KEY_DC = "device_auth:dc:{}" +_KEY_UC = "device_auth:uc:{}" +_KEY_POLL_LAST = "device_auth:poll_last:{}" +_KEY_AUTHORIZE_RATE = "device_auth:rate:authorize:{}" +_KEY_TOKEN_RATE = ( + "device_auth:rate:token:{}" # nosec B105 -- redis key template, not a secret +) + + +class FlowStatus: + PENDING = "pending" + APPROVED = "approved" + DENIED = "denied" + + +def normalize_user_code(code: str) -> str: + """Strip separators and uppercase a user-typed pairing code.""" + return code.replace("-", "").replace(" ", "").upper() + + +def generate_device_code() -> str: + return secrets.token_hex(DEVICE_CODE_BYTES) + + +def generate_user_code() -> str: + return "".join(secrets.choice(PAIR_ALPHABET) for _ in range(USER_CODE_LENGTH)) + + +def build_verification_urls(request: Request, user_code: str) -> tuple[str, str]: + """Build server-origin URLs only — never from client input. + + This is intentional: no user-supplied callback URLs, so no javascript:/data: + XSS surface on the approval page. + """ + base = str(request.base_url).rstrip("/") + verification_url = f"{base}/pair/device" + verification_url_complete = f"{verification_url}?user_code={user_code}" + return verification_url, verification_url_complete + + +def check_authorize_rate_limit(request: Request) -> None: + client_ip = request.client.host if request.client else "unknown" + key = _KEY_AUTHORIZE_RATE.format(client_ip) + pipe = sync_cache.pipeline() + pipe.incr(key) + pipe.expire(key, RATE_LIMIT_WINDOW_SECONDS) + count, _ = pipe.execute() + if count > AUTHORIZE_RATE_LIMIT: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many authorize attempts. Try again later.", + ) + + +def check_token_poll_rate_limit(request: Request) -> None: + client_ip = request.client.host if request.client else "unknown" + key = _KEY_TOKEN_RATE.format(client_ip) + pipe = sync_cache.pipeline() + pipe.incr(key) + pipe.expire(key, RATE_LIMIT_WINDOW_SECONDS) + count, _ = pipe.execute() + if count > TOKEN_POLL_RATE_LIMIT: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many polling attempts. Try again later.", + ) + + +def polled_too_fast(device_code: str, interval_seconds: int) -> bool: + """Per-device_code poll pacing check. + + Records the wall-clock ms of this call and returns True if the caller + polled inside ``interval_seconds`` of the previous poll. + """ + key = _KEY_POLL_LAST.format(device_code) + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) + prev_raw = sync_cache.getset(key, str(now_ms)) + sync_cache.expire(key, max(interval_seconds * 4, 30)) + if prev_raw is None: + return False + try: + prev_ms = int(prev_raw.decode() if isinstance(prev_raw, bytes) else prev_raw) + except (ValueError, AttributeError): + return False + return (now_ms - prev_ms) < (interval_seconds * 1000) + + +def store_pending(device_code: str, user_code: str, data: dict[str, Any]) -> None: + payload = { + **data, + "status": FlowStatus.PENDING, + "user_code": user_code, + } + sync_cache.setex( + _KEY_DC.format(device_code), + PENDING_TTL_SECONDS, + json.dumps(payload), + ) + sync_cache.setex( + _KEY_UC.format(user_code), + PENDING_TTL_SECONDS, + device_code, + ) + + +def load_pending(device_code: str) -> dict[str, Any] | None: + raw = sync_cache.get(_KEY_DC.format(device_code)) + if not raw: + return None + return json.loads(raw) + + +def resolve_device_code_from_user_code(user_code: str) -> str | None: + raw = sync_cache.get(_KEY_UC.format(user_code)) + if not raw: + return None + return raw.decode() if isinstance(raw, bytes) else raw + + +def pending_expires_at(device_code: str) -> datetime: + """Derive a pending request's deadline from the current Redis TTL.""" + remaining = sync_cache.ttl(_KEY_DC.format(device_code)) + if remaining is None or remaining < 0: + remaining = 0 + return datetime.now(timezone.utc) + timedelta(seconds=int(remaining)) + + +def mark_approved( + device_code: str, + *, + raw_token: str, + device_id: str, + scopes: list[str], + expires_at: datetime | None, +) -> None: + pending = load_pending(device_code) + if pending is None: + return + user_code = pending.get("user_code") + approved = { + "status": FlowStatus.APPROVED, + "raw_token": raw_token, + "device_id": device_id, + "scopes": scopes, + "expires_at": expires_at.isoformat() if expires_at else None, + } + remaining = sync_cache.ttl(_KEY_DC.format(device_code)) + if remaining is None or remaining < 1: + remaining = PENDING_TTL_SECONDS + sync_cache.setex( + _KEY_DC.format(device_code), + min(remaining, PENDING_TTL_SECONDS), + json.dumps(approved), + ) + if user_code: + sync_cache.delete(_KEY_UC.format(user_code)) + + +def mark_denied(device_code: str) -> None: + pending = load_pending(device_code) + if pending is None: + return + user_code = pending.get("user_code") + denied = {"status": FlowStatus.DENIED} + sync_cache.setex( + _KEY_DC.format(device_code), + DENIED_TTL_SECONDS, + json.dumps(denied), + ) + if user_code: + sync_cache.delete(_KEY_UC.format(user_code)) + + +def consume_approved(device_code: str) -> dict[str, Any] | None: + """One-shot read of an approved blob. Deletes the key on success. + + Callers MUST have already established via ``load_pending`` that the status + is approved; this helper is defensive and will refuse to return a non- + approved payload (and will reinsert it so a subsequent flow can proceed). + """ + raw = sync_cache.getdel(_KEY_DC.format(device_code)) + if not raw: + return None + data = json.loads(raw) + if data.get("status") != FlowStatus.APPROVED: + sync_cache.setex( + _KEY_DC.format(device_code), + PENDING_TTL_SECONDS, + json.dumps(data), + ) + return None + return data diff --git a/frontend/src/__generated__/index.ts b/frontend/src/__generated__/index.ts index 352bc0682b..af6a288731 100644 --- a/frontend/src/__generated__/index.ts +++ b/frontend/src/__generated__/index.ts @@ -47,6 +47,14 @@ export type { ConversionTaskMeta } from './models/ConversionTaskMeta'; export type { ConversionTaskStatusResponse } from './models/ConversionTaskStatusResponse'; export type { CustomLimitOffsetPage_SimpleRomSchema_ } from './models/CustomLimitOffsetPage_SimpleRomSchema_'; export type { DetailedRomSchema } from './models/DetailedRomSchema'; +export type { DeviceAuthApprovePayload } from './models/DeviceAuthApprovePayload'; +export type { DeviceAuthApproveResponse } from './models/DeviceAuthApproveResponse'; +export type { DeviceAuthDenyPayload } from './models/DeviceAuthDenyPayload'; +export type { DeviceAuthInitPayload } from './models/DeviceAuthInitPayload'; +export type { DeviceAuthInitResponse } from './models/DeviceAuthInitResponse'; +export type { DeviceAuthPendingSchema } from './models/DeviceAuthPendingSchema'; +export type { DeviceAuthTokenPayload } from './models/DeviceAuthTokenPayload'; +export type { DeviceAuthTokenResponse } from './models/DeviceAuthTokenResponse'; export type { DeviceCreatePayload } from './models/DeviceCreatePayload'; export type { DeviceCreateResponse } from './models/DeviceCreateResponse'; export type { DeviceSchema } from './models/DeviceSchema'; diff --git a/frontend/src/__generated__/models/ClientTokenAdminSchema.ts b/frontend/src/__generated__/models/ClientTokenAdminSchema.ts index 3439aea49d..d75366e44e 100644 --- a/frontend/src/__generated__/models/ClientTokenAdminSchema.ts +++ b/frontend/src/__generated__/models/ClientTokenAdminSchema.ts @@ -10,6 +10,7 @@ export type ClientTokenAdminSchema = { last_used_at: (string | null); created_at: string; user_id: number; + device_id?: (string | null); username: string; }; diff --git a/frontend/src/__generated__/models/ClientTokenCreateSchema.ts b/frontend/src/__generated__/models/ClientTokenCreateSchema.ts index 8f2cc7a837..3e5780d732 100644 --- a/frontend/src/__generated__/models/ClientTokenCreateSchema.ts +++ b/frontend/src/__generated__/models/ClientTokenCreateSchema.ts @@ -10,6 +10,7 @@ export type ClientTokenCreateSchema = { last_used_at: (string | null); created_at: string; user_id: number; + device_id?: (string | null); raw_token: string; }; diff --git a/frontend/src/__generated__/models/ClientTokenSchema.ts b/frontend/src/__generated__/models/ClientTokenSchema.ts index 52feebdec6..9c63097288 100644 --- a/frontend/src/__generated__/models/ClientTokenSchema.ts +++ b/frontend/src/__generated__/models/ClientTokenSchema.ts @@ -10,5 +10,6 @@ export type ClientTokenSchema = { last_used_at: (string | null); created_at: string; user_id: number; + device_id?: (string | null); }; diff --git a/frontend/src/__generated__/models/DeviceAuthApprovePayload.ts b/frontend/src/__generated__/models/DeviceAuthApprovePayload.ts new file mode 100644 index 0000000000..ab07932367 --- /dev/null +++ b/frontend/src/__generated__/models/DeviceAuthApprovePayload.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeviceAuthApprovePayload = { + user_code: string; + approved_scopes: Array; + device_name?: (string | null); + expires_in?: (string | null); +}; diff --git a/frontend/src/__generated__/models/DeviceAuthApproveResponse.ts b/frontend/src/__generated__/models/DeviceAuthApproveResponse.ts new file mode 100644 index 0000000000..4fc32fef3f --- /dev/null +++ b/frontend/src/__generated__/models/DeviceAuthApproveResponse.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeviceAuthApproveResponse = { + device_id: string; + device_name: (string | null); +}; diff --git a/frontend/src/__generated__/models/DeviceAuthDenyPayload.ts b/frontend/src/__generated__/models/DeviceAuthDenyPayload.ts new file mode 100644 index 0000000000..2ba5aac960 --- /dev/null +++ b/frontend/src/__generated__/models/DeviceAuthDenyPayload.ts @@ -0,0 +1,7 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeviceAuthDenyPayload = { + user_code: string; +}; diff --git a/frontend/src/__generated__/models/DeviceAuthInitPayload.ts b/frontend/src/__generated__/models/DeviceAuthInitPayload.ts new file mode 100644 index 0000000000..f9314c9fa4 --- /dev/null +++ b/frontend/src/__generated__/models/DeviceAuthInitPayload.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeviceAuthInitPayload = { + client_device_identifier: string; + name: string; + client: string; + platform?: (string | null); + client_version?: (string | null); + requested_scopes: Array; +}; diff --git a/frontend/src/__generated__/models/DeviceAuthInitResponse.ts b/frontend/src/__generated__/models/DeviceAuthInitResponse.ts new file mode 100644 index 0000000000..39bf29d3b1 --- /dev/null +++ b/frontend/src/__generated__/models/DeviceAuthInitResponse.ts @@ -0,0 +1,12 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeviceAuthInitResponse = { + device_code: string; + user_code: string; + verification_url: string; + verification_url_complete: string; + expires_in: number; + interval: number; +}; diff --git a/frontend/src/__generated__/models/DeviceAuthPendingSchema.ts b/frontend/src/__generated__/models/DeviceAuthPendingSchema.ts new file mode 100644 index 0000000000..c44d1787a1 --- /dev/null +++ b/frontend/src/__generated__/models/DeviceAuthPendingSchema.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeviceAuthPendingSchema = { + client_device_identifier: string; + name: string; + client: string; + platform: (string | null); + client_version: (string | null); + requested_scopes: Array; + allowed_scopes: Array; + expires_at: string; +}; diff --git a/frontend/src/__generated__/models/DeviceAuthTokenPayload.ts b/frontend/src/__generated__/models/DeviceAuthTokenPayload.ts new file mode 100644 index 0000000000..b00748df73 --- /dev/null +++ b/frontend/src/__generated__/models/DeviceAuthTokenPayload.ts @@ -0,0 +1,7 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeviceAuthTokenPayload = { + device_code: string; +}; diff --git a/frontend/src/__generated__/models/DeviceAuthTokenResponse.ts b/frontend/src/__generated__/models/DeviceAuthTokenResponse.ts new file mode 100644 index 0000000000..33c3f74c49 --- /dev/null +++ b/frontend/src/__generated__/models/DeviceAuthTokenResponse.ts @@ -0,0 +1,10 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DeviceAuthTokenResponse = { + access_token: string; + device_id: string; + scopes: Array; + expires_at: (string | null); +}; diff --git a/frontend/src/__generated__/models/DeviceSchema.ts b/frontend/src/__generated__/models/DeviceSchema.ts index da7f458e4b..b4a8cd13b7 100644 --- a/frontend/src/__generated__/models/DeviceSchema.ts +++ b/frontend/src/__generated__/models/DeviceSchema.ts @@ -13,6 +13,7 @@ export type DeviceSchema = { ip_address: (string | null); mac_address: (string | null); hostname: (string | null); + client_device_identifier: (string | null); sync_mode: SyncMode; sync_enabled: boolean; sync_config: (Record | null); diff --git a/frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue b/frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue index f529d974ce..e6af06323c 100644 --- a/frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue +++ b/frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue @@ -1,10 +1,12 @@ + +