"""Auth API — single-admin sign-in (phase 16; A10 revised 2026-08-22). * ``POST /api/login`` — 204 + signed session cookie on success; 401 ``invalid password`` on any mismatch (constant-time, one generic message, no session set). * ``POST /api/logout`` — 204; clears the session and expires the cookie (idempotent for anonymous callers). * ``GET /api/whoami`` — ``{"authenticated": bool, "role": "admin"|"anonymous"}``; the single source of truth for all UI gating. The public API otherwise stays stateless (A10): chat, the document content endpoint (soft rule — anonymous may open any document by direct URL), suggestions, and health never require the cookie. """ from __future__ import annotations from fastapi import APIRouter, HTTPException, Request, Response from app.config import get_settings from app.core.auth import ADMIN_SESSION_KEY, check_password, sign_in, sign_out from app.schemas import LoginRequest, WhoamiResponse router = APIRouter(tags=["auth"]) @router.post("/login", status_code=204) def login(payload: LoginRequest, request: Request) -> Response: """Sign in the single admin. Success: 204 + the signed ``bor_session`` cookie (``same_site=lax``, 12 h default lifetime). Failure: one generic 401 — the admin count is one, so there is nothing else to leak, and a wrong password must not set any session state. """ settings = get_settings() if check_password(payload.password, settings.admin_password): sign_in(request.session) return Response(status_code=204) raise HTTPException(status_code=401, detail="invalid password") @router.post("/logout", status_code=204) def logout(request: Request, response: Response) -> Response: """Sign out: clear the session AND expire the browser cookie. ``sign_out`` empties the session dict (which the middleware does not re-persist — an empty session has nothing to sign), so this route also sends ``delete_cookie`` to make the browser drop the signed cookie right now. Idempotent: an anonymous logout is still a 204. """ sign_out(request.session) response.delete_cookie(get_settings().session_cookie, path="/") return Response(status_code=204) @router.get("/whoami", response_model=WhoamiResponse) def whoami(request: Request) -> WhoamiResponse: """Who is the caller? Drives every UI gating decision (phase 16).""" authenticated = bool(request.session.get(ADMIN_SESSION_KEY)) return WhoamiResponse( authenticated=authenticated, role="admin" if authenticated else "anonymous", )