import logging import os import secrets from datetime import datetime, timedelta, timezone from typing import Annotated import jwt as _jwt from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status from fastapi.security import HTTPAuthorizationCredentials from jwt.exceptions import PyJWTError from sqlalchemy import delete, or_, select from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from backend.app.api.routes.settings import get_external_login_url from backend.app.core.auth import ( ALGORITHM, SECRET_KEY, Permission, RequirePermissionIfAuthEnabled, _is_token_fresh, _validate_api_key, apikey_effective_permissions, authenticate_user, authenticate_user_by_email, create_access_token, create_media_token, create_websocket_token, current_api_key_if_present, get_current_active_user, get_password_hash, get_user_by_email, get_user_by_username, is_jti_revoked, require_auth_if_enabled, resolve_apikey_owner, resolve_session_max_minutes, revoke_jti, security, token_outlived_its_session, ) from backend.app.core.database import async_session, get_db from backend.app.core.oidc_env import env_bool from backend.app.models.api_key import APIKey from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType from backend.app.models.group import Group from backend.app.models.settings import Settings from backend.app.models.user import User from backend.app.schemas.auth import ( EncryptionRowCounts, EncryptionStatusResponse, ForgotPasswordConfirmRequest, ForgotPasswordRequest, ForgotPasswordResponse, GroupBrief, LDAPProvisionRequest, LDAPSearchResultResponse, LoginRequest, LoginResponse, ResetPasswordRequest, ResetPasswordResponse, SetupRequest, SetupResponse, SMTPSettings, TestSMTPRequest, TestSMTPResponse, UserResponse, _validate_password_complexity, ) from backend.app.services.email_service import ( create_password_reset_link_email_from_template, get_smtp_settings, save_smtp_settings, send_email, ) from backend.app.services.finance_defaults import ensure_user_finance_defaults _logger = logging.getLogger(__name__) def _user_to_response(user: User) -> UserResponse: """Convert a User model to UserResponse schema.""" return UserResponse( id=user.id, username=user.username, email=user.email, role=user.role, is_active=user.is_active, is_admin=user.is_admin, auth_source=getattr(user, "auth_source", "local"), groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups], permissions=sorted(user.get_permissions()), created_at=user.created_at.isoformat(), ) async def _api_key_to_user_response(db: AsyncSession, api_key) -> UserResponse: """Describe a valid API key as the identity it actually carries (#1894). Until 0.2.5 this returned a synthetic admin: ``id=0``, ``role="admin"``, ``is_admin=True`` and every permission in the enum. That was wrong in both directions. A key cannot perform administrative operations at all -- ``_check_apikey_permissions`` denies every permission that is not in the scope allowlist -- so a client that builds its UI from this response (which is exactly what a native client does) rendered admin actions that 403 on use, and had no way to learn the id its own prints are filed under. Now: identity comes from the key's owner, and ``permissions`` is the set the key can genuinely exercise. ``is_admin`` is always False because no key can reach an administrative route regardless of who owns it. Legacy keys predating per-user ownership (``user_id IS NULL``) have no identity to report, so they keep ``id=0`` and the ``api-key:`` username -- but they stop claiming admin. ``created_at`` describes the credential in both branches, unchanged. """ # Same resolution the permission gate uses, so what is reported here and # what is enforced there cannot drift -- including the 403 when the owner # has been deactivated, which makes the key dead rather than anonymous. owner = await resolve_apikey_owner(db, api_key) return UserResponse( id=owner.id if owner else 0, username=owner.username if owner else f"api-key:{api_key.key_prefix}", # Withheld on purpose: the owner's email is not needed to resolve # identity, and this response is reachable by anyone holding the key. email=None, # Deprecated free-text field; "user" is the existing value meaning # "not an admin". Inventing an "api_key" role here would put a third # value into a field callers compare against string literals. role="user", is_active=True, is_admin=False, auth_source=getattr(owner, "auth_source", "local") if owner else "local", # The key is not a group member -- listing the owner's groups would # imply capabilities the key does not inherit. groups=[], permissions=apikey_effective_permissions(api_key, owner), created_at=api_key.created_at.isoformat(), ) # --------------------------------------------------------------------------- # M-R9-A: Real client IP resolution for rate limiting behind reverse proxies. # Set TRUSTED_PROXY_IPS (comma-separated) to enable X-Forwarded-For trust. # Without this env var client.host is used directly (safe default). # --------------------------------------------------------------------------- _TRUSTED_PROXY_IPS: frozenset[str] = frozenset( ip.strip() for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",") if ip.strip() ) # #1589: read at call time, not import time, so tests can monkeypatch os.environ # between cases without re-importing the module. def _local_login_env_bypass() -> bool: """Return True when ``BAMBUDDY_LOCAL_LOGIN`` env var is set truthy. Bypasses the ``local_login_enabled`` DB setting on the local-credentials code path AND the forgot-password endpoint so a server admin can recover an install whose SSO provider is unreachable. Accepted truthy values: ``true``, ``1``, ``yes`` (case-insensitive). """ # strict=False: this runs on the login/forgot-password request path, not at # startup. An unrecognized value must fall back to "off" (the safe default), # never raise -- a 500 on the recovery endpoint is the opposite of what this # bypass is for. return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False) def _get_client_ip(request: Request) -> str: """Return the real client IP for rate-limiting purposes. When TRUSTED_PROXY_IPS is configured and the direct TCP peer is a trusted proxy, X-Forwarded-For is evaluated right-to-left: the rightmost IP that is NOT itself a trusted proxy is the true client address (M-R10-A fix). Standard nginx with proxy_add_x_forwarded_for *appends* the client IP, so the rightmost entry is always the one added by the last trusted proxy — i.e. the real client. Walking right-to-left and skipping known proxies is safe for multi-hop chains as well. Falls back to request.client.host when TRUSTED_PROXY_IPS is unset (direct deployment without a reverse proxy). """ # I5: Use a per-request unique token instead of "unknown" when the transport # layer provides no client address. This prevents all such requests from # sharing one rate-limit bucket, and avoids collision with a literal username # "unknown". The token is not stable across requests, which is intentional: # we cannot track the IP so we also cannot rate-limit by it meaningfully. direct_ip = request.client.host if request.client else f"__no_ip_{secrets.token_hex(8)}__" if _TRUSTED_PROXY_IPS and direct_ip in _TRUSTED_PROXY_IPS: forwarded_for = request.headers.get("X-Forwarded-For", "") ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()] # Walk right-to-left; skip IPs that belong to trusted proxies. for ip in reversed(ips): if ip not in _TRUSTED_PROXY_IPS: return ip # Edge case: every entry is a trusted proxy — fall back to leftmost. if ips: return ips[0] return direct_ip router = APIRouter(prefix="/auth", tags=["authentication"]) async def is_auth_enabled(db: AsyncSession) -> bool: """Check if authentication is enabled.""" result = await db.execute(select(Settings).where(Settings.key == "auth_enabled")) setting = result.scalar_one_or_none() if setting is None: return False return setting.value.lower() == "true" async def is_advanced_auth_enabled(db: AsyncSession) -> bool: """Check if advanced authentication is enabled.""" result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled")) setting = result.scalar_one_or_none() if setting is None: return False return setting.value.lower() == "true" async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None: """Set advanced authentication enabled status.""" from backend.app.core.db_dialect import upsert_setting await upsert_setting(db, Settings, "advanced_auth_enabled", "true" if enabled else "false") async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None: """Set authentication enabled status.""" from backend.app.core.auth import invalidate_auth_enabled_cache from backend.app.core.db_dialect import upsert_setting await upsert_setting(db, Settings, "auth_enabled", "true" if enabled else "false") # Drop the cached auth-enabled flag so the change takes effect immediately # instead of after the TTL (issue #2572). Safe pre-commit: only enabled=True # is ever cached, and the newly-enabled True isn't visible to other sessions # until this transaction commits, so no stale value can be re-cached here. invalidate_auth_enabled_cache() # Note: Don't commit here - let get_db handle it or commit explicitly in the route async def is_setup_completed(db: AsyncSession) -> bool: """Check if setup has been completed.""" result = await db.execute(select(Settings).where(Settings.key == "setup_completed")) setting = result.scalar_one_or_none() return setting and setting.value.lower() == "true" async def set_setup_completed(db: AsyncSession, completed: bool) -> None: """Set setup completed status.""" from backend.app.core.db_dialect import upsert_setting await upsert_setting(db, Settings, "setup_completed", "true" if completed else "false") # Note: Don't commit here - let get_db handle it or commit explicitly in the route @router.post("/setup", response_model=SetupResponse) async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)): """First-time setup: enable/disable authentication and create admin user.""" import logging logger = logging.getLogger(__name__) try: # If auth is currently enabled, block unauthenticated setup changes. # Use the admin panel (/disable endpoint) to modify auth when it's already on. if await is_auth_enabled(db): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Authentication is already configured. Use the admin panel to modify auth settings.", ) admin_created = False if request.auth_enabled: # Check if admin users already exist admin_users_result = await db.execute(select(User).where(User.role == "admin")) existing_admin_users = list(admin_users_result.scalars().all()) has_admin_users = len(existing_admin_users) > 0 if has_admin_users: # Admin users already exist, just enable auth (don't create new admin) logger.info( f"Admin users already exist ({len(existing_admin_users)} found), enabling authentication without creating new admin" ) admin_created = False else: # No admin users exist, require admin credentials to create first admin if not request.admin_username or not request.admin_password: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Admin username and password are required when enabling authentication (no admin users exist)", ) # Enforce password complexity only when actually creating a new admin. # Schema-level validation was removed so that re-enabling auth with an # existing admin (or LDAP) doesn't reject whatever placeholder the form sends. try: _validate_password_complexity(request.admin_password) except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc), ) # Check if username already exists (shouldn't happen if no admin users exist, but check anyway) existing_user = await get_user_by_username(db, request.admin_username) if existing_user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="User with this username already exists", ) # Create admin user FIRST (before enabling auth) try: logger.info("Creating admin user: %s", request.admin_username) admin_user = User( username=request.admin_username, password_hash=get_password_hash(request.admin_password), role="admin", is_active=True, ) # Try to add user to Administrators group if it exists admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators")) admin_group = admin_group_result.scalar_one_or_none() if admin_group: admin_user.groups.append(admin_group) logger.info("Added new admin user to Administrators group") db.add(admin_user) logger.info("Admin user added to session: %s", request.admin_username) admin_created = True except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); no user is created on error await db.rollback() logger.error("Failed to create admin user: %s", e, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create admin user", ) if request.auth_enabled: # Enabling auth flips cloud-credential storage from the global # Settings rows to User.cloud_token. Carry any token linked while # auth was off across to the owning admin, or /cloud/* silently # degrades to local presets with no indication anything broke # (#2530). Only migrate when there is exactly one obvious owner: # handing another admin's session a Bambu credential is not a # guess worth making. from backend.app.api.routes.cloud import migrate_global_cloud_token_to_user from backend.app.services.bambu_cloud_credentials import get_stored_token if admin_created: cloud_owner = admin_user elif len(existing_admin_users) == 1: cloud_owner = existing_admin_users[0] else: cloud_owner = None if cloud_owner is not None: if await migrate_global_cloud_token_to_user(db, cloud_owner): logger.info("Migrated global Bambu Cloud credentials to admin '%s'", cloud_owner.username) else: global_token, _, _ = await get_stored_token(db, None) if global_token: logger.warning( "A Bambu Cloud account is linked globally but %s admins exist; " "leaving it unassigned. Re-link the account from Settings after login.", len(existing_admin_users), ) # Set auth enabled and mark setup as completed await set_auth_enabled(db, request.auth_enabled) await set_setup_completed(db, True) if request.auth_enabled: # Tokens minted while it was off name nobody (every tab mints a # WebSocket one): they would reconnect the dashboards closed below. await db.execute( delete(AuthEphemeralToken).where( or_(AuthEphemeralToken.username.is_(None), AuthEphemeralToken.username == ""), AuthEphemeralToken.api_key_id.is_(None), AuthEphemeralToken.token_type != "revoked_jti", ) ) await db.commit() if request.auth_enabled: # Dashboards opened while it was off have every printer and would # keep them. Closed, such a tab re-checks auth and goes to the login. from backend.app.core.websocket import ws_manager await ws_manager.close_anonymous() if admin_created: await db.refresh(admin_user) logger.info("Admin user created successfully: %s", admin_user.id) logger.info("Setup completed: auth_enabled=%s, admin_created=%s", request.auth_enabled, admin_created) return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created) except HTTPException: raise except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); setup state stays unchanged logger.error("Setup error: %s", e, exc_info=True) await db.rollback() raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Setup failed", ) @router.get("/status") async def get_auth_status(db: AsyncSession = Depends(get_db)): """Get authentication status (public endpoint).""" auth_enabled = await is_auth_enabled(db) setup_completed = await is_setup_completed(db) # Only require setup if it hasn't been completed yet requires_setup = not setup_completed return {"auth_enabled": auth_enabled, "requires_setup": requires_setup} @router.post("/disable", response_model=dict) async def disable_auth( current_user: User = Depends(get_current_active_user), db: AsyncSession = Depends(get_db), ): """Disable authentication (admin only).""" import logging logger = logging.getLogger(__name__) # Reload user with groups for proper is_admin check result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups))) user = result.scalar_one() # Only admins can disable authentication if not user.is_admin: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only admins can disable authentication", ) try: # Mirror of the migration in setup_auth: with auth off the cloud routes # read the global Settings rows and never look at User.cloud_token, so # hand this admin's credential over rather than stranding it (#2530). from backend.app.api.routes.cloud import migrate_user_cloud_token_to_global if await migrate_user_cloud_token_to_global(db, user): logger.info("Migrated Bambu Cloud credentials from admin '%s' to global storage", user.username) await set_auth_enabled(db, False) await db.commit() logger.info("Authentication disabled by admin user: %s", user.username) return {"message": "Authentication disabled successfully", "auth_enabled": False} except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); auth_enabled stays at its prior value await db.rollback() logger.error("Failed to disable authentication: %s", e, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to disable authentication", ) @router.post("/login", response_model=LoginResponse) async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)): """Login and get access token. Supports username or email-based login. Username lookup is case-insensitive. When 2FA is enabled for the user the response contains ``requires_2fa=True`` and a short-lived ``pre_auth_token`` instead of the final JWT. The client must then call ``POST /auth/2fa/verify`` (or first ``POST /auth/2fa/email/send`` to trigger an email OTP) to obtain the real access token. """ # Check if auth is enabled auth_enabled = await is_auth_enabled(db) if not auth_enabled: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Authentication is not enabled", ) # Rate-limit repeated login failures — two independent buckets (M-R5-B / M-R6-A): # 1. Per-username (10/15 min): prevents password brute-force on a known account. # 2. Per-IP (20/15 min): prevents an attacker from locking out arbitrary accounts # (DoS) by sending failures for many usernames from a single address. from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS, check_rate_limit, record_failed_attempt await check_rate_limit(db, request.username, event_type=EventType.LOGIN_ATTEMPT, max_attempts=MAX_LOGIN_ATTEMPTS) client_ip = _get_client_ip(raw_request) await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20) # Initialize `user` up front so every downstream branch can read/write # it without UnboundLocalError. The LDAP success path sets it inside its # own block; the local-credentials and email-credentials paths set it # below. The original code relied on the local-credentials path running # unconditionally to bind `user`; #1589 made that path skippable, so the # init has to live here. user = None # Check if LDAP is enabled ldap_user = None ldap_user_provisioned = False ldap_settings = await _get_ldap_settings(db) if ldap_settings: try: from backend.app.services.ldap_service import ( authenticate_ldap_user, parse_ldap_config, ) ldap_config = parse_ldap_config(ldap_settings) if ldap_config: ldap_user = authenticate_ldap_user(ldap_config, request.username, request.password) if ldap_user: # LDAP auth succeeded — find or create local user user = await get_user_by_username(db, ldap_user.username) if user and user.auth_source != "ldap": # Username exists as local user — don't override user = None ldap_user = None elif not user: if not ldap_config.auto_provision: # User doesn't exist and auto-provision is off ldap_user = None else: # Auto-provision LDAP user. Provisioning already sets the # email, groups and finance defaults the sync below would, # so a new user skips it (it also logged the default-group # warning a second time, #3197). user = await _provision_ldap_user(db, ldap_user, ldap_config) ldap_user_provisioned = True if user and ldap_user and not ldap_user_provisioned: # Update email and group mappings on each login await _sync_ldap_user(db, user, ldap_user, ldap_config) # Keep finance defaults idempotently in sync for LDAP users # (wallet + private cost center + self-membership). await ensure_user_finance_defaults(db, user) except Exception as e: # SEC-AUTH-EXC: LDAP failure sets ldap_user=None, downstream local-auth path runs with its own credential check (no implicit grant) import logging logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e) ldap_user = None # #1589: local username/password gate. LDAP keeps its own switch # (ldap_enabled) and is not affected — a delegated directory has its # own policy and lockouts and is closer to SSO than to local creds. # The env-var BAMBUDDY_LOCAL_LOGIN=true bypasses this gate so a server # admin can recover an install whose SSO provider is unreachable # without editing the DB. from backend.app.models.settings import Settings as _Settings_for_local_login local_login_allowed = ldap_user is not None or _local_login_env_bypass() if not local_login_allowed: setting_row = await db.execute( select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled") ) row = setting_row.scalar_one_or_none() # Default True when the row is absent — matches AppSettings default # so fresh installs and tests behave like every release before #1589. local_login_allowed = row is None or row.value.lower() == "true" # Try username-based authentication (skip if already authenticated via LDAP) if not ldap_user and local_login_allowed: user = await authenticate_user(db, request.username, request.password) # If username auth failed and advanced auth is enabled, try email-based authentication if not user and not ldap_user and local_login_allowed: advanced_auth = await is_advanced_auth_enabled(db) if advanced_auth: user = await authenticate_user_by_email(db, request.username, request.password) if not user: await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT) await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP) # Same generic 401 either way — never tell the client whether the # username exists or whether local login was disabled. The Settings # UI and /auth/advanced-auth/status are the channels for that state; # leaking it here would help credential-stuffing distinguish "local # disabled" from "wrong password" across an install fleet. raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password", headers={"WWW-Authenticate": "Bearer"}, ) # Reload user with groups for proper permission calculation result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups))) user = result.scalar_one() # L-R6-A: Password was correct — reset login failure counters for both buckets from backend.app.api.routes.mfa import clear_failed_attempts await clear_failed_attempts(db, user.username, event_type=EventType.LOGIN_ATTEMPT) await clear_failed_attempts(db, client_ip, event_type=EventType.LOGIN_IP) # --- 2FA check --- # Determine which 2FA methods are active for this user. from backend.app.models.settings import Settings as _Settings from backend.app.models.user_totp import UserTOTP totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id)) user_totp = totp_result.scalar_one_or_none() totp_enabled = user_totp is not None and user_totp.is_enabled email_2fa_result = await db.execute(select(_Settings).where(_Settings.key == f"user_{user.id}_email_2fa_enabled")) email_2fa_setting = email_2fa_result.scalar_one_or_none() email_otp_enabled = ( email_2fa_setting is not None and email_2fa_setting.value.lower() == "true" and user.email is not None ) if totp_enabled or email_otp_enabled: # Import here to avoid circular imports from backend.app.api.routes.mfa import create_pre_auth_token # Bind the pre_auth_token to an HttpOnly cookie so XSS cannot steal the # token from JS memory and complete 2FA from a different client. challenge_id = secrets.token_urlsafe(32) pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id) response.set_cookie( key="2fa_challenge", value=challenge_id, httponly=True, # H-1: only transmit over HTTPS so the binding cookie can't be intercepted # on mixed-content deployments. Falls back to False on plain HTTP so tests # and local development still work (the client wouldn't send it otherwise). secure=raw_request.url.scheme == "https", samesite="lax", max_age=300, path="/api/v1/auth/2fa", ) methods: list[str] = [] if totp_enabled: methods.append("totp") if email_otp_enabled: methods.append("email") # Backup codes are always available when TOTP is set up if totp_enabled: methods.append("backup") return LoginResponse( requires_2fa=True, pre_auth_token=pre_auth_token, two_fa_methods=methods, ) # No 2FA — issue full token immediately. Session lifetime honours the # admin-configurable ceiling (#1706); resolver clamps to [1h, 720h]. access_token_expires = timedelta(minutes=await resolve_session_max_minutes(db)) access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires) return LoginResponse( access_token=access_token, token_type="bearer", user=_user_to_response(user), ) @router.post("/ws-token") async def mint_websocket_token( current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT), api_key: APIKey | None = Depends(current_api_key_if_present), ): """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up). The WebSocket endpoint cannot read ``Authorization`` headers from browsers (the WebSocket handshake does not let JS attach custom headers), so we use the same opaque-token-in-query-param pattern as ``/camera/stream`` — the token is minted here behind the standard permission gate, then appended as ``?token=`` on the ``ws://...`` URL. The WebSocket endpoint validates it *before* calling ``websocket.accept()``. Returns ``{"token": }``. The token is valid for 60 minutes; the SPA refreshes it on reconnect if expired. API keys can mint tokens too — their scope flags decide whether ``WEBSOCKET_CONNECT`` passes via the standard allowlist (``can_read_status`` covers it). """ username = current_user.username if current_user is not None else None # The socket only carries the printers its minter may see (#1727) api_key_id = api_key.id if api_key is not None else None # The key's prefix binds the token to this key (SQLite reuses a deleted key's id) api_key_prefix = api_key.key_prefix if api_key is not None else None return {"token": await create_websocket_token(username, api_key_id, api_key_prefix)} @router.post("/media-token") async def mint_media_token( current_user: User | None = Depends(require_auth_if_enabled), ): """Mint a short-lived token for ```` / ``