1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114"""Cookie set/clear helpers.
Centralised so login, logout, and the middleware's sliding-window re-issue
all set the same flags. The ``X-Auth-Cookie-Set`` response header is a
sentinel: middleware checks it before re-issuing so that logout's
``Max-Age=0`` is never overwritten by a refresh in the same response cycle.
"""
from __future__ import annotations
from fastapi import Response
from ..config import settings
from . import csrf
from .base import Identity
from .factory import get_session_store
def set_session_cookies(*, response: Response, identity: Identity) -> None:
"""Set both the signed session cookie and its CSRF companion."""
store = get_session_store()
secret = settings.auth_secret_key
if secret is None:
raise RuntimeError("auth_secret_key required to set session cookies")
session_value = store.encode(identity)
csrf_token = csrf.make_token(session_value, secret.get_secret_value())
# Session cookie: HttpOnly so JS can't steal it; SameSite=Lax so the
# OIDC redirect-back from the IdP carries it through (Strict would drop
# the cookie on the post-redirect first request and silently break login).
response.set_cookie(
key=settings.auth_session_cookie_name,
value=session_value,
max_age=settings.auth_session_ttl_seconds,
secure=settings.auth_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)
# CSRF companion: NOT HttpOnly — the SPA needs to read it to echo as a
# header on non-GET requests. Same Secure/SameSite/path as the session.
response.set_cookie(
key=f"{settings.auth_session_cookie_name}_csrf",
value=csrf_token,
max_age=settings.auth_session_ttl_seconds,
secure=settings.auth_cookie_secure,
httponly=False,
samesite="lax",
path="/",
)
response.headers["X-Auth-Cookie-Set"] = "1"
def clear_session_cookies(response: Response) -> None:
"""Expire both cookies on logout."""
for name in (
settings.auth_session_cookie_name,
f"{settings.auth_session_cookie_name}_csrf",
):
response.set_cookie(
key=name,
value="",
max_age=0,
secure=settings.auth_cookie_secure,
httponly=(name == settings.auth_session_cookie_name),
samesite="lax",
path="/",
)
response.headers["X-Auth-Cookie-Set"] = "1"
# --- OAuth transaction cookie (OIDC kickoff/callback handshake) --------------
OAUTH_TX_COOKIE_NAME = "reflow_oauth_tx"
# 10 minutes — long enough that a slow IdP (Entra MFA, password resets)
# still completes; short enough that a captured cookie can't be replayed
# hours later. Mirrors OAUTH_TX_TTL_SECONDS in providers/oidc_provider.py.
OAUTH_TX_TTL_SECONDS = 600
def set_oauth_tx_cookie(response: Response, value: str) -> None:
"""Write the signed OAuth transaction cookie set during OIDC kickoff.
Carries ``state``, ``nonce``, PKCE verifier, and ``next_path``. Read
by the matching ``/api/v1/auth/callback/{provider_id}`` route to
validate the redirect-back. ``HttpOnly`` so JS can't read it;
``SameSite=Lax`` so the IdP's redirect-back GET still sends it.
"""
response.set_cookie(
key=OAUTH_TX_COOKIE_NAME,
value=value,
max_age=OAUTH_TX_TTL_SECONDS,
secure=settings.auth_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)
def clear_oauth_tx_cookie(response: Response) -> None:
"""Delete the OAuth transaction cookie. Call on callback success or
failure so a stale tx can't be replayed against a future kickoff.
"""
response.set_cookie(
key=OAUTH_TX_COOKIE_NAME,
value="",
max_age=0,
secure=settings.auth_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)