"""Single-admin authentication (phase 16; LOCKED A10 revised 2026-08-22). One admin (the owner), one plaintext password, one signed cookie. The mechanism is Starlette's ``SessionMiddleware`` (itsdangerous-signed cookie — no server-side store, no new services, no DB tables): the public API stays stateless, the cookie is the *only* session state. Contract: * ``ensure_admin_configured`` — fail-loud startup gate: the app must name the missing ``BOR_`` variable(s) instead of serving anything. * ``check_password`` — constant-time compare; exactly one generic 401 message (no user enumeration, there is no second user). * ``require_admin`` — FastAPI dependency; anonymous callers get 403 ``{"detail": "admin only"}`` (used by ``GET /api/docs`` and the whole ``/api/steering`` router). * ``sign_in`` / ``sign_out`` — session-dict helpers for the API routes. """ from __future__ import annotations import secrets from collections.abc import MutableMapping from typing import Any from fastapi import HTTPException, Request from app.config import Settings #: The session key the single admin is stored under. ADMIN_SESSION_KEY = "admin" def ensure_admin_configured(settings: Settings) -> None: """Refuse to boot without admin auth (fail-loud, A6 spirit). Raises :class:`RuntimeError` naming every missing ``BOR_`` variable so the startup log tells the operator exactly what to set. """ missing = [ env_name for env_name, value in ( ("BOR_ADMIN_PASSWORD", settings.admin_password), ("BOR_SESSION_SECRET", settings.session_secret), ) if not value.strip() ] if missing: raise RuntimeError( "Brain of Reese cannot start: admin auth is not configured. " f"Set the missing variable(s): {', '.join(missing)} " "(see .env.example and the README 'Admin & sign-in' section)." ) def check_password(candidate: str, expected: str) -> bool: """Constant-time password check (``secrets.compare_digest``). One admin → one generic 401 on any mismatch: the response never reveals whether the password was *close*, empty, or not (no user enumeration). """ return secrets.compare_digest(candidate.encode("utf-8"), expected.encode("utf-8")) def require_admin(request: Request) -> None: """FastAPI dependency: allow the signed-in admin, else 403. Reads the cookie-backed session installed by ``SessionMiddleware``; a request without a valid admin session gets 403 ``admin only``. """ if not request.session.get(ADMIN_SESSION_KEY): raise HTTPException(status_code=403, detail="admin only") def sign_in(session: MutableMapping[str, Any]) -> None: """Mark the (cookie-backed) session as the single admin. Writing the key marks the session modified, so the middleware emits the signed ``bor_session`` cookie with the configured Max-Age. """ session[ADMIN_SESSION_KEY] = True def sign_out(session: MutableMapping[str, Any]) -> None: """Clear the session state (the route additionally expires the cookie). An emptied dict is not re-persisted by the middleware, so the API route pairs this with ``response.delete_cookie`` to make the browser drop the signed cookie immediately. """ session.clear()