"""Session and CSRF cookie handling (SPEC §6.1, §10.3). Over HTTPS we emit the spec-mandated ``__Host-session`` prefix with ``Secure``. Over plain HTTP (dev only) we fall back to a plain ``session`` name without ``Secure`` so login works behind the Vite HTTP proxy. See docs/open-questions.md OQ-3. ``SameSite=Lax``, ``HttpOnly`` (session), ``Path=/`` are always applied. """ from __future__ import annotations import secrets from fastapi import Request, Response SESSION_COOKIE_SECURE = "__Host-session" SESSION_COOKIE_INSECURE = "session" CSRF_COOKIE_SECURE = "__Host-csrf" CSRF_COOKIE_INSECURE = "csrf" CSRF_HEADER = "X-CSRF" def _is_secure(request: Request) -> bool: return request.url.scheme == "https" def session_cookie_name(request: Request) -> str: """Return the session cookie name appropriate to the request scheme.""" return SESSION_COOKIE_SECURE if _is_secure(request) else SESSION_COOKIE_INSECURE def csrf_cookie_name(request: Request) -> str: """Return the CSRF cookie name appropriate to the request scheme.""" return CSRF_COOKIE_SECURE if _is_secure(request) else CSRF_COOKIE_INSECURE def read_session_token(request: Request) -> str: """Read the session token from either cookie name (whichever is present).""" return request.cookies.get(SESSION_COOKIE_SECURE) or request.cookies.get( SESSION_COOKIE_INSECURE, "" ) def new_csrf_token() -> str: """Return a fresh CSRF token for the double-submit pattern.""" return secrets.token_urlsafe(32) def set_auth_cookies( request: Request, response: Response, session_token: str, csrf_token: str, max_age: int ) -> None: """Set the session (HttpOnly) and CSRF (JS-readable) cookies.""" secure = _is_secure(request) response.set_cookie( session_cookie_name(request), session_token, max_age=max_age, httponly=True, secure=secure, samesite="lax", path="/", ) response.set_cookie( csrf_cookie_name(request), csrf_token, max_age=max_age, httponly=False, # double-submit: client JS echoes it in the X-CSRF header secure=secure, samesite="lax", path="/", ) def clear_auth_cookies(request: Request, response: Response) -> None: """Clear both auth cookies (logout).""" for name in (session_cookie_name(request), csrf_cookie_name(request)): response.delete_cookie(name, path="/")