From e0b25fbc6c86d89bfb42ba923494e091f14905aa Mon Sep 17 00:00:00 2001 From: nendo Date: Tue, 10 Mar 2026 16:06:07 +0900 Subject: [PATCH] feat(client-tokens): add client API tokens with QR pairing flow Long-lived, revocable, scope-restricted tokens for external clients (mobile apps, retro handhelds, third-party tools). Includes: - Backend: model, migration, DB handler, auth integration (rmm_ prefix routing in HybridAuthBackend), CRUD + pairing + exchange endpoints, rate limiting, scope intersection enforcement, admin oversight - Frontend: settings page with token management table, stepped create/deliver dialog (config -> copy/pair), QR code with RomM logo, admin token table, standalone /pair page for QR scan landing - /pair page supports custom-scheme callbacks for app deep linking, falls back to displaying code for manual entry - 33 backend tests across 5 classes (CRUD, auth, isolation, pairing, admin) --- .../alembic/versions/0072_client_tokens.py | 60 ++ backend/endpoints/client_tokens.py | 290 +++++++ backend/endpoints/responses/client_token.py | 29 + backend/handler/auth/base_handler.py | 10 + backend/handler/auth/hybrid_auth.py | 30 + backend/handler/database/__init__.py | 2 + .../handler/database/client_tokens_handler.py | 143 ++++ backend/main.py | 4 + backend/models/client_token.py | 27 + backend/models/user.py | 4 + backend/tests/conftest.py | 2 + backend/tests/endpoints/test_client_tokens.py | 718 ++++++++++++++++++ frontend/package-lock.json | 16 + .../Administration/Tokens/TokensTable.vue | 154 ++++ .../ClientApiTokens/ClientTokensTable.vue | 167 ++++ .../Dialog/CreateClientToken.vue | 561 ++++++++++++++ .../Dialog/DeleteClientToken.vue | 85 +++ .../common/Navigation/SettingsDrawer.vue | 12 + frontend/src/locales/en_US/settings.json | 17 + frontend/src/plugins/router.ts | 22 +- frontend/src/services/api/client-token.ts | 88 +++ frontend/src/types/emitter.d.ts | 4 + frontend/src/views/Pair.vue | 107 +++ .../src/views/Settings/Administration.vue | 2 + .../src/views/Settings/ClientApiTokens.vue | 6 + 25 files changed, 2558 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0072_client_tokens.py create mode 100644 backend/endpoints/client_tokens.py create mode 100644 backend/endpoints/responses/client_token.py create mode 100644 backend/handler/database/client_tokens_handler.py create mode 100644 backend/models/client_token.py create mode 100644 backend/tests/endpoints/test_client_tokens.py create mode 100644 frontend/src/components/Settings/Administration/Tokens/TokensTable.vue create mode 100644 frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue create mode 100644 frontend/src/components/Settings/ClientApiTokens/Dialog/CreateClientToken.vue create mode 100644 frontend/src/components/Settings/ClientApiTokens/Dialog/DeleteClientToken.vue create mode 100644 frontend/src/services/api/client-token.ts create mode 100644 frontend/src/views/Pair.vue create mode 100644 frontend/src/views/Settings/ClientApiTokens.vue diff --git a/backend/alembic/versions/0072_client_tokens.py b/backend/alembic/versions/0072_client_tokens.py new file mode 100644 index 000000000..a8386ddee --- /dev/null +++ b/backend/alembic/versions/0072_client_tokens.py @@ -0,0 +1,60 @@ +"""Add client_tokens table for long-lived API tokens + +Revision ID: 0072_client_tokens +Revises: 0071_sibling_roms_fs_name +Create Date: 2026-03-10 00:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "0072_client_tokens" +down_revision = "0071_sibling_roms_fs_name" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "client_tokens", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("hashed_token", sa.String(length=64), nullable=False), + sa.Column("scopes", sa.String(length=1000), nullable=False), + sa.Column("expires_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("last_used_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.TIMESTAMP(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + with op.batch_alter_table("client_tokens") as batch_op: + batch_op.create_index( + batch_op.f("ix_client_tokens_hashed_token"), + ["hashed_token"], + unique=True, + ) + batch_op.create_index( + batch_op.f("ix_client_tokens_user_id"), + ["user_id"], + ) + + +def downgrade() -> None: + with op.batch_alter_table("client_tokens") as batch_op: + batch_op.drop_index(batch_op.f("ix_client_tokens_user_id")) + batch_op.drop_index(batch_op.f("ix_client_tokens_hashed_token")) + op.drop_table("client_tokens") diff --git a/backend/endpoints/client_tokens.py b/backend/endpoints/client_tokens.py new file mode 100644 index 000000000..00bae9998 --- /dev/null +++ b/backend/endpoints/client_tokens.py @@ -0,0 +1,290 @@ +import json +import secrets +from datetime import datetime, timedelta, timezone + +from fastapi import HTTPException, Request, status +from pydantic import BaseModel + +from decorators.auth import protected_route +from endpoints.responses.client_token import ( + ClientTokenAdminSchema, + ClientTokenCreateSchema, + ClientTokenPairSchema, + ClientTokenSchema, +) +from handler.auth.base_handler import generate_client_token, hash_client_token +from handler.auth.constants import Scope +from handler.database import db_client_token_handler, db_user_handler +from handler.redis_handler import sync_cache +from models.client_token import ClientToken +from utils.router import APIRouter + +router = APIRouter( + prefix="/client-tokens", + tags=["client-tokens"], +) + +MAX_TOKENS_PER_USER = 25 +PAIR_CODE_LENGTH = 8 +PAIR_CODE_TTL_SECONDS = 60 +RATE_LIMIT_MAX_ATTEMPTS = 5 +RATE_LIMIT_WINDOW_SECONDS = 60 +PAIR_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +EXPIRY_MAP = { + "30d": timedelta(days=30), + "90d": timedelta(days=90), + "1y": timedelta(days=365), +} + + +class ClientTokenCreatePayload(BaseModel): + name: str + scopes: list[str] + expires_in: str | None = None + + +class ClientTokenExchangePayload(BaseModel): + code: str + + +def _generate_pair_code() -> str: + return "".join(secrets.choice(PAIR_ALPHABET) for _ in range(PAIR_CODE_LENGTH)) + + +def _parse_expiry(expires_in: str | None) -> datetime | None: + if expires_in is None or expires_in == "never": + return None + delta = EXPIRY_MAP.get(expires_in) + if delta is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid expires_in value: {expires_in}. " + f"Valid values: {', '.join(EXPIRY_MAP.keys())}, never", + ) + return datetime.now(timezone.utc) + delta + + +def _validate_scopes(requested: list[str], user_scopes: list[Scope]) -> None: + user_scope_values = {str(s) for s in user_scopes} + invalid = set(requested) - user_scope_values + if invalid: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Requested scopes exceed your permissions: " + f"{', '.join(sorted(invalid))}", + ) + + +def _check_rate_limit(request: Request) -> None: + client_ip = request.client.host if request.client else "unknown" + rate_key = f"client-token-rate:{client_ip}" + current = sync_cache.get(rate_key) + if current and int(current) >= RATE_LIMIT_MAX_ATTEMPTS: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many exchange attempts. Try again later.", + ) + pipe = sync_cache.pipeline() + pipe.incr(rate_key) + pipe.expire(rate_key, RATE_LIMIT_WINDOW_SECONDS) + pipe.execute() + + +def _build_create_schema(token: ClientToken, raw_token: str) -> ClientTokenCreateSchema: + return ClientTokenCreateSchema( + id=token.id, + name=token.name, + scopes=token.scopes.split(), + expires_at=token.expires_at, + last_used_at=token.last_used_at, + created_at=token.created_at, + user_id=token.user_id, + raw_token=raw_token, + ) + + +def _build_schema(token: ClientToken) -> ClientTokenSchema: + return ClientTokenSchema( + id=token.id, + name=token.name, + scopes=token.scopes.split(), + expires_at=token.expires_at, + last_used_at=token.last_used_at, + created_at=token.created_at, + user_id=token.user_id, + ) + + +def _build_admin_schema(token: ClientToken) -> ClientTokenAdminSchema: + return ClientTokenAdminSchema( + id=token.id, + name=token.name, + scopes=token.scopes.split(), + expires_at=token.expires_at, + last_used_at=token.last_used_at, + created_at=token.created_at, + user_id=token.user_id, + username=token.user.username, + ) + + +@protected_route(router.post, "", [Scope.ME_WRITE], status_code=status.HTTP_201_CREATED) +def create_token( + request: Request, + payload: ClientTokenCreatePayload, +) -> ClientTokenCreateSchema: + user = request.user + _validate_scopes(payload.scopes, user.oauth_scopes) + + count = db_client_token_handler.count_tokens_by_user(user.id) + if count >= MAX_TOKENS_PER_USER: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum of {MAX_TOKENS_PER_USER} tokens per user reached", + ) + + raw_token = generate_client_token() + hashed = hash_client_token(raw_token) + expires_at = _parse_expiry(payload.expires_in) + + token = ClientToken( + user_id=user.id, + name=payload.name, + hashed_token=hashed, + scopes=" ".join(payload.scopes), + expires_at=expires_at, + ) + token = db_client_token_handler.add_token(token) + return _build_create_schema(token, raw_token) + + +@protected_route(router.get, "", [Scope.ME_READ]) +def list_tokens(request: Request) -> list[ClientTokenSchema]: + tokens = db_client_token_handler.get_tokens_by_user(request.user.id) + return [_build_schema(t) for t in tokens] + + +@protected_route(router.delete, "/{token_id}", [Scope.ME_WRITE]) +def delete_token(request: Request, token_id: int) -> None: + rows = db_client_token_handler.delete_token( + token_id=token_id, user_id=request.user.id + ) + if rows == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Token not found", + ) + + +@protected_route(router.put, "/{token_id}/regenerate", [Scope.ME_WRITE]) +def regenerate_token(request: Request, token_id: int) -> ClientTokenCreateSchema: + raw_token = generate_client_token() + new_hash = hash_client_token(raw_token) + + token = db_client_token_handler.update_hashed_token( + token_id=token_id, + new_hash=new_hash, + user_id=request.user.id, + ) + if token is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Token not found", + ) + return _build_create_schema(token, raw_token) + + +@protected_route(router.post, "/{token_id}/pair", [Scope.ME_WRITE]) +def pair_token(request: Request, token_id: int) -> ClientTokenPairSchema: + token = db_client_token_handler.get_token( + token_id=token_id, user_id=request.user.id + ) + if token is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Token not found", + ) + + code = _generate_pair_code() + redis_key = f"pair:{code}" + sync_cache.setex( + redis_key, + PAIR_CODE_TTL_SECONDS, + json.dumps({"token_id": token_id, "user_id": request.user.id}), + ) + return ClientTokenPairSchema(code=code, expires_in=PAIR_CODE_TTL_SECONDS) + + +@router.get( + "/pair/{code}/status", + status_code=status.HTTP_200_OK, +) +def pair_status(code: str) -> None: + normalized = code.replace("-", "").upper() + redis_key = f"pair:{normalized}" + if not sync_cache.exists(redis_key): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + +def _exchange(request: Request, code: str) -> ClientTokenCreateSchema: + _check_rate_limit(request) + + normalized = code.replace("-", "").upper() + redis_key = f"pair:{normalized}" + data = sync_cache.get(redis_key) + if not data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Invalid or expired pairing code", + ) + + sync_cache.delete(redis_key) + params = json.loads(data) + + user = db_user_handler.get_user(params["user_id"]) + if user is None or not user.enabled: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Token owner is disabled", + ) + + raw_token = generate_client_token() + new_hash = hash_client_token(raw_token) + + token = db_client_token_handler.update_hashed_token( + token_id=params["token_id"], + new_hash=new_hash, + user_id=params["user_id"], + ) + if token is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Token no longer exists", + ) + + return _build_create_schema(token, raw_token) + + +@router.post("/exchange") +def exchange_pair_code( + request: Request, + payload: ClientTokenExchangePayload, +) -> ClientTokenCreateSchema: + return _exchange(request, payload.code) + + +@protected_route(router.get, "/all", [Scope.USERS_READ]) +def list_all_tokens(request: Request) -> list[ClientTokenAdminSchema]: + tokens = db_client_token_handler.get_all_tokens() + return [_build_admin_schema(t) for t in tokens] + + +@protected_route(router.delete, "/{token_id}/admin", [Scope.USERS_WRITE]) +def admin_delete_token(request: Request, token_id: int) -> None: + rows = db_client_token_handler.delete_token(token_id=token_id) + if rows == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Token not found", + ) diff --git a/backend/endpoints/responses/client_token.py b/backend/endpoints/responses/client_token.py new file mode 100644 index 000000000..4ca800bd3 --- /dev/null +++ b/backend/endpoints/responses/client_token.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from .base import BaseModel + + +class ClientTokenSchema(BaseModel): + id: int + name: str + scopes: list[str] + expires_at: datetime | None + last_used_at: datetime | None + created_at: datetime + user_id: int + + class Config: + from_attributes = True + + +class ClientTokenCreateSchema(ClientTokenSchema): + raw_token: str + + +class ClientTokenAdminSchema(ClientTokenSchema): + username: str + + +class ClientTokenPairSchema(BaseModel): + code: str + expires_in: int diff --git a/backend/handler/auth/base_handler.py b/backend/handler/auth/base_handler.py index d16fa28c8..3b37b9ec3 100644 --- a/backend/handler/auth/base_handler.py +++ b/backend/handler/auth/base_handler.py @@ -1,3 +1,5 @@ +import hashlib +import secrets import uuid from datetime import datetime, timedelta, timezone from typing import Any @@ -32,6 +34,14 @@ from logger.logger import log oct_key = OctKey.import_key(ROMM_AUTH_SECRET_KEY) +def generate_client_token() -> str: + return "rmm_" + secrets.token_hex(32) + + +def hash_client_token(raw: str) -> str: + return hashlib.sha256(raw.encode()).hexdigest() + + class AuthHandler: def __init__(self) -> None: self.pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") diff --git a/backend/handler/auth/hybrid_auth.py b/backend/handler/auth/hybrid_auth.py index 688b8c081..681b3b41c 100644 --- a/backend/handler/auth/hybrid_auth.py +++ b/backend/handler/auth/hybrid_auth.py @@ -1,10 +1,15 @@ +from datetime import datetime, timezone + from fastapi.security.http import HTTPBasic from starlette.authentication import AuthCredentials, AuthenticationBackend from starlette.requests import HTTPConnection from config import KIOSK_MODE from handler.auth import auth_handler, oauth_handler +from handler.auth.base_handler import hash_client_token +from handler.database import db_client_token_handler, db_user_handler from models.user import User +from utils.datetime import to_utc from .constants import READ_SCOPES @@ -40,6 +45,31 @@ class HybridAuthBackend(AuthenticationBackend): # Check if bearer auth header is valid if scheme.lower() == "bearer": + # Client API tokens use the rmm_ prefix + if token.startswith("rmm_"): + hashed = hash_client_token(token) + client_token = db_client_token_handler.get_token_by_hash(hashed) + if client_token is None: + return None + + if client_token.expires_at and to_utc( + client_token.expires_at + ) < datetime.now(timezone.utc): + return None + + user = db_user_handler.get_user(client_token.user_id) + if user is None or not user.enabled: + return None + + token_scopes = set(client_token.scopes.split()) + effective_scopes = list(token_scopes & set(user.oauth_scopes)) + + db_client_token_handler.update_last_used(client_token.id) + user.set_last_active() + conn.state.client_token_id = client_token.id + return (AuthCredentials(effective_scopes), user) + + # OAuth JWT bearer tokens ( user, claims, diff --git a/backend/handler/database/__init__.py b/backend/handler/database/__init__.py index a7f1a22d1..1b0867cc4 100644 --- a/backend/handler/database/__init__.py +++ b/backend/handler/database/__init__.py @@ -1,3 +1,4 @@ +from .client_tokens_handler import DBClientTokensHandler from .collections_handler import DBCollectionsHandler from .device_save_sync_handler import DBDeviceSaveSyncHandler from .devices_handler import DBDevicesHandler @@ -10,6 +11,7 @@ from .states_handler import DBStatesHandler from .stats_handler import DBStatsHandler from .users_handler import DBUsersHandler +db_client_token_handler = DBClientTokensHandler() db_collection_handler = DBCollectionsHandler() db_device_handler = DBDevicesHandler() db_device_save_sync_handler = DBDeviceSaveSyncHandler() diff --git a/backend/handler/database/client_tokens_handler.py b/backend/handler/database/client_tokens_handler.py new file mode 100644 index 000000000..7bd27a5d8 --- /dev/null +++ b/backend/handler/database/client_tokens_handler.py @@ -0,0 +1,143 @@ +from collections.abc import Sequence +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, func, select, update +from sqlalchemy.orm import Session, joinedload + +from decorators.database import begin_session +from models.client_token import ClientToken +from utils.datetime import to_utc + +from .base_handler import DBBaseHandler + +LAST_USED_DEBOUNCE = timedelta(minutes=5) + + +class DBClientTokensHandler(DBBaseHandler): + @begin_session + def add_token( + self, + token: ClientToken, + session: Session = None, # type: ignore + ) -> ClientToken: + return session.merge(token) + + @begin_session + def get_token_by_hash( + self, + hashed_token: str, + session: Session = None, # type: ignore + ) -> ClientToken | None: + return session.scalar( + select(ClientToken).where(ClientToken.hashed_token == hashed_token) + ) + + @begin_session + def get_tokens_by_user( + self, + user_id: int, + session: Session = None, # type: ignore + ) -> Sequence[ClientToken]: + return session.scalars( + select(ClientToken) + .where(ClientToken.user_id == user_id) + .order_by(ClientToken.created_at.desc()) + ).all() + + @begin_session + def get_all_tokens( + self, + session: Session = None, # type: ignore + ) -> Sequence[ClientToken]: + return ( + session.scalars( + select(ClientToken) + .options(joinedload(ClientToken.user)) + .order_by(ClientToken.created_at.desc()) + ) + .unique() + .all() + ) + + @begin_session + def delete_token( + self, + token_id: int, + user_id: int | None = None, + session: Session = None, # type: ignore + ) -> int: + stmt = delete(ClientToken).where(ClientToken.id == token_id) + if user_id is not None: + stmt = stmt.where(ClientToken.user_id == user_id) + result = session.execute(stmt.execution_options(synchronize_session="evaluate")) + return result.rowcount + + @begin_session + def update_last_used( + self, + token_id: int, + session: Session = None, # type: ignore + ) -> None: + now = datetime.now(timezone.utc) + token = session.get(ClientToken, token_id) + if token is None: + return + if ( + token.last_used_at + and (now - to_utc(token.last_used_at)) < LAST_USED_DEBOUNCE + ): + return + session.execute( + update(ClientToken) + .where(ClientToken.id == token_id) + .values(last_used_at=now) + .execution_options(synchronize_session="evaluate") + ) + + @begin_session + def update_hashed_token( + self, + token_id: int, + new_hash: str, + user_id: int | None = None, + session: Session = None, # type: ignore + ) -> ClientToken | None: + stmt = ( + update(ClientToken) + .where(ClientToken.id == token_id) + .values(hashed_token=new_hash, last_used_at=None) + .execution_options(synchronize_session="evaluate") + ) + if user_id is not None: + stmt = stmt.where(ClientToken.user_id == user_id) + result = session.execute(stmt) + if result.rowcount == 0: + return None + return session.get(ClientToken, token_id) + + @begin_session + def count_tokens_by_user( + self, + user_id: int, + session: Session = None, # type: ignore + ) -> int: + return ( + session.scalar( + select(func.count()) + .select_from(ClientToken) + .where(ClientToken.user_id == user_id) + ) + or 0 + ) + + @begin_session + def get_token( + self, + token_id: int, + user_id: int | None = None, + session: Session = None, # type: ignore + ) -> ClientToken | None: + stmt = select(ClientToken).where(ClientToken.id == token_id) + if user_id is not None: + stmt = stmt.where(ClientToken.user_id == user_id) + return session.scalar(stmt) diff --git a/backend/main.py b/backend/main.py index 21c6edf94..fad20037a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -25,6 +25,7 @@ from config import ( SENTRY_DSN, ) from endpoints.auth import router as auth_router +from endpoints.client_tokens import router as client_tokens_router from endpoints.collections import router as collections_router from endpoints.configs import router as configs_router from endpoints.device import router as device_router @@ -96,6 +97,8 @@ if not IS_PYTEST_RUN and not DISABLE_CSRF_PROTECTION: secret=ROMM_AUTH_SECRET_KEY, exempt_urls=[ re.compile(r"^/api/token.*"), + re.compile(r"^/api/client-tokens/exchange"), + re.compile(r"^/api/client-tokens/pair/.+/status"), re.compile(r"^/ws"), re.compile(r"^/netplay"), ], @@ -121,6 +124,7 @@ app.middleware("http")(set_context_middleware) app.include_router(heartbeat_router, prefix="/api") app.include_router(auth_router, prefix="/api") 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(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 new file mode 100644 index 000000000..fada6ce51 --- /dev/null +++ b/backend/models/client_token.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import TIMESTAMP, ForeignKey, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from models.base import BaseModel + +if TYPE_CHECKING: + from models.user import User + + +class ClientToken(BaseModel): + __tablename__ = "client_tokens" + __table_args__ = {"extend_existing": True} + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + name: Mapped[str] = mapped_column(String(255)) + hashed_token: Mapped[str] = mapped_column(String(64), unique=True, index=True) + scopes: Mapped[str] = mapped_column(String(1000)) + expires_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) + + user: Mapped[User] = relationship(lazy="joined") diff --git a/backend/models/user.py b/backend/models/user.py index 2643f1656..227c75510 100644 --- a/backend/models/user.py +++ b/backend/models/user.py @@ -21,6 +21,7 @@ from utils.database import CustomJSON if TYPE_CHECKING: from models.assets import Save, Screenshot, State + from models.client_token import ClientToken from models.collection import Collection, SmartCollection from models.device import Device from models.rom import RomNote, RomUser @@ -83,6 +84,9 @@ class User(BaseModel, SimpleUser): devices: Mapped[list["Device"]] = relationship( lazy="raise", back_populates="user", cascade="all, delete-orphan" ) + client_tokens: Mapped[list["ClientToken"]] = relationship( + lazy="raise", back_populates="user" + ) @classmethod def kiosk_mode_user(cls) -> User: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 37138fb50..15f3338f5 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -18,6 +18,7 @@ from handler.database import ( db_user_handler, ) from models.assets import Save, Screenshot, State +from models.client_token import ClientToken from models.device import Device from models.device_save_sync import DeviceSaveSync from models.platform import Platform @@ -36,6 +37,7 @@ def setup_database(): @pytest.fixture(autouse=True) def clear_database(): with session.begin() as s: + s.query(ClientToken).delete(synchronize_session="evaluate") s.query(DeviceSaveSync).delete(synchronize_session="evaluate") s.query(Device).delete(synchronize_session="evaluate") s.query(Save).delete(synchronize_session="evaluate") diff --git a/backend/tests/endpoints/test_client_tokens.py b/backend/tests/endpoints/test_client_tokens.py new file mode 100644 index 000000000..406ce39fa --- /dev/null +++ b/backend/tests/endpoints/test_client_tokens.py @@ -0,0 +1,718 @@ +from datetime import timedelta + +import pytest +from fastapi import status +from fastapi.testclient import TestClient +from main import app + +from endpoints.auth import ACCESS_TOKEN_EXPIRE_SECONDS +from handler.auth import oauth_handler +from handler.auth.base_handler import hash_client_token +from handler.database import db_client_token_handler, db_user_handler +from handler.redis_handler import sync_cache +from models.client_token import ClientToken +from models.user import Role, User + + +@pytest.fixture +def client(): + with TestClient(app) as client: + yield client + + +@pytest.fixture +def editor_access_token(editor_user): + return oauth_handler.create_oauth_token( + data={ + "sub": editor_user.username, + "iss": "romm:oauth", + "scopes": " ".join(editor_user.oauth_scopes), + "type": "access", + }, + expires_delta=timedelta(seconds=ACCESS_TOKEN_EXPIRE_SECONDS), + ) + + +@pytest.fixture +def viewer_access_token(viewer_user): + return oauth_handler.create_oauth_token( + data={ + "sub": viewer_user.username, + "iss": "romm:oauth", + "scopes": " ".join(viewer_user.oauth_scopes), + "type": "access", + }, + expires_delta=timedelta(seconds=ACCESS_TOKEN_EXPIRE_SECONDS), + ) + + +@pytest.fixture(autouse=True) +def clear_cache(): + yield + sync_cache.flushall() + + +class TestClientTokenCRUD: + def test_create_token(self, client, access_token, admin_user): + response = client.post( + "/api/client-tokens", + json={ + "name": "My Device", + "scopes": ["roms.read", "assets.read"], + "expires_in": "90d", + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + body = response.json() + assert body["name"] == "My Device" + assert body["raw_token"].startswith("rmm_") + assert len(body["raw_token"]) == 68 + assert set(body["scopes"]) == {"roms.read", "assets.read"} + assert body["expires_at"] is not None + assert body["user_id"] == admin_user.id + + def test_create_token_minimal(self, client, access_token, admin_user): + response = client.post( + "/api/client-tokens", + json={ + "name": "Never Expires", + "scopes": ["roms.read"], + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_201_CREATED + body = response.json() + assert body["expires_at"] is None + assert body["raw_token"].startswith("rmm_") + + def test_list_tokens(self, client, access_token, admin_user): + for name in ["Token A", "Token B"]: + client.post( + "/api/client-tokens", + json={"name": name, "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + + response = client.get( + "/api/client-tokens", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + tokens = response.json() + assert len(tokens) == 2 + names = {t["name"] for t in tokens} + assert names == {"Token A", "Token B"} + for t in tokens: + assert "raw_token" not in t + + def test_delete_token(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "To Delete", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + + response = client.delete( + f"/api/client-tokens/{token_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + list_resp = client.get( + "/api/client-tokens", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert len(list_resp.json()) == 0 + + def test_delete_token_not_found(self, client, access_token, admin_user): + response = client.delete( + "/api/client-tokens/99999", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_regenerate_token(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={ + "name": "Regenerable", + "scopes": ["roms.read"], + "expires_in": "30d", + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + old_raw = create_resp.json()["raw_token"] + token_id = create_resp.json()["id"] + + regen_resp = client.put( + f"/api/client-tokens/{token_id}/regenerate", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert regen_resp.status_code == status.HTTP_200_OK + body = regen_resp.json() + new_raw = body["raw_token"] + assert new_raw.startswith("rmm_") + assert new_raw != old_raw + assert body["name"] == "Regenerable" + assert body["scopes"] == ["roms.read"] + assert body["expires_at"] is not None + + # Old token should no longer work + old_hash = hash_client_token(old_raw) + assert db_client_token_handler.get_token_by_hash(old_hash) is None + + # New token should work + new_hash = hash_client_token(new_raw) + assert db_client_token_handler.get_token_by_hash(new_hash) is not None + + def test_create_token_limit(self, client, access_token, admin_user): + for i in range(25): + resp = client.post( + "/api/client-tokens", + json={"name": f"Token {i}", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_201_CREATED + + resp = client.post( + "/api/client-tokens", + json={"name": "Token 26", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + def test_create_token_invalid_expiry(self, client, access_token, admin_user): + response = client.post( + "/api/client-tokens", + json={ + "name": "Bad Expiry", + "scopes": ["roms.read"], + "expires_in": "999x", + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +class TestClientTokenAuth: + def test_authenticate_with_client_token(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Auth Test", "scopes": ["roms.read", "platforms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + raw_token = create_resp.json()["raw_token"] + + # Use the client token to hit a protected endpoint + response = client.get( + "/api/platforms", + headers={"Authorization": f"Bearer {raw_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + def test_expired_token_rejected(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={ + "name": "Expired", + "scopes": ["roms.read", "platforms.read"], + "expires_in": "30d", + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + raw_token = create_resp.json()["raw_token"] + token_id = create_resp.json()["id"] + + # Manually set expires_at to the past + from tests.conftest import session + + with session.begin() as s: + from datetime import datetime, timezone + + s.query(ClientToken).filter_by(id=token_id).update( + {"expires_at": datetime(2020, 1, 1, tzinfo=timezone.utc)} + ) + + response = client.get( + "/api/platforms", + headers={"Authorization": f"Bearer {raw_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_revoked_token_rejected(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Revoked", "scopes": ["roms.read", "platforms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + raw_token = create_resp.json()["raw_token"] + token_id = create_resp.json()["id"] + + client.delete( + f"/api/client-tokens/{token_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + + response = client.get( + "/api/platforms", + headers={"Authorization": f"Bearer {raw_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_scope_enforcement(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Read Only", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + raw_token = create_resp.json()["raw_token"] + + # roms.read should allow listing platforms? No -- need platforms.read + response = client.get( + "/api/platforms", + headers={"Authorization": f"Bearer {raw_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_scope_intersection_on_demotion(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={ + "name": "Admin Token", + "scopes": ["users.write", "roms.read", "platforms.read"], + }, + headers={"Authorization": f"Bearer {access_token}"}, + ) + raw_token = create_resp.json()["raw_token"] + + # Demote user to viewer + db_user_handler.update_user(admin_user.id, {"role": Role.VIEWER}) + + # users.write should no longer be effective + response = client.get( + "/api/users", + headers={"Authorization": f"Bearer {raw_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Restore admin role for other tests + db_user_handler.update_user(admin_user.id, {"role": Role.ADMIN}) + + def test_disabled_user_rejected(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Disabled", "scopes": ["roms.read", "platforms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + raw_token = create_resp.json()["raw_token"] + + db_user_handler.update_user(admin_user.id, {"enabled": False}) + + response = client.get( + "/api/platforms", + headers={"Authorization": f"Bearer {raw_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + # Re-enable for other tests + db_user_handler.update_user(admin_user.id, {"enabled": True}) + + def test_invalid_token_format(self, client, admin_user): + response = client.get( + "/api/platforms", + headers={"Authorization": "Bearer rmm_invalidgarbage"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_scopes_subset_validation(self, client, viewer_access_token, viewer_user): + response = client.post( + "/api/client-tokens", + json={ + "name": "Overreach", + "scopes": ["users.write"], + }, + headers={"Authorization": f"Bearer {viewer_access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestClientTokenUserIsolation: + def test_list_only_own_tokens( + self, + client, + access_token, + editor_access_token, + admin_user, + editor_user, + ): + client.post( + "/api/client-tokens", + json={"name": "Admin Token", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + client.post( + "/api/client-tokens", + json={"name": "Editor Token", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + + admin_list = client.get( + "/api/client-tokens", + headers={"Authorization": f"Bearer {access_token}"}, + ).json() + editor_list = client.get( + "/api/client-tokens", + headers={"Authorization": f"Bearer {editor_access_token}"}, + ).json() + + assert len(admin_list) == 1 + assert admin_list[0]["name"] == "Admin Token" + assert len(editor_list) == 1 + assert editor_list[0]["name"] == "Editor Token" + + def test_cannot_delete_other_users_token( + self, + client, + access_token, + editor_access_token, + admin_user, + editor_user, + ): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Editor's Token", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + token_id = create_resp.json()["id"] + + # Admin tries to delete via user endpoint (not admin endpoint) + response = client.delete( + f"/api/client-tokens/{token_id}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + # Verify token still exists + editor_tokens = client.get( + "/api/client-tokens", + headers={"Authorization": f"Bearer {editor_access_token}"}, + ).json() + assert len(editor_tokens) == 1 + + def test_cannot_regenerate_other_users_token( + self, + client, + access_token, + editor_access_token, + admin_user, + editor_user, + ): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Editor's Token", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + token_id = create_resp.json()["id"] + + response = client.put( + f"/api/client-tokens/{token_id}/regenerate", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_admin_can_list_all_tokens( + self, + client, + access_token, + editor_access_token, + admin_user, + editor_user, + ): + client.post( + "/api/client-tokens", + json={"name": "Admin Token", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + client.post( + "/api/client-tokens", + json={"name": "Editor Token", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + + response = client.get( + "/api/client-tokens/all", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + tokens = response.json() + assert len(tokens) == 2 + usernames = {t["username"] for t in tokens} + assert usernames == {"test_admin", "test_editor"} + + +class TestClientTokenPairing: + def test_pair_creates_code(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Pair Test", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + + response = client.post( + f"/api/client-tokens/{token_id}/pair", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert len(body["code"]) == 8 + assert body["expires_in"] == 60 + + def test_exchange_returns_token(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Exchange Test", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + old_raw = create_resp.json()["raw_token"] + + pair_resp = client.post( + f"/api/client-tokens/{token_id}/pair", + headers={"Authorization": f"Bearer {access_token}"}, + ) + code = pair_resp.json()["code"] + + exchange_resp = client.post( + "/api/client-tokens/exchange", + json={"code": code}, + ) + assert exchange_resp.status_code == status.HTTP_200_OK + new_raw = exchange_resp.json()["raw_token"] + assert new_raw.startswith("rmm_") + assert new_raw != old_raw + + # Old credential should be dead + old_hash = hash_client_token(old_raw) + assert db_client_token_handler.get_token_by_hash(old_hash) is None + + # New credential should work + new_hash = hash_client_token(new_raw) + assert db_client_token_handler.get_token_by_hash(new_hash) is not None + + def test_exchange_expired_code(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Expired Code", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + + pair_resp = client.post( + f"/api/client-tokens/{token_id}/pair", + headers={"Authorization": f"Bearer {access_token}"}, + ) + code = pair_resp.json()["code"] + + # Delete the Redis key to simulate expiry + sync_cache.delete(f"pair:{code}") + + exchange_resp = client.post( + "/api/client-tokens/exchange", + json={"code": code}, + ) + assert exchange_resp.status_code == status.HTTP_404_NOT_FOUND + + def test_exchange_replay_rejected(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Replay Test", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + + pair_resp = client.post( + f"/api/client-tokens/{token_id}/pair", + headers={"Authorization": f"Bearer {access_token}"}, + ) + code = pair_resp.json()["code"] + + # First exchange succeeds + resp1 = client.post( + "/api/client-tokens/exchange", + json={"code": code}, + ) + assert resp1.status_code == status.HTTP_200_OK + + # Second exchange with same code fails + resp2 = client.post( + "/api/client-tokens/exchange", + json={"code": code}, + ) + assert resp2.status_code == status.HTTP_404_NOT_FOUND + + def test_exchange_invalid_code(self, client): + response = client.post( + "/api/client-tokens/exchange", + json={"code": "ZZZZZZZZ"}, + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_exchange_case_insensitive(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Case Test", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + + pair_resp = client.post( + f"/api/client-tokens/{token_id}/pair", + headers={"Authorization": f"Bearer {access_token}"}, + ) + code = pair_resp.json()["code"] + + exchange_resp = client.post( + "/api/client-tokens/exchange", + json={"code": code.lower()}, + ) + assert exchange_resp.status_code == status.HTTP_200_OK + + def test_pair_status_pending(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Status Test", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + + pair_resp = client.post( + f"/api/client-tokens/{token_id}/pair", + headers={"Authorization": f"Bearer {access_token}"}, + ) + code = pair_resp.json()["code"] + + status_resp = client.get(f"/api/client-tokens/pair/{code}/status") + assert status_resp.status_code == status.HTTP_200_OK + + def test_pair_status_after_exchange(self, client, access_token, admin_user): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Status After", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + + pair_resp = client.post( + f"/api/client-tokens/{token_id}/pair", + headers={"Authorization": f"Bearer {access_token}"}, + ) + code = pair_resp.json()["code"] + + client.post( + "/api/client-tokens/exchange", + json={"code": code}, + ) + + status_resp = client.get(f"/api/client-tokens/pair/{code}/status") + assert status_resp.status_code == status.HTTP_404_NOT_FOUND + + def test_exchange_rate_limit(self, client): + for _ in range(5): + client.post( + "/api/client-tokens/exchange", + json={"code": "BADCODE1"}, + ) + + response = client.post( + "/api/client-tokens/exchange", + json={"code": "BADCODE2"}, + ) + assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS + + +class TestClientTokenAdmin: + def test_admin_list_all( + self, + client, + access_token, + editor_access_token, + admin_user, + editor_user, + ): + client.post( + "/api/client-tokens", + json={"name": "Admin's", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + client.post( + "/api/client-tokens", + json={"name": "Editor's", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + + response = client.get( + "/api/client-tokens/all", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + tokens = response.json() + assert len(tokens) == 2 + for t in tokens: + assert "username" in t + + def test_admin_revoke_other_user_token( + self, + client, + access_token, + editor_access_token, + admin_user, + editor_user, + ): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Editor Token", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + token_id = create_resp.json()["id"] + raw_token = create_resp.json()["raw_token"] + + response = client.delete( + f"/api/client-tokens/{token_id}/admin", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + + # Verify token no longer authenticates + hashed = hash_client_token(raw_token) + assert db_client_token_handler.get_token_by_hash(hashed) is None + + def test_non_admin_cannot_list_all(self, client, editor_access_token, editor_user): + response = client.get( + "/api/client-tokens/all", + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_non_admin_cannot_admin_revoke( + self, + client, + access_token, + editor_access_token, + admin_user, + editor_user, + ): + create_resp = client.post( + "/api/client-tokens", + json={"name": "Admin's Token", "scopes": ["roms.read"]}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + token_id = create_resp.json()["id"] + + response = client.delete( + f"/api/client-tokens/{token_id}/admin", + headers={"Authorization": f"Bearer {editor_access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 585efb465..94cf78bf7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -108,6 +108,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3824,6 +3825,7 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -4355,6 +4357,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4712,6 +4715,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -5516,6 +5520,7 @@ "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -6151,6 +6156,7 @@ "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -7355,6 +7361,7 @@ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -8158,6 +8165,7 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -9001,6 +9009,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9217,6 +9226,7 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -9364,6 +9374,7 @@ "integrity": "sha512-Q4SC/4TqbNvaZIFb9YsfBqkGlYHbJJJ6uU3CnRBZqLUF3s5eCMVZAaV4GkTbehIH/bhSj42lMXztOwc71u6rVw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@vuetify/loader-shared": "^2.1.2", "debug": "^4.3.3", @@ -9390,6 +9401,7 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", @@ -9412,6 +9424,7 @@ "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "debug": "^4.4.0", "eslint-scope": "^8.2.0 || ^9.0.0", @@ -9509,6 +9522,7 @@ "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.12.2.tgz", "integrity": "sha512-cVQa4+5iQpDs00ToMUnWRHlMdv1d5tEH2wcZIthqSCmBipQAG4rQKE55zFwZFYlPyiDhUVY1RcAFtXCuHNcCww==", "license": "MIT", + "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/johnleider" @@ -9841,6 +9855,7 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -9924,6 +9939,7 @@ "integrity": "sha512-cIFJOD1DESzpjOBl763Kp1AH7UE/0fcdHe6rZXUdQ9c50uvgigvW97u3IcSeBwOkgqL/PXPBktBCh0KEu5L8XQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "rollup": "dist/bin/rollup" }, diff --git a/frontend/src/components/Settings/Administration/Tokens/TokensTable.vue b/frontend/src/components/Settings/Administration/Tokens/TokensTable.vue new file mode 100644 index 000000000..62440c436 --- /dev/null +++ b/frontend/src/components/Settings/Administration/Tokens/TokensTable.vue @@ -0,0 +1,154 @@ + + + diff --git a/frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue b/frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue new file mode 100644 index 000000000..b52719a13 --- /dev/null +++ b/frontend/src/components/Settings/ClientApiTokens/ClientTokensTable.vue @@ -0,0 +1,167 @@ + + + diff --git a/frontend/src/components/Settings/ClientApiTokens/Dialog/CreateClientToken.vue b/frontend/src/components/Settings/ClientApiTokens/Dialog/CreateClientToken.vue new file mode 100644 index 000000000..115bb9004 --- /dev/null +++ b/frontend/src/components/Settings/ClientApiTokens/Dialog/CreateClientToken.vue @@ -0,0 +1,561 @@ + + + diff --git a/frontend/src/components/Settings/ClientApiTokens/Dialog/DeleteClientToken.vue b/frontend/src/components/Settings/ClientApiTokens/Dialog/DeleteClientToken.vue new file mode 100644 index 000000000..697087c8a --- /dev/null +++ b/frontend/src/components/Settings/ClientApiTokens/Dialog/DeleteClientToken.vue @@ -0,0 +1,85 @@ + + diff --git a/frontend/src/components/common/Navigation/SettingsDrawer.vue b/frontend/src/components/common/Navigation/SettingsDrawer.vue index a90288e07..2fb4020d4 100644 --- a/frontend/src/components/common/Navigation/SettingsDrawer.vue +++ b/frontend/src/components/common/Navigation/SettingsDrawer.vue @@ -153,6 +153,18 @@ function onClose() { > {{ t("scan.metadata-sources") }} + + {{ t("settings.client-api-tokens") }} + import("@/views/Settings/MetadataSources.vue"), }, + { + path: "client-api-tokens", + name: ROUTES.CLIENT_API_TOKENS, + meta: { + title: i18n.global.t("settings.client-api-tokens"), + }, + component: () => import("@/views/Settings/ClientApiTokens.vue"), + }, { path: "administration", name: ROUTES.ADMINISTRATION, @@ -249,6 +259,11 @@ const routes = [ }, ], }, + { + path: "/pair", + name: ROUTES.PAIR, + component: () => import("@/views/Pair.vue"), + }, // Console mode (separate UI namespace under /console) { path: "/console", @@ -313,6 +328,7 @@ const router = createRouter({ }); const routePermissions: RoutePermissions[] = [ + { path: ROUTES.CLIENT_API_TOKENS, requiredScopes: ["me.write"] }, { path: ROUTES.SCAN, requiredScopes: ["platforms.write"] }, { path: ROUTES.LIBRARY_MANAGEMENT, requiredScopes: ["platforms.write"] }, { path: ROUTES.ADMINISTRATION, requiredScopes: ["users.write"] }, @@ -324,7 +340,8 @@ function checkRoutePermissions(route: string, user: User | null): boolean { route === ROUTES.LOGIN || route === ROUTES.SETUP || route === ROUTES.RESET_PASSWORD || - route === ROUTES.REGISTER + route === ROUTES.REGISTER || + route === ROUTES.PAIR ) return true; @@ -358,7 +375,8 @@ router.beforeEach(async (to, _from, next) => { !user.value && currentRoute !== ROUTES.LOGIN && currentRoute !== ROUTES.RESET_PASSWORD && - currentRoute !== ROUTES.REGISTER + currentRoute !== ROUTES.REGISTER && + currentRoute !== ROUTES.PAIR ) { return next({ name: ROUTES.LOGIN, diff --git a/frontend/src/services/api/client-token.ts b/frontend/src/services/api/client-token.ts new file mode 100644 index 000000000..b7a9446c1 --- /dev/null +++ b/frontend/src/services/api/client-token.ts @@ -0,0 +1,88 @@ +import api from "@/services/api"; + +export interface ClientTokenSchema { + id: number; + name: string; + scopes: string[]; + expires_at: string | null; + last_used_at: string | null; + created_at: string; + user_id: number; +} + +export interface ClientTokenCreateSchema extends ClientTokenSchema { + raw_token: string; +} + +export interface ClientTokenAdminSchema extends ClientTokenSchema { + username: string; +} + +export interface ClientTokenPairSchema { + code: string; + expires_in: number; +} + +async function createToken({ + name, + scopes, + expires_in, +}: { + name: string; + scopes: string[]; + expires_in?: string; +}) { + return api.post("/client-tokens", { + name, + scopes, + expires_in, + }); +} + +async function fetchTokens() { + return api.get("/client-tokens"); +} + +async function deleteToken(tokenId: number) { + return api.delete(`/client-tokens/${tokenId}`); +} + +async function regenerateToken(tokenId: number) { + return api.put( + `/client-tokens/${tokenId}/regenerate`, + ); +} + +async function pairToken(tokenId: number) { + return api.post(`/client-tokens/${tokenId}/pair`); +} + +async function pollPairStatus(code: string) { + return api.get(`/client-tokens/pair/${code}/status`); +} + +async function exchangeCode(code: string) { + return api.post("/client-tokens/exchange", { + code, + }); +} + +async function fetchAllTokens() { + return api.get("/client-tokens/all"); +} + +async function adminDeleteToken(tokenId: number) { + return api.delete(`/client-tokens/${tokenId}/admin`); +} + +export default { + createToken, + fetchTokens, + deleteToken, + regenerateToken, + pairToken, + pollPairStatus, + exchangeCode, + fetchAllTokens, + adminDeleteToken, +}; diff --git a/frontend/src/types/emitter.d.ts b/frontend/src/types/emitter.d.ts index f9878cd53..30d99b862 100644 --- a/frontend/src/types/emitter.d.ts +++ b/frontend/src/types/emitter.d.ts @@ -1,4 +1,5 @@ import type { SaveSchema, StateSchema } from "@/__generated__"; +import type { ClientTokenSchema } from "@/services/api/client-token"; import type { Collection, SmartCollection } from "@/stores/collections"; import type { Platform } from "@/stores/platforms"; import type { SimpleRom } from "@/stores/roms"; @@ -73,6 +74,9 @@ export type Events = { saveSelected: SaveSchema; openEmulatorJSCacheDialog: null; stateSelected: StateSchema; + showCreateClientTokenDialog: null; + showRegenerateClientTokenDialog: ClientTokenSchema; + showDeleteClientTokenDialog: ClientTokenSchema; showAboutDialog: null; showNoteDialog: SimpleRom; playGame: number; diff --git a/frontend/src/views/Pair.vue b/frontend/src/views/Pair.vue new file mode 100644 index 000000000..5ba73f382 --- /dev/null +++ b/frontend/src/views/Pair.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/src/views/Settings/Administration.vue b/frontend/src/views/Settings/Administration.vue index 0d0fe9485..de427761a 100644 --- a/frontend/src/views/Settings/Administration.vue +++ b/frontend/src/views/Settings/Administration.vue @@ -1,5 +1,6 @@ diff --git a/frontend/src/views/Settings/ClientApiTokens.vue b/frontend/src/views/Settings/ClientApiTokens.vue new file mode 100644 index 000000000..dcd38a68d --- /dev/null +++ b/frontend/src/views/Settings/ClientApiTokens.vue @@ -0,0 +1,6 @@ + +