Coverage for server / authentication / cookie_session.py: 89%
44 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2BFF httpOnly-Cookie Session Helpers — Teacher/Student API (port 8000)
4Phase 1 of ADR-001: dual-mode auth. The login endpoint calls
5set_auth_cookies() to write the two session cookies while still returning
6the token in the JSON body. Auth0Bearer reads the cookie as an alternative
7to the Authorization header; cookie-based requests on unsafe HTTP methods
8are CSRF-verified via the double-submit pattern.
10Cookie attributes (committed in ADR-001):
11 access_token HttpOnly; Secure*; SameSite=None; Path=/; Max-Age=<ttl>
12 csrf_token Secure*; SameSite=None; Path=/ (NOT HttpOnly — SPA reads it)
14* Secure is configurable via COOKIE_SECURE env var (default "true").
15 Set COOKIE_SECURE=false for local http:// testing only.
17Developer: Allan Ninal
18"""
20import logging
21import os
22import secrets
24from fastapi import Request, Response, status
25from fastapi import HTTPException
27logger = logging.getLogger(__name__)
29# ---------------------------------------------------------------------------
30# Internal helpers
31# ---------------------------------------------------------------------------
33_COOKIE_SECURE_ENV = os.getenv("COOKIE_SECURE", "true").lower()
34_COOKIE_SECURE: bool = _COOKIE_SECURE_ENV not in ("false", "0", "no")
36_SAMESITE = "none"
37_ACCESS_TOKEN_COOKIE = "access_token"
38_CSRF_TOKEN_COOKIE = "csrf_token"
39_CSRF_HEADER = "x-csrf-token"
41# Phase 4 (ADR-001): the refresh token is scoped to the auth routes so the
42# browser only ever sends it to /v1/auth/refresh and /v1/auth/logout — never to
43# the rest of the API. Refresh tokens outlive the 24h access token; cap at 14d.
44_REFRESH_TOKEN_COOKIE = "refresh_token"
45_REFRESH_COOKIE_PATH = "/v1/auth"
46_REFRESH_MAX_AGE = 14 * 24 * 3600
48# Unsafe HTTP methods that require a CSRF header when using cookie auth.
49_UNSAFE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
52def _cookie_secure() -> bool:
53 """Re-read COOKIE_SECURE at call time so tests can patch os.environ."""
54 val = os.getenv("COOKIE_SECURE", "true").lower()
55 return val not in ("false", "0", "no")
58# ---------------------------------------------------------------------------
59# Public API
60# ---------------------------------------------------------------------------
62def set_auth_cookies(
63 response: Response,
64 access_token: str,
65 expires_in: int | None,
66 csrf_token: str | None = None,
67) -> str:
68 """
69 Write access_token (HttpOnly) and csrf_token (readable) cookies onto
70 *response*. Returns the generated csrf_token value so callers can log it
71 if needed.
73 Called from the login route BEFORE the existing ``return {access_token, …}``
74 so the body token stays in Phase 1.
76 These are **session cookies** (no Max-Age/Expires): the browser drops them
77 when it closes, so closing the browser without logging out ends the
78 session. The access token's own JWT ``exp`` still bounds its lifetime
79 within an open session; the refresh cookie (also session-scoped) renews it
80 while the browser stays open. ``expires_in`` is accepted for signature
81 compatibility but intentionally no longer pinned onto the cookie.
83 ``csrf_token`` is generated when omitted, which is what every caller wants.
84 It is passed in for exactly one case: a refresh that JOINED an in-flight
85 rotation (see ``server/utilities/refresh_single_flight.py``) must reuse the
86 winner's CSRF value. If it minted its own, the two racing tabs would hold
87 different CSRF tokens while the browser kept only the last cookie written,
88 and whichever tab lost that race would fail its next unsafe request.
89 """
90 secure = _cookie_secure()
92 csrf_token = csrf_token or secrets.token_urlsafe(32)
94 response.set_cookie(
95 key=_ACCESS_TOKEN_COOKIE,
96 value=access_token,
97 httponly=True,
98 secure=secure,
99 samesite=_SAMESITE,
100 path="/",
101 )
102 response.set_cookie(
103 key=_CSRF_TOKEN_COOKIE,
104 value=csrf_token,
105 httponly=False,
106 secure=secure,
107 samesite=_SAMESITE,
108 path="/",
109 )
111 logger.debug("Auth session cookies set (secure=%s, session-scoped)", secure)
112 return csrf_token
115def set_refresh_cookie(response: Response, refresh_token: str) -> None:
116 """Write the httpOnly refresh-token cookie, scoped to the auth routes
117 (Path=/v1/auth) so it is never sent to the rest of the API.
119 Session-scoped (no Max-Age): the browser drops it on close, so a closed
120 browser cannot silently revive the session via /v1/auth/refresh, and a
121 logged-out user has no surviving refresh token to auto-login with."""
122 response.set_cookie(
123 key=_REFRESH_TOKEN_COOKIE,
124 value=refresh_token,
125 httponly=True,
126 secure=_cookie_secure(),
127 samesite=_SAMESITE,
128 path=_REFRESH_COOKIE_PATH,
129 )
132def get_refresh_token(request: Request) -> str:
133 """Return the refresh-token cookie value, or "" if absent."""
134 return request.cookies.get(_REFRESH_TOKEN_COOKIE, "")
137def clear_auth_cookies(response: Response) -> None:
138 """Delete all three session cookies (access, csrf, refresh)."""
139 secure = _cookie_secure()
141 response.delete_cookie(
142 key=_ACCESS_TOKEN_COOKIE,
143 path="/",
144 secure=secure,
145 samesite=_SAMESITE,
146 httponly=True,
147 )
148 response.delete_cookie(
149 key=_CSRF_TOKEN_COOKIE,
150 path="/",
151 secure=secure,
152 samesite=_SAMESITE,
153 httponly=False,
154 )
155 response.delete_cookie(
156 key=_REFRESH_TOKEN_COOKIE,
157 path=_REFRESH_COOKIE_PATH,
158 secure=secure,
159 samesite=_SAMESITE,
160 httponly=True,
161 )
162 logger.debug("Auth cookies cleared")
165def validate_csrf(request: Request) -> bool:
166 """
167 Double-submit CSRF check.
169 Returns True — safe method, or CSRF header matches cookie.
170 Returns False — unsafe method and header is missing/mismatched.
172 Callers should raise HTTP 403 when this returns False.
173 Note: GET/HEAD/OPTIONS are always safe (the response is not readable
174 cross-origin thanks to CORS), so we only check unsafe methods.
175 """
176 if request.method.upper() not in _UNSAFE_METHODS:
177 return True
179 cookie_value = request.cookies.get(_CSRF_TOKEN_COOKIE, "")
180 header_value = request.headers.get(_CSRF_HEADER, "")
182 if not cookie_value or not header_value:
183 return False
185 # Use secrets.compare_digest to avoid timing attacks.
186 return secrets.compare_digest(cookie_value, header_value)