"""Session bootstrap: the one endpoint reachable without a session (US07-02). It sets the ``HttpOnly``/``SameSite=Strict`` session cookie and returns the CSRF token in the body. A foreign page can call this — it just cannot read the answer, because the app sends no CORS headers — and the cookie it received is never attached to a request that foreign page initiated. When an access secret is configured (mandatory as soon as the app is reachable from another machine, US08-01) this is also the authentication gate: the secret buys the cookie, and every route behind it keeps asking for exactly the session and CSRF token it asked for before. Wrong secrets are counted, and a burst of them stops being answered — otherwise a proxy-exposed deployment could be guessed at indefinitely. """ from __future__ import annotations import logging import secrets from fastapi import APIRouter, Request from fastapi.responses import JSONResponse from photo_pipeline.api.security import ACCESS_SECRET_HEADER, SESSION_COOKIE router = APIRouter(tags=["session"]) log = logging.getLogger(__name__) def _refuse(status: int, code: str, message: str) -> JSONResponse: return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}}) @router.get("/session") def start_session(request: Request) -> JSONResponse: config = request.app.state.config secret = config.access_secret if secret is not None: limiter = request.app.state.access_limiter if limiter.blocked(): return _refuse(429, "too_many_attempts", "too many failed attempts; retry later") offered = request.headers.get(ACCESS_SECRET_HEADER, "") if not secrets.compare_digest(offered, secret.get_secret_value()): limiter.record_failure() # The client address is the whole record: the offered secret, the issued # session, and the request body all stay out of the log. log.warning( "access secret rejected", extra={"client": _client(request), "path": "/session"} ) return _refuse(401, "access_denied", "a valid access secret is required") session = request.app.state.session response = JSONResponse({"csrf_token": session.csrf_token}) response.set_cookie( SESSION_COOKIE, session.id, httponly=True, samesite="strict", # HTTPS outside means the cookie must never travel over a plain hop, even one # this process cannot see. Loopback http keeps working unchanged. secure=request.scope.get("state", {}).get("external_scheme") == "https", path="/", ) return response def _client(request: Request) -> str: return request.client.host if request.client else "unknown"