📦 EqualifyEverything / equalify-reflow

📄 cookies.py · 114 lines
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="/",
    )