"""HTTP Basic auth for the API. Gate is a single ASGI middleware that protects `/api/*` (the data + functionality). `/health`, the static SPA shell, and `/portal` stay open so the login screen can load and Vercel health checks pass. Credentials come from APERTURE_USER / APERTURE_PASS (defaults: demo / aperture2026 for the demo deployment). Stateless: the SPA stores base64(user:pass) and sends it on every request; there is no server-side session to manage on serverless. """ from __future__ import annotations import base64 import secrets from starlette.requests import Request from starlette.responses import JSONResponse def _check(authorization: str | None, user: str, pwd: str) -> bool: if not authorization or not authorization.lower().startswith("basic "): return False try: decoded = base64.b64decode(authorization.split(" ", 1)[1]).decode("utf-8") u, _, p = decoded.partition(":") except Exception: return False return secrets.compare_digest(u, user) and secrets.compare_digest(p, pwd) def make_auth_middleware(user: str, pwd: str): async def auth_middleware(request: Request, call_next): path = request.url.path # Only guard the API; allow preflight, health, SPA shell, and portal. if request.method == "OPTIONS" or not path.startswith("/api/"): return await call_next(request) if _check(request.headers.get("authorization"), user, pwd): return await call_next(request) # NOTE: deliberately NO `WWW-Authenticate: Basic` header — that triggers the # browser's native credential popup. The React SPA handles 401 itself and # shows its own login screen, so we return a plain JSON 401. return JSONResponse({"detail": "Authentication required"}, status_code=401) return auth_middleware