mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
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)
This commit is contained in:
60
backend/alembic/versions/0072_client_tokens.py
Normal file
60
backend/alembic/versions/0072_client_tokens.py
Normal file
@@ -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")
|
||||
290
backend/endpoints/client_tokens.py
Normal file
290
backend/endpoints/client_tokens.py
Normal file
@@ -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",
|
||||
)
|
||||
29
backend/endpoints/responses/client_token.py
Normal file
29
backend/endpoints/responses/client_token.py
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
143
backend/handler/database/client_tokens_handler.py
Normal file
143
backend/handler/database/client_tokens_handler.py
Normal file
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
27
backend/models/client_token.py
Normal file
27
backend/models/client_token.py
Normal file
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
718
backend/tests/endpoints/test_client_tokens.py
Normal file
718
backend/tests/endpoints/test_client_tokens.py
Normal file
@@ -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
|
||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { inject, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import RSection from "@/components/common/RSection.vue";
|
||||
import clientTokenApi, {
|
||||
type ClientTokenAdminSchema,
|
||||
} from "@/services/api/client-token";
|
||||
import type { Events } from "@/types/emitter";
|
||||
import { formatTimestamp } from "@/utils";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const tokenSearch = ref("");
|
||||
const tokens = ref<ClientTokenAdminSchema[]>([]);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
|
||||
const HEADERS = [
|
||||
{
|
||||
title: "User",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "username",
|
||||
},
|
||||
{
|
||||
title: "Token Name",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
title: "Scopes",
|
||||
align: "start",
|
||||
sortable: false,
|
||||
key: "scopes",
|
||||
},
|
||||
{
|
||||
title: "Expires",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "expires_at",
|
||||
},
|
||||
{
|
||||
title: "Last used",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "last_used_at",
|
||||
},
|
||||
{ title: "", align: "end", key: "actions", sortable: false },
|
||||
] as const;
|
||||
|
||||
function fetchTokens() {
|
||||
clientTokenApi
|
||||
.fetchAllTokens()
|
||||
.then(({ data }) => {
|
||||
tokens.value = data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
async function adminDelete(tokenId: number) {
|
||||
await clientTokenApi
|
||||
.adminDeleteToken(tokenId)
|
||||
.then(() => {
|
||||
tokens.value = tokens.value.filter((t) => t.id !== tokenId);
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: t("settings.client-token-deleted"),
|
||||
icon: "mdi-check",
|
||||
color: "romm-green",
|
||||
timeout: 4000,
|
||||
});
|
||||
})
|
||||
.catch(({ response, message }) => {
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: `Unable to revoke token: ${
|
||||
response?.data?.detail || response?.statusText || message
|
||||
}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchTokens);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RSection
|
||||
icon="mdi-key-variant"
|
||||
:title="t('settings.client-api-tokens')"
|
||||
class="ma-2"
|
||||
>
|
||||
<template #content>
|
||||
<v-text-field
|
||||
v-model="tokenSearch"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="t('common.search')"
|
||||
single-line
|
||||
hide-details
|
||||
clearable
|
||||
rounded="0"
|
||||
density="comfortable"
|
||||
class="bg-surface"
|
||||
/>
|
||||
<v-data-table-virtual
|
||||
:style="{ 'max-height': '40dvh' }"
|
||||
:search="tokenSearch"
|
||||
:headers="HEADERS"
|
||||
:items="tokens"
|
||||
:sort-by="[{ key: 'username', order: 'asc' }]"
|
||||
fixed-header
|
||||
fixed-footer
|
||||
density="comfortable"
|
||||
class="rounded bg-background"
|
||||
hide-default-footer
|
||||
>
|
||||
<template #item.scopes="{ item }">
|
||||
<v-chip
|
||||
v-for="scope in item.scopes"
|
||||
:key="scope"
|
||||
size="x-small"
|
||||
class="mr-1"
|
||||
label
|
||||
>
|
||||
{{ scope }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #item.expires_at="{ item }">
|
||||
{{
|
||||
item.expires_at
|
||||
? formatTimestamp(item.expires_at, locale)
|
||||
: t("settings.client-token-expiry-never")
|
||||
}}
|
||||
</template>
|
||||
<template #item.last_used_at="{ item }">
|
||||
{{
|
||||
item.last_used_at ? formatTimestamp(item.last_used_at, locale) : "-"
|
||||
}}
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn
|
||||
size="small"
|
||||
variant="text"
|
||||
class="text-romm-red"
|
||||
@click="adminDelete(item.id)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
</template>
|
||||
</RSection>
|
||||
</template>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { inject, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import CreateClientTokenDialog from "@/components/Settings/ClientApiTokens/Dialog/CreateClientToken.vue";
|
||||
import DeleteClientTokenDialog from "@/components/Settings/ClientApiTokens/Dialog/DeleteClientToken.vue";
|
||||
import RSection from "@/components/common/RSection.vue";
|
||||
import clientTokenApi, {
|
||||
type ClientTokenSchema,
|
||||
} from "@/services/api/client-token";
|
||||
import type { Events } from "@/types/emitter";
|
||||
import { formatTimestamp } from "@/utils";
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const tokenSearch = ref("");
|
||||
const tokens = ref<ClientTokenSchema[]>([]);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
|
||||
const HEADERS = [
|
||||
{
|
||||
title: "Name",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "name",
|
||||
},
|
||||
{
|
||||
title: "Scopes",
|
||||
align: "start",
|
||||
sortable: false,
|
||||
key: "scopes",
|
||||
},
|
||||
{
|
||||
title: "Expires",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "expires_at",
|
||||
},
|
||||
{
|
||||
title: "Last used",
|
||||
align: "start",
|
||||
sortable: true,
|
||||
key: "last_used_at",
|
||||
},
|
||||
{ title: "", align: "end", key: "actions", sortable: false },
|
||||
] as const;
|
||||
|
||||
function fetchTokens() {
|
||||
clientTokenApi
|
||||
.fetchTokens()
|
||||
.then(({ data }) => {
|
||||
tokens.value = data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(fetchTokens);
|
||||
|
||||
emitter?.on("showCreateClientTokenDialog", () => {
|
||||
// Refetch after dialog closes via snackbar events
|
||||
});
|
||||
|
||||
function onTokenCreated() {
|
||||
fetchTokens();
|
||||
}
|
||||
|
||||
function onTokenDeleted(tokenId: number) {
|
||||
tokens.value = tokens.value.filter((t) => t.id !== tokenId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RSection
|
||||
icon="mdi-key-variant"
|
||||
:title="t('settings.client-api-tokens')"
|
||||
class="ma-2"
|
||||
>
|
||||
<template #content>
|
||||
<v-text-field
|
||||
v-model="tokenSearch"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
:label="t('common.search')"
|
||||
single-line
|
||||
hide-details
|
||||
clearable
|
||||
rounded="0"
|
||||
density="comfortable"
|
||||
class="bg-surface"
|
||||
/>
|
||||
<v-data-table-virtual
|
||||
:style="{ 'max-height': '75dvh' }"
|
||||
:search="tokenSearch"
|
||||
:headers="HEADERS"
|
||||
:items="tokens"
|
||||
:sort-by="[{ key: 'name', order: 'asc' }]"
|
||||
fixed-header
|
||||
fixed-footer
|
||||
density="comfortable"
|
||||
class="rounded bg-background"
|
||||
hide-default-footer
|
||||
>
|
||||
<template #header.actions>
|
||||
<v-btn
|
||||
prepend-icon="mdi-plus"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
class="text-primary"
|
||||
@click="emitter?.emit('showCreateClientTokenDialog', null)"
|
||||
>
|
||||
{{ t("common.create") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<template #item.name="{ item }">
|
||||
<v-list-item class="pa-0" min-width="120px">
|
||||
{{ item.name }}
|
||||
</v-list-item>
|
||||
</template>
|
||||
<template #item.scopes="{ item }">
|
||||
<v-chip
|
||||
v-for="scope in item.scopes"
|
||||
:key="scope"
|
||||
size="x-small"
|
||||
class="mr-1"
|
||||
label
|
||||
>
|
||||
{{ scope }}
|
||||
</v-chip>
|
||||
</template>
|
||||
<template #item.expires_at="{ item }">
|
||||
{{
|
||||
item.expires_at
|
||||
? formatTimestamp(item.expires_at, locale)
|
||||
: t("settings.client-token-expiry-never")
|
||||
}}
|
||||
</template>
|
||||
<template #item.last_used_at="{ item }">
|
||||
{{
|
||||
item.last_used_at ? formatTimestamp(item.last_used_at, locale) : "-"
|
||||
}}
|
||||
</template>
|
||||
<template #item.actions="{ item }">
|
||||
<v-btn-group divided density="compact" variant="text">
|
||||
<v-btn
|
||||
size="small"
|
||||
title="Regenerate"
|
||||
@click="emitter?.emit('showRegenerateClientTokenDialog', item)"
|
||||
>
|
||||
<v-icon>mdi-refresh</v-icon>
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="text-romm-red"
|
||||
size="small"
|
||||
title="Delete"
|
||||
@click="emitter?.emit('showDeleteClientTokenDialog', item)"
|
||||
>
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
</template>
|
||||
</v-data-table-virtual>
|
||||
</template>
|
||||
</RSection>
|
||||
|
||||
<CreateClientTokenDialog @created="onTokenCreated" />
|
||||
<DeleteClientTokenDialog @deleted="onTokenDeleted" />
|
||||
</template>
|
||||
@@ -0,0 +1,561 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import qrcode from "qrcode";
|
||||
import { computed, inject, nextTick, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useDisplay } from "vuetify";
|
||||
import RDialog from "@/components/common/RDialog.vue";
|
||||
import clientTokenApi, {
|
||||
type ClientTokenSchema,
|
||||
} from "@/services/api/client-token";
|
||||
import storeAuth from "@/stores/auth";
|
||||
import type { Events } from "@/types/emitter";
|
||||
|
||||
const emit = defineEmits<{ created: [] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { lgAndUp } = useDisplay();
|
||||
const auth = storeAuth();
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
|
||||
const show = ref(false);
|
||||
const step = ref<"config" | "delivery" | "copy" | "pair">("config");
|
||||
const loading = ref(false);
|
||||
|
||||
const tokenName = ref("");
|
||||
const selectedScopes = ref<string[]>([]);
|
||||
const selectedExpiry = ref("never");
|
||||
const rawToken = ref("");
|
||||
const tokenId = ref<number | null>(null);
|
||||
|
||||
const pairCode = ref("");
|
||||
const pairExpiresIn = ref(0);
|
||||
const pairTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const pairCountdown = ref(0);
|
||||
const pairStatus = ref<"pending" | "claimed" | "expired">("pending");
|
||||
const pairLoading = ref(false);
|
||||
|
||||
const isRegenerate = ref(false);
|
||||
const regenerateToken = ref<ClientTokenSchema | null>(null);
|
||||
|
||||
const expiryOptions = [
|
||||
{ title: t("settings.client-token-expiry-30d"), value: "30d" },
|
||||
{ title: t("settings.client-token-expiry-90d"), value: "90d" },
|
||||
{ title: t("settings.client-token-expiry-1y"), value: "1y" },
|
||||
{ title: t("settings.client-token-expiry-never"), value: "never" },
|
||||
];
|
||||
|
||||
const scopeColumns = computed(() => {
|
||||
const sortScopes = (scopes: string[]) => {
|
||||
const prefixOrder = new Map<string, number>();
|
||||
scopes.forEach((s) => {
|
||||
const prefix = s.split(".").slice(0, -1).join(".");
|
||||
if (!prefixOrder.has(prefix)) prefixOrder.set(prefix, prefixOrder.size);
|
||||
});
|
||||
return [...scopes].sort((a, b) => {
|
||||
const pa = a.split(".").slice(0, -1).join(".");
|
||||
const pb = b.split(".").slice(0, -1).join(".");
|
||||
if (pa !== pb)
|
||||
return (prefixOrder.get(pa) ?? 0) - (prefixOrder.get(pb) ?? 0);
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
};
|
||||
|
||||
const personal = sortScopes(
|
||||
userScopes.value.filter((s) => /^(me|assets|devices|roms\.user)\./.test(s)),
|
||||
);
|
||||
const admin = sortScopes(
|
||||
userScopes.value.filter((s) => /^(users|tasks)\./.test(s)),
|
||||
);
|
||||
const library = sortScopes(
|
||||
userScopes.value.filter(
|
||||
(s) =>
|
||||
/^(platforms|firmware|collections)\./.test(s) ||
|
||||
(/^roms\./.test(s) && !/^roms\.user\./.test(s)),
|
||||
),
|
||||
);
|
||||
const grouped = new Set([...personal, ...admin, ...library]);
|
||||
const other = userScopes.value.filter((s) => !grouped.has(s));
|
||||
|
||||
const left: { label: string; scopes: string[] }[] = [];
|
||||
if (personal.length > 0) left.push({ label: "Personal", scopes: personal });
|
||||
if (admin.length > 0) left.push({ label: "Administration", scopes: admin });
|
||||
if (other.length > 0) left.push({ label: "Other", scopes: other });
|
||||
|
||||
const right: { label: string; scopes: string[] }[] = [];
|
||||
if (library.length > 0) right.push({ label: "Library", scopes: library });
|
||||
|
||||
return { left, right };
|
||||
});
|
||||
|
||||
const userScopes = computed(() => auth.scopes);
|
||||
const configValid = computed(
|
||||
() => tokenName.value.trim().length > 0 && selectedScopes.value.length > 0,
|
||||
);
|
||||
|
||||
const formattedPairCode = computed(() => {
|
||||
if (!pairCode.value) return "";
|
||||
const c = pairCode.value;
|
||||
return c.slice(0, 4) + "-" + c.slice(4);
|
||||
});
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (step.value === "config") return "Create New API Token";
|
||||
if (step.value === "delivery") {
|
||||
return isRegenerate.value ? "Regenerate Token" : "Deliver Token";
|
||||
}
|
||||
if (step.value === "copy") return "Copy Token";
|
||||
return "Pair Device";
|
||||
});
|
||||
|
||||
emitter?.on("showCreateClientTokenDialog", () => {
|
||||
resetDialog();
|
||||
isRegenerate.value = false;
|
||||
regenerateToken.value = null;
|
||||
selectedScopes.value = [...userScopes.value];
|
||||
show.value = true;
|
||||
});
|
||||
|
||||
emitter?.on("showRegenerateClientTokenDialog", (token) => {
|
||||
resetDialog();
|
||||
isRegenerate.value = true;
|
||||
regenerateToken.value = token;
|
||||
tokenName.value = token.name;
|
||||
selectedScopes.value = [...token.scopes];
|
||||
show.value = true;
|
||||
step.value = "delivery";
|
||||
doRegenerate();
|
||||
});
|
||||
|
||||
function resetDialog() {
|
||||
step.value = "config";
|
||||
tokenName.value = "";
|
||||
selectedScopes.value = [];
|
||||
selectedExpiry.value = "never";
|
||||
rawToken.value = "";
|
||||
tokenId.value = null;
|
||||
pairCode.value = "";
|
||||
pairStatus.value = "pending";
|
||||
pairCountdown.value = 0;
|
||||
loading.value = false;
|
||||
pairLoading.value = false;
|
||||
clearPairTimer();
|
||||
}
|
||||
|
||||
function clearPairTimer() {
|
||||
if (pairTimer.value) {
|
||||
clearInterval(pairTimer.value);
|
||||
pairTimer.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createToken() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await clientTokenApi.createToken({
|
||||
name: tokenName.value.trim(),
|
||||
scopes: selectedScopes.value,
|
||||
expires_in:
|
||||
selectedExpiry.value === "never" ? undefined : selectedExpiry.value,
|
||||
});
|
||||
rawToken.value = data.raw_token;
|
||||
tokenId.value = data.id;
|
||||
step.value = "delivery";
|
||||
emit("created");
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: t("settings.client-token-created"),
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
} catch (error: any) {
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: error.response?.data?.detail || "Failed to create token",
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doRegenerate() {
|
||||
if (!regenerateToken.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await clientTokenApi.regenerateToken(
|
||||
regenerateToken.value.id,
|
||||
);
|
||||
rawToken.value = data.raw_token;
|
||||
tokenId.value = data.id;
|
||||
emit("created");
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: t("settings.client-token-regenerated"),
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
} catch (error: any) {
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: error.response?.data?.detail || "Failed to regenerate token",
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
show.value = false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rawToken.value);
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: t("settings.client-token-copied"),
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
} catch {
|
||||
// Fallback: select the text
|
||||
}
|
||||
}
|
||||
|
||||
async function startPairing() {
|
||||
if (!tokenId.value) return;
|
||||
step.value = "pair";
|
||||
pairLoading.value = true;
|
||||
pairStatus.value = "pending";
|
||||
|
||||
try {
|
||||
const { data } = await clientTokenApi.pairToken(tokenId.value);
|
||||
pairCode.value = data.code;
|
||||
pairExpiresIn.value = data.expires_in;
|
||||
pairCountdown.value = data.expires_in;
|
||||
pairLoading.value = false;
|
||||
|
||||
await nextTick();
|
||||
renderQR(data.code);
|
||||
startPairPolling();
|
||||
} catch (error: any) {
|
||||
pairLoading.value = false;
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: error.response?.data?.detail || "Failed to generate pairing code",
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
step.value = "delivery";
|
||||
}
|
||||
}
|
||||
|
||||
function startPairPolling() {
|
||||
clearPairTimer();
|
||||
pairTimer.value = setInterval(async () => {
|
||||
pairCountdown.value -= 1;
|
||||
|
||||
if (pairCountdown.value <= 0) {
|
||||
clearPairTimer();
|
||||
pairStatus.value = "expired";
|
||||
return;
|
||||
}
|
||||
|
||||
if (pairCountdown.value % 3 === 0) {
|
||||
try {
|
||||
await clientTokenApi.pollPairStatus(pairCode.value);
|
||||
} catch {
|
||||
clearPairTimer();
|
||||
if (pairCountdown.value > 0) {
|
||||
pairStatus.value = "claimed";
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: t("settings.client-token-pair-claimed"),
|
||||
icon: "mdi-check-bold",
|
||||
color: "green",
|
||||
});
|
||||
} else {
|
||||
pairStatus.value = "expired";
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function regeneratePairCode() {
|
||||
pairStatus.value = "pending";
|
||||
pairLoading.value = true;
|
||||
try {
|
||||
const { data } = await clientTokenApi.pairToken(tokenId.value!);
|
||||
pairCode.value = data.code;
|
||||
pairExpiresIn.value = data.expires_in;
|
||||
pairCountdown.value = data.expires_in;
|
||||
pairLoading.value = false;
|
||||
|
||||
await nextTick();
|
||||
renderQR(data.code);
|
||||
startPairPolling();
|
||||
} catch (error: any) {
|
||||
pairLoading.value = false;
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: error.response?.data?.detail || "Failed to regenerate code",
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderQR(code: string) {
|
||||
const displayCode = code.slice(0, 4) + "-" + code.slice(4);
|
||||
const pairUrl = `${window.location.origin}/pair?code=${displayCode}`;
|
||||
const canvas = document.getElementById("pair-qr-code") as HTMLCanvasElement;
|
||||
if (!canvas) return;
|
||||
|
||||
const size = lgAndUp.value ? 250 : 200;
|
||||
qrcode.toCanvas(
|
||||
canvas,
|
||||
pairUrl,
|
||||
{
|
||||
margin: 2,
|
||||
width: size,
|
||||
errorCorrectionLevel: "H",
|
||||
},
|
||||
() => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const logo = new Image();
|
||||
logo.src = "/assets/logos/romm_logo_xbox_one_circle.svg";
|
||||
logo.onload = () => {
|
||||
const logoSize = canvas.width * 0.24;
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const radius = logoSize / 2 + 4;
|
||||
|
||||
// Circular white background
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fill();
|
||||
|
||||
ctx.drawImage(
|
||||
logo,
|
||||
cx - logoSize / 2,
|
||||
cy - logoSize / 2,
|
||||
logoSize,
|
||||
logoSize,
|
||||
);
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
clearPairTimer();
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
watch(show, (val) => {
|
||||
if (!val) clearPairTimer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RDialog
|
||||
v-model="show"
|
||||
:icon="isRegenerate ? 'mdi-refresh' : 'mdi-key-plus'"
|
||||
:width="lgAndUp ? '50vw' : '95vw'"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<template #header>
|
||||
<v-toolbar-title class="ml-2">{{ dialogTitle }}</v-toolbar-title>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<!-- Step 1: Configuration -->
|
||||
<div v-if="step === 'config'" class="pa-4">
|
||||
<v-text-field
|
||||
v-model="tokenName"
|
||||
:label="`${t('settings.client-token-name')} *`"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<v-select
|
||||
v-model="selectedExpiry"
|
||||
:label="t('settings.client-token-select-expiry')"
|
||||
:items="expiryOptions"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-2"
|
||||
/>
|
||||
|
||||
<div class="text-subtitle-2 mb-2">
|
||||
{{ t("settings.client-token-scopes") }}
|
||||
</div>
|
||||
<v-row no-gutters>
|
||||
<v-col cols="12" md="6" class="pr-2">
|
||||
<template v-for="group in scopeColumns.left" :key="group.label">
|
||||
<div class="text-caption text-medium-emphasis mb-1 mt-2">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<v-checkbox
|
||||
v-for="scope in group.scopes"
|
||||
:key="scope"
|
||||
v-model="selectedScopes"
|
||||
:label="scope"
|
||||
:value="scope"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</template>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="pr-2">
|
||||
<template v-for="group in scopeColumns.right" :key="group.label">
|
||||
<div class="text-caption text-medium-emphasis mb-1 mt-2">
|
||||
{{ group.label }}
|
||||
</div>
|
||||
<v-checkbox
|
||||
v-for="scope in group.scopes"
|
||||
:key="scope"
|
||||
v-model="selectedScopes"
|
||||
:label="scope"
|
||||
:value="scope"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
</template>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Delivery method choice -->
|
||||
<div v-if="step === 'delivery'" class="pa-4 text-center">
|
||||
<p class="text-body-1 mb-2">
|
||||
{{
|
||||
isRegenerate
|
||||
? "Your previous token has been revoked and a new one generated. Choose how to deliver it:"
|
||||
: "Your token has been created. Choose how to deliver it:"
|
||||
}}
|
||||
</p>
|
||||
<p class="text-body-2 mb-4 text-warning">
|
||||
{{ t("settings.client-token-delivery-hint") }}
|
||||
</p>
|
||||
<v-row class="justify-center" no-gutters>
|
||||
<v-col cols="auto" class="mx-2">
|
||||
<v-btn
|
||||
size="large"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-content-copy"
|
||||
@click="step = 'copy'"
|
||||
>
|
||||
Copy Token
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="auto" class="mx-2">
|
||||
<v-btn
|
||||
size="large"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-qrcode"
|
||||
@click="startPairing"
|
||||
>
|
||||
Pair Device
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Step 2a: Copy token -->
|
||||
<div v-if="step === 'copy'" class="pa-4">
|
||||
<p class="text-body-2 mb-4 text-warning">
|
||||
{{ t("settings.client-token-delivery-hint") }}
|
||||
</p>
|
||||
<v-text-field
|
||||
:model-value="rawToken"
|
||||
readonly
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
style="font-family: monospace"
|
||||
>
|
||||
<template #append-inner>
|
||||
<v-icon
|
||||
class="text-primary"
|
||||
style="cursor: pointer"
|
||||
@click="copyToken"
|
||||
>
|
||||
mdi-content-copy
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Step 2b: Pair device -->
|
||||
<div v-if="step === 'pair'" class="pa-4 text-center">
|
||||
<div v-if="pairLoading" class="py-8">
|
||||
<v-progress-circular indeterminate />
|
||||
</div>
|
||||
<div v-else-if="pairStatus === 'pending'">
|
||||
<canvas id="pair-qr-code" class="mx-auto d-block" />
|
||||
<div
|
||||
class="text-h5 font-weight-bold mt-2"
|
||||
style="font-family: monospace"
|
||||
>
|
||||
{{ formattedPairCode }}
|
||||
</div>
|
||||
<div class="text-body-2 mt-2">{{ pairCountdown }}s</div>
|
||||
</div>
|
||||
<div v-else-if="pairStatus === 'claimed'" class="py-8">
|
||||
<v-icon size="64" color="green">mdi-check-circle</v-icon>
|
||||
<p class="text-h6 mt-4">
|
||||
{{ t("settings.client-token-pair-claimed") }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="pairStatus === 'expired'" class="py-8">
|
||||
<v-icon size="64" color="warning">mdi-timer-off</v-icon>
|
||||
<p class="text-body-1 mt-4">
|
||||
{{ t("settings.client-token-pair-expired") }}
|
||||
</p>
|
||||
<v-btn
|
||||
class="mt-4"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-refresh"
|
||||
@click="regeneratePairCode"
|
||||
>
|
||||
Regenerate Code
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<v-row class="justify-center my-2" no-gutters>
|
||||
<v-btn-group divided density="compact">
|
||||
<template v-if="step === 'config'">
|
||||
<v-btn class="bg-toplayer" @click="closeDialog">
|
||||
{{ t("common.cancel") }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
class="bg-toplayer text-primary"
|
||||
:disabled="!configValid"
|
||||
:loading="loading"
|
||||
@click="createToken"
|
||||
>
|
||||
{{ t("common.create") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<template v-else-if="step === 'delivery'">
|
||||
<v-btn class="bg-toplayer" @click="closeDialog"> Close </v-btn>
|
||||
</template>
|
||||
|
||||
<template v-else-if="step === 'copy'">
|
||||
<v-btn class="bg-toplayer" @click="step = 'delivery'"> Back </v-btn>
|
||||
<v-btn class="bg-toplayer" @click="closeDialog"> Close </v-btn>
|
||||
</template>
|
||||
|
||||
<template v-else-if="step === 'pair'">
|
||||
<v-btn class="bg-toplayer" @click="step = 'delivery'"> Back </v-btn>
|
||||
<v-btn class="bg-toplayer" @click="closeDialog"> Close </v-btn>
|
||||
</template>
|
||||
</v-btn-group>
|
||||
</v-row>
|
||||
</template>
|
||||
</RDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emitter } from "mitt";
|
||||
import { inject, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useDisplay } from "vuetify";
|
||||
import RDialog from "@/components/common/RDialog.vue";
|
||||
import clientTokenApi, {
|
||||
type ClientTokenSchema,
|
||||
} from "@/services/api/client-token";
|
||||
import type { Events } from "@/types/emitter";
|
||||
|
||||
const emit = defineEmits<{ deleted: [id: number] }>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { lgAndUp } = useDisplay();
|
||||
const token = ref<ClientTokenSchema | null>(null);
|
||||
const show = ref(false);
|
||||
const emitter = inject<Emitter<Events>>("emitter");
|
||||
|
||||
emitter?.on("showDeleteClientTokenDialog", (tokenToDelete) => {
|
||||
token.value = tokenToDelete;
|
||||
show.value = true;
|
||||
});
|
||||
|
||||
async function deleteToken() {
|
||||
if (!token.value) return;
|
||||
|
||||
await clientTokenApi
|
||||
.deleteToken(token.value.id)
|
||||
.then(() => {
|
||||
emit("deleted", token.value!.id);
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: t("settings.client-token-deleted"),
|
||||
icon: "mdi-check",
|
||||
color: "romm-green",
|
||||
timeout: 4000,
|
||||
});
|
||||
})
|
||||
.catch(({ response, message }) => {
|
||||
emitter?.emit("snackbarShow", {
|
||||
msg: `Unable to delete token: ${
|
||||
response?.data?.detail || response?.statusText || message
|
||||
}`,
|
||||
icon: "mdi-close-circle",
|
||||
color: "red",
|
||||
});
|
||||
});
|
||||
|
||||
show.value = false;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
show.value = false;
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<RDialog
|
||||
v-if="token"
|
||||
v-model="show"
|
||||
icon="mdi-delete"
|
||||
:width="lgAndUp ? '45vw' : '95vw'"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<template #content>
|
||||
<v-row class="justify-center align-center pa-4" no-gutters>
|
||||
<span>{{ t("settings.client-token-confirm-delete") }}</span>
|
||||
</v-row>
|
||||
<v-row class="justify-center pa-2" no-gutters>
|
||||
<v-chip label class="text-primary">{{ token.name }}</v-chip>
|
||||
</v-row>
|
||||
</template>
|
||||
<template #footer>
|
||||
<v-row class="justify-center my-2" no-gutters>
|
||||
<v-btn-group divided density="compact">
|
||||
<v-btn class="bg-toplayer" @click="closeDialog">
|
||||
{{ t("common.cancel") }}
|
||||
</v-btn>
|
||||
<v-btn class="bg-toplayer text-romm-red" @click="deleteToken">
|
||||
{{ t("common.confirm") }}
|
||||
</v-btn>
|
||||
</v-btn-group>
|
||||
</v-row>
|
||||
</template>
|
||||
</RDialog>
|
||||
</template>
|
||||
@@ -153,6 +153,18 @@ function onClose() {
|
||||
>
|
||||
{{ t("scan.metadata-sources") }}
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="scopes.includes('me.write')"
|
||||
:tabindex="tabIndex"
|
||||
class="mt-1"
|
||||
rounded
|
||||
:to="{ name: ROUTES.CLIENT_API_TOKENS }"
|
||||
append-icon="mdi-key-variant"
|
||||
aria-label="Client API Tokens"
|
||||
role="listitem"
|
||||
>
|
||||
{{ t("settings.client-api-tokens") }}
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="scopes.includes('users.write')"
|
||||
:tabindex="tabIndex"
|
||||
|
||||
@@ -13,6 +13,23 @@
|
||||
"boxart-physical": "Physical",
|
||||
"boxart-style": "Boxart style",
|
||||
"canceled": "Canceled",
|
||||
"client-api-tokens": "Client API Tokens",
|
||||
"client-token-confirm-delete": "Are you sure you want to revoke this token? Any device using it will lose access.",
|
||||
"client-token-confirm-regenerate": "This will invalidate the current credential. Any device using the old token will need to be re-paired.",
|
||||
"client-token-copied": "Token copied to clipboard",
|
||||
"client-token-created": "Token created successfully",
|
||||
"client-token-deleted": "Token revoked successfully",
|
||||
"client-token-delivery-hint": "This token will not be shown again.",
|
||||
"client-token-expiry-30d": "30 days",
|
||||
"client-token-expiry-90d": "90 days",
|
||||
"client-token-expiry-1y": "1 year",
|
||||
"client-token-expiry-never": "Never",
|
||||
"client-token-name": "Token name",
|
||||
"client-token-pair-claimed": "Device paired successfully!",
|
||||
"client-token-pair-expired": "Pairing code expired",
|
||||
"client-token-regenerated": "Token regenerated successfully",
|
||||
"client-token-scopes": "Permissions",
|
||||
"client-token-select-expiry": "Expiration",
|
||||
"cleanup": "Cleanup",
|
||||
"cleanup-all": "Clean up all",
|
||||
"cleanup-all-confirm": "This will permanently delete all missing ROMs{platform} from the database and their resource directories. This action cannot be undone.",
|
||||
|
||||
@@ -33,8 +33,10 @@ export const ROUTES = {
|
||||
USER_INTERFACE: "user-interface",
|
||||
LIBRARY_MANAGEMENT: "library-management",
|
||||
METADATA_SOURCES: "metadata-sources",
|
||||
CLIENT_API_TOKENS: "client-api-tokens",
|
||||
ADMINISTRATION: "administration",
|
||||
SERVER_STATS: "server-stats",
|
||||
PAIR: "pair",
|
||||
NOT_FOUND: "404",
|
||||
CONSOLE_HOME: "console-home",
|
||||
CONSOLE_PLATFORM: "console-platform",
|
||||
@@ -226,6 +228,14 @@ const routes = [
|
||||
},
|
||||
component: () => 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,
|
||||
|
||||
88
frontend/src/services/api/client-token.ts
Normal file
88
frontend/src/services/api/client-token.ts
Normal file
@@ -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<ClientTokenCreateSchema>("/client-tokens", {
|
||||
name,
|
||||
scopes,
|
||||
expires_in,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchTokens() {
|
||||
return api.get<ClientTokenSchema[]>("/client-tokens");
|
||||
}
|
||||
|
||||
async function deleteToken(tokenId: number) {
|
||||
return api.delete(`/client-tokens/${tokenId}`);
|
||||
}
|
||||
|
||||
async function regenerateToken(tokenId: number) {
|
||||
return api.put<ClientTokenCreateSchema>(
|
||||
`/client-tokens/${tokenId}/regenerate`,
|
||||
);
|
||||
}
|
||||
|
||||
async function pairToken(tokenId: number) {
|
||||
return api.post<ClientTokenPairSchema>(`/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<ClientTokenCreateSchema>("/client-tokens/exchange", {
|
||||
code,
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAllTokens() {
|
||||
return api.get<ClientTokenAdminSchema[]>("/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,
|
||||
};
|
||||
4
frontend/src/types/emitter.d.ts
vendored
4
frontend/src/types/emitter.d.ts
vendored
@@ -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;
|
||||
|
||||
107
frontend/src/views/Pair.vue
Normal file
107
frontend/src/views/Pair.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import axios from "axios";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const code = computed(() => (route.query.code as string) || "");
|
||||
const callback = computed(() => (route.query.callback as string) || "");
|
||||
|
||||
const status = ref<"idle" | "exchanging" | "success" | "error">("idle");
|
||||
const rawToken = ref("");
|
||||
const errorMessage = ref("");
|
||||
|
||||
function isCustomScheme(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol !== "http:" && parsed.protocol !== "https:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function exchange(pairCode: string): Promise<string> {
|
||||
const resp = await axios.post<{ raw_token: string }>(
|
||||
"/api/client-tokens/exchange",
|
||||
{ code: pairCode },
|
||||
);
|
||||
return resp.data.raw_token;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!code.value) {
|
||||
status.value = "error";
|
||||
errorMessage.value = "No pairing code provided.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback.value) {
|
||||
if (!isCustomScheme(callback.value)) {
|
||||
status.value = "error";
|
||||
errorMessage.value =
|
||||
"Only custom URL schemes are supported for callbacks (no http/https).";
|
||||
return;
|
||||
}
|
||||
|
||||
status.value = "exchanging";
|
||||
try {
|
||||
const token = await exchange(code.value);
|
||||
const separator = callback.value.includes("?") ? "&" : "?";
|
||||
window.location.href = `${callback.value}${separator}token=${encodeURIComponent(token)}`;
|
||||
} catch (err: any) {
|
||||
status.value = "error";
|
||||
errorMessage.value =
|
||||
err.response?.data?.detail || "Failed to exchange pairing code.";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// No callback -- just display the code for manual entry
|
||||
status.value = "idle";
|
||||
});
|
||||
|
||||
const formattedCode = computed(() => {
|
||||
const c = code.value.replace("-", "").toUpperCase();
|
||||
if (c.length === 8) return c.slice(0, 4) + "-" + c.slice(4);
|
||||
return code.value;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app>
|
||||
<v-main
|
||||
class="d-flex align-center justify-center"
|
||||
style="min-height: 100dvh"
|
||||
>
|
||||
<v-card max-width="450" class="pa-8 text-center" variant="outlined">
|
||||
<template v-if="status === 'exchanging'">
|
||||
<v-progress-circular indeterminate size="48" class="mb-4" />
|
||||
<p class="text-body-1">Exchanging pairing code...</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="status === 'error'">
|
||||
<v-icon size="64" color="error" class="mb-4">mdi-alert-circle</v-icon>
|
||||
<p class="text-body-1">{{ errorMessage }}</p>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<v-icon size="48" class="mb-4">mdi-key-variant</v-icon>
|
||||
<p class="text-body-1 mb-4">
|
||||
Enter this code in your device to complete pairing:
|
||||
</p>
|
||||
<div
|
||||
class="text-h3 font-weight-bold mb-4"
|
||||
style="font-family: monospace; letter-spacing: 0.1em"
|
||||
>
|
||||
{{ formattedCode }}
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis">
|
||||
This code expires shortly. Return to the web interface to generate a
|
||||
new one if needed.
|
||||
</p>
|
||||
</template>
|
||||
</v-card>
|
||||
</v-main>
|
||||
</v-app>
|
||||
</template>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import Tasks from "@/components/Settings/Administration/Tasks.vue";
|
||||
import TokensTable from "@/components/Settings/Administration/Tokens/TokensTable.vue";
|
||||
import UsersTable from "@/components/Settings/Administration/Users/UsersTable.vue";
|
||||
import storeAuth from "@/stores/auth";
|
||||
|
||||
@@ -7,5 +8,6 @@ const auth = storeAuth();
|
||||
</script>
|
||||
<template>
|
||||
<UsersTable />
|
||||
<TokensTable v-if="auth.scopes.includes('users.read')" class="mt-6" />
|
||||
<Tasks v-if="auth.scopes.includes('tasks.run')" class="mt-6" />
|
||||
</template>
|
||||
|
||||
6
frontend/src/views/Settings/ClientApiTokens.vue
Normal file
6
frontend/src/views/Settings/ClientApiTokens.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import ClientTokensTable from "@/components/Settings/ClientApiTokens/ClientTokensTable.vue";
|
||||
</script>
|
||||
<template>
|
||||
<ClientTokensTable />
|
||||
</template>
|
||||
Reference in New Issue
Block a user