325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""Cookie helpers for dashboard auth.
|
|
|
|
Cookies (all HttpOnly, ``SameSite=Lax`` unless noted, Path = proxy prefix or /):
|
|
- hermes_session_at access token; Max-Age = token TTL (~15 min)
|
|
- hermes_session_rt rotating refresh token; written only when the
|
|
provider returned one (AT-only sessions degrade
|
|
gracefully), always cleared on logout/expiry
|
|
- hermes_session_provider non-secret routing hint so a refresh token is
|
|
not handed to the wrong provider when several
|
|
auth plugins are enabled
|
|
- hermes_session_pkce PKCE state + CSRF nonce + provider hint, 10 min.
|
|
``SameSite=None; Secure`` over HTTPS because it is
|
|
set on the /auth/login 302 and must survive the
|
|
cross-site redirect chain out to the IDP and back
|
|
(Chromium drops Lax cookies set on such a 302,
|
|
crbug 40508226); plain HTTP falls back to Lax.
|
|
- hermes_sso_attempt one-shot auto-SSO loop-guard marker (60 s)
|
|
|
|
``Secure`` is set only when the request arrived over HTTPS
|
|
(``request.url.scheme``, which honours ``X-Forwarded-Proto`` only from a peer
|
|
in uvicorn's ``forwarded_allow_ips``). Cookie-prefix hardening follows
|
|
draft-west-cookie-prefixes: bare name over HTTP; ``__Host-`` on gated HTTPS
|
|
with Path=/; ``__Secure-`` behind a proxy prefix (``__Host-`` forbids
|
|
Path != /). Setters and readers BOTH resolve the name through
|
|
:func:`_resolved_name` — a mismatch silently breaks sessions.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import json
|
|
import re
|
|
from typing import Literal, Optional, Tuple
|
|
from urllib.parse import unquote
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import Response
|
|
|
|
SESSION_AT_COOKIE = "hermes_session_at"
|
|
SESSION_RT_COOKIE = "hermes_session_rt"
|
|
SESSION_PROVIDER_COOKIE = "hermes_session_provider"
|
|
PKCE_COOKIE = "hermes_session_pkce"
|
|
SSO_ATTEMPT_COOKIE = "hermes_sso_attempt"
|
|
|
|
# Name variants a reader may have to try; most strict first.
|
|
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
|
|
|
|
# RT cookie lifetime is a generous browser-side upper bound; the provider's own
|
|
# RT TTL is the real authority (an expired RT -> RefreshExpiredError -> re-login).
|
|
_RT_MAX_AGE = 30 * 24 * 60 * 60
|
|
_PKCE_MAX_AGE = 10 * 60
|
|
# Long enough for one portal round trip / back-button; short enough that a
|
|
# user returning later gets a fresh silent attempt rather than a stuck /login.
|
|
_SSO_ATTEMPT_MAX_AGE = 60
|
|
|
|
|
|
def _resolved_name(bare: str, *, use_https: bool, prefix: str) -> str:
|
|
"""Cookie-prefix variant for the request shape (see module docstring)."""
|
|
if not use_https:
|
|
return bare
|
|
if prefix:
|
|
return f"__Secure-{bare}"
|
|
return f"__Host-{bare}"
|
|
|
|
|
|
def _cookie_path(prefix: str) -> str:
|
|
"""``Path=/hermes`` under a proxy prefix (so the browser sends the cookie
|
|
back under the prefix and it does not leak to sibling apps), else ``/``."""
|
|
return prefix if prefix else "/"
|
|
|
|
|
|
def _common_attrs(*, use_https: bool, prefix: str) -> dict:
|
|
attrs: dict = {
|
|
"httponly": True,
|
|
"samesite": "lax",
|
|
"path": _cookie_path(prefix),
|
|
}
|
|
if use_https:
|
|
attrs["secure"] = True
|
|
return attrs
|
|
|
|
|
|
def _pkce_attrs(*, use_https: bool, prefix: str) -> dict:
|
|
"""Attributes shared by the PKCE set AND clear paths (a shape mismatch
|
|
means the browser silently keeps the stale cookie)."""
|
|
attrs = _common_attrs(use_https=use_https, prefix=prefix)
|
|
if use_https:
|
|
attrs["samesite"] = "none"
|
|
return attrs
|
|
|
|
|
|
def _set(response: Response, bare: str, value: str, *, max_age: int,
|
|
use_https: bool, prefix: str, attrs: dict | None = None) -> None:
|
|
response.set_cookie(
|
|
_resolved_name(bare, use_https=use_https, prefix=prefix),
|
|
value,
|
|
max_age=max_age,
|
|
**(attrs if attrs is not None else _common_attrs(use_https=use_https, prefix=prefix)),
|
|
)
|
|
|
|
|
|
def set_session_provider_cookie(
|
|
response: Response,
|
|
*,
|
|
provider: str,
|
|
use_https: bool,
|
|
prefix: str = "",
|
|
) -> None:
|
|
"""Persist the non-secret provider routing hint for token refresh."""
|
|
if not provider:
|
|
return
|
|
_set(response, SESSION_PROVIDER_COOKIE, provider, max_age=_RT_MAX_AGE,
|
|
use_https=use_https, prefix=prefix)
|
|
|
|
|
|
def set_session_cookies(
|
|
response: Response,
|
|
*,
|
|
access_token: str,
|
|
refresh_token: str,
|
|
access_token_expires_in: int,
|
|
use_https: bool,
|
|
prefix: str = "",
|
|
provider: str = "",
|
|
) -> None:
|
|
"""Set the session cookies.
|
|
|
|
``access_token_expires_in`` is seconds (the provider's reported TTL). An
|
|
empty ``refresh_token`` means "don't persist the RT cookie" — a literal
|
|
empty cookie would be dead state at best, attack surface at worst.
|
|
``prefix`` is the normalised X-Forwarded-Prefix or ``""``.
|
|
"""
|
|
_set(response, SESSION_AT_COOKIE, access_token, max_age=access_token_expires_in,
|
|
use_https=use_https, prefix=prefix)
|
|
if refresh_token:
|
|
_set(response, SESSION_RT_COOKIE, refresh_token, max_age=_RT_MAX_AGE,
|
|
use_https=use_https, prefix=prefix)
|
|
set_session_provider_cookie(
|
|
response, provider=provider, use_https=use_https, prefix=prefix,
|
|
)
|
|
|
|
|
|
def _clear_cookie_variants(
|
|
response: Response,
|
|
bare_name: str,
|
|
*,
|
|
prefix: str,
|
|
https_samesite: Literal["lax", "strict", "none"],
|
|
bare_attrs: dict,
|
|
) -> None:
|
|
"""Emit Max-Age=0 deletions for every plausible name variant of a cookie.
|
|
|
|
We don't know which variant the setter used (it depends on the setting
|
|
request's shape). Prefixed names are rejected by the browser unless they
|
|
carry ``Secure`` (``__Host-`` additionally needs ``Path=/``), so those
|
|
deletions always carry them; the bare deletion mirrors the setter's shape
|
|
(``bare_attrs``), which works on both HTTP and HTTPS origins.
|
|
"""
|
|
for variant in _NAME_VARIANTS:
|
|
if variant == "__Host-":
|
|
response.set_cookie(
|
|
f"{variant}{bare_name}", "", max_age=0,
|
|
path="/", httponly=True, samesite=https_samesite,
|
|
secure=True,
|
|
)
|
|
elif variant == "__Secure-":
|
|
response.set_cookie(
|
|
f"{variant}{bare_name}", "", max_age=0,
|
|
path=_cookie_path(prefix), httponly=True,
|
|
samesite=https_samesite, secure=True,
|
|
)
|
|
else:
|
|
response.set_cookie(
|
|
bare_name, "", max_age=0, **bare_attrs,
|
|
)
|
|
|
|
|
|
def _lax_bare_attrs(prefix: str) -> dict:
|
|
return {"path": _cookie_path(prefix), "httponly": True, "samesite": "lax"}
|
|
|
|
|
|
def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
|
|
"""Delete the AT, RT and provider cookies (every name variant, active path)."""
|
|
bare_attrs = _lax_bare_attrs(prefix)
|
|
for name in (SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE):
|
|
_clear_cookie_variants(
|
|
response, name,
|
|
prefix=prefix, https_samesite="lax", bare_attrs=bare_attrs,
|
|
)
|
|
|
|
|
|
def encode_pkce_payload(parts: dict[str, str]) -> str:
|
|
"""Serialise PKCE segments to the wire value ``base64url(JSON)``, no padding.
|
|
|
|
The urlsafe alphabet is a strict subset of RFC 6265 cookie-octets (no
|
|
``;``, ``"``, ``\\``), so http.cookies never quotes it (strict proxy hops
|
|
such as Go net/http reject the quoted form) and no segment value can
|
|
collide with a delimiter. Padding ``=`` would trigger quoting; the parser
|
|
restores it.
|
|
"""
|
|
raw = json.dumps(parts, separators=(",", ":"), sort_keys=True)
|
|
return (
|
|
base64.urlsafe_b64encode(raw.encode("utf-8"))
|
|
.decode("ascii")
|
|
.rstrip("=")
|
|
)
|
|
|
|
|
|
def set_pkce_cookie(
|
|
response: Response,
|
|
*,
|
|
payload: dict[str, str],
|
|
use_https: bool,
|
|
prefix: str = "",
|
|
) -> None:
|
|
"""Set the PKCE cookie (``payload`` is the segment dict; see module docstring
|
|
for the SameSite=None rationale)."""
|
|
_set(response, PKCE_COOKIE, encode_pkce_payload(payload), max_age=_PKCE_MAX_AGE,
|
|
use_https=use_https, prefix=prefix,
|
|
attrs=_pkce_attrs(use_https=use_https, prefix=prefix))
|
|
|
|
|
|
def clear_pkce_cookie(
|
|
response: Response, *, use_https: bool, prefix: str = "",
|
|
) -> None:
|
|
"""Delete every PKCE cookie variant; the bare deletion mirrors the setter's
|
|
shape for the active origin, the prefixed ones carry ``Secure; SameSite=None``."""
|
|
_clear_cookie_variants(
|
|
response, PKCE_COOKIE,
|
|
prefix=prefix, https_samesite="none",
|
|
bare_attrs=_pkce_attrs(use_https=use_https, prefix=prefix),
|
|
)
|
|
|
|
|
|
def _read_with_fallback(
|
|
request: Request, bare_name: str,
|
|
) -> Optional[str]:
|
|
"""Read a cookie trying every prefix variant (the reading request may not
|
|
have the same shape as the one that set it)."""
|
|
for variant in _NAME_VARIANTS:
|
|
value = request.cookies.get(f"{variant}{bare_name}")
|
|
if value is not None:
|
|
return value
|
|
return None
|
|
|
|
|
|
def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]]:
|
|
"""Returns (access_token, refresh_token), either may be None."""
|
|
return (
|
|
_read_with_fallback(request, SESSION_AT_COOKIE),
|
|
_read_with_fallback(request, SESSION_RT_COOKIE),
|
|
)
|
|
|
|
|
|
def read_session_provider(request: Request) -> Optional[str]:
|
|
"""Return the provider routing hint associated with the session cookies."""
|
|
return _read_with_fallback(request, SESSION_PROVIDER_COOKIE)
|
|
|
|
|
|
def read_pkce_cookie(request: Request) -> Optional[str]:
|
|
return _read_with_fallback(request, PKCE_COOKIE)
|
|
|
|
|
|
# Cheap pre-filter: legacy wire forms always contain ``%`` or ``;``, which are
|
|
# outside the base64url alphabet, so they never reach the base64 decoder.
|
|
_B64URL_RE = re.compile(r"^[A-Za-z0-9_-]+={0,2}$")
|
|
|
|
|
|
def parse_pkce_payload(raw: str) -> dict[str, str]:
|
|
"""Decode a PKCE cookie value into its segment dict (inverse of
|
|
:func:`encode_pkce_payload`). EVERY reader must go through this — a reader
|
|
that interprets the raw wire value parses zero segments and silently
|
|
disables the check it feeds (provider dispatch, CSRF state, broker binding).
|
|
|
|
Compatibility ladder for cookies minted by an older server during a rolling
|
|
upgrade (10-minute TTL), each rung unambiguous:
|
|
1. base64url(JSON) — current.
|
|
2. Oldest flat form with raw ``;`` delimiters — split WITHOUT unquoting
|
|
(the ``next`` segment carries its own URL-encoding; unquoting first
|
|
would turn a ``%3B`` inside it into a bogus delimiter).
|
|
3. URL-encoded flat form (``quote(payload, safe="")``) — unquote once, split.
|
|
A NEW cookie hitting an OLD server fails the state check and the user
|
|
simply retries; nothing is minted.
|
|
"""
|
|
if _B64URL_RE.match(raw):
|
|
try:
|
|
padded = raw + "=" * (-len(raw) % 4)
|
|
decoded = json.loads(
|
|
base64.urlsafe_b64decode(padded.encode("ascii"))
|
|
)
|
|
except (binascii.Error, ValueError, UnicodeDecodeError):
|
|
decoded = None
|
|
if isinstance(decoded, dict):
|
|
return {str(k): str(v) for k, v in decoded.items()}
|
|
flat = raw if ";" in raw else unquote(raw)
|
|
return dict(seg.split("=", 1) for seg in flat.split(";") if "=" in seg)
|
|
|
|
|
|
def set_sso_attempt_cookie(
|
|
response: Response, *, use_https: bool, prefix: str = "",
|
|
) -> None:
|
|
"""Set the auto-SSO loop-guard marker; only its presence matters."""
|
|
_set(response, SSO_ATTEMPT_COOKIE, "1", max_age=_SSO_ATTEMPT_MAX_AGE,
|
|
use_https=use_https, prefix=prefix)
|
|
|
|
|
|
def read_sso_attempt_cookie(request: Request) -> Optional[str]:
|
|
"""Return the auto-SSO marker value if present (any variant), else None."""
|
|
return _read_with_fallback(request, SSO_ATTEMPT_COOKIE)
|
|
|
|
|
|
def clear_sso_attempt_cookie(response: Response, *, prefix: str = "") -> None:
|
|
"""Delete the auto-SSO marker (every variant) so it never suppresses a
|
|
later silent attempt."""
|
|
_clear_cookie_variants(
|
|
response, SSO_ATTEMPT_COOKIE,
|
|
prefix=prefix, https_samesite="lax", bare_attrs=_lax_bare_attrs(prefix),
|
|
)
|
|
|
|
|
|
def detect_https(request: Request) -> bool:
|
|
"""``Secure`` flag decision: ``request.url.scheme == "https"`` (honours
|
|
``X-Forwarded-Proto`` under uvicorn ``proxy_headers=True``)."""
|
|
return request.url.scheme == "https"
|