Files
aiturk-hermes-ide/hermes_cli/dashboard_auth/cookies.py
T

526 lines
22 KiB
Python

"""Cookie helpers for dashboard auth.
Three cookies in play:
- hermes_session_at: the OAuth access token
(HttpOnly, lifetime = token TTL, ~15 min)
- hermes_session_rt: the OAuth refresh token
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
Nous Portal issues a rotating refresh token for the
dashboard auth-code grant (Portal NAS #293 / hermes
#37247). ``set_session_cookies`` writes this cookie
whenever the provider returns a non-empty
``refresh_token``; the middleware uses it to rotate a
fresh access token transparently on AT expiry. A
provider that omits the refresh token (empty string)
degrades gracefully to access-token-only sessions —
the RT cookie is simply not written.
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
hint (HttpOnly, lifetime = 10 minutes)
The two session cookies are ``SameSite=Lax`` and live under the prefix's
Path. The PKCE cookie is the exception: ``SameSite=None`` over HTTPS,
falling back to ``Lax`` on plain HTTP (where ``SameSite=None`` is invalid
without ``Secure``). It is set on the ``/auth/login`` 302 and must survive
the cross-site redirect chain out to the IDP and back to
``/auth/callback``; Chromium intermittently drops ``Lax`` cookies set on a
302 in such a chain (crbug 40508226), which surfaces as "Missing PKCE
state cookie". ``Secure`` is set ONLY when the dashboard was reached over
HTTPS — detected via the request URL scheme, which honours
``X-Forwarded-Proto`` upstream of Fly's TLS terminator when uvicorn is
configured with ``proxy_headers=True``. Loopback dev traffic is always
HTTP so ``Secure`` would lock the cookies out of the browser.
NOTE: uvicorn only honours ``X-Forwarded-Proto`` from a peer inside its
``forwarded_allow_ips`` (default: ``127.0.0.1``). A TLS terminator that
reaches the dashboard from a non-loopback address — e.g. a reverse proxy
in its own container — is not trusted, so the request still looks like
HTTP here and these cookies are written in their HTTP shape.
Cookie prefix selection (browser hardening per
https://datatracker.ietf.org/doc/html/draft-west-cookie-prefixes):
* Loopback HTTP — bare name. ``__Host-`` / ``__Secure-`` require
``Secure``, which is incompatible with HTTP.
* Gated HTTPS, direct deploy (Path=/) — ``__Host-`` prefix. Binds the
cookie to the exact origin (no Domain attribute) — strongest spec
guarantee.
* Gated HTTPS, behind a reverse-proxy prefix (Path=/hermes) —
``__Secure-`` prefix. ``__Host-`` is disallowed when Path != "/";
``__Secure-`` keeps the Secure-required hardening without the
Path constraint, and the explicit ``Path=/hermes`` covers
same-origin app isolation.
The setters and readers BOTH consult the active prefix because the
cookie *name* changes — a reader that looked up the bare name when the
setter wrote ``__Secure-hermes_session_at`` would never find the value.
Refresh-token handling:
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
it) and silently skips writing the RT cookie in that case, so a
refresh-token-less provider degrades to access-token-only sessions.
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
cookie on logout / session expiry so a stale cookie from an earlier
deployment gets cleared. The transparent rotation flow ("expired AT +
live RT → rotate server-side, else 401 → /login") lives in
``middleware._attempt_refresh``.
"""
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
# Bare cookie names — the request-scoped ``_resolved_name`` helper
# decides whether to prepend ``__Host-`` / ``__Secure-`` based on the
# request's HTTPS + prefix combination.
SESSION_AT_COOKIE = "hermes_session_at"
SESSION_RT_COOKIE = "hermes_session_rt"
# Provider that minted the session. This non-secret routing hint prevents a
# refresh token from being handed to the wrong provider when several dashboard
# auth plugins are enabled (for example Basic + Nous OAuth).
SESSION_PROVIDER_COOKIE = "hermes_session_provider"
PKCE_COOKIE = "hermes_session_pkce"
# One-shot loop-guard marker for the auto-SSO redirect (Phase 1,
# cloud-auto-discovery). Set when the gate auto-initiates the portal OAuth
# redirect on an unauthenticated document load; its mere PRESENCE on the next
# unauthenticated load tells the gate "we already bounced once" so a genuinely
# absent portal session degrades to the /login page instead of ping-ponging.
# Carries no secret — it's a boolean breadcrumb — but is set HttpOnly/Lax/Secure
# like the others for consistency. Short TTL so a user who returns later gets a
# fresh silent attempt rather than a permanently-disabled one.
SSO_ATTEMPT_COOKIE = "hermes_sso_attempt"
# Possible name variants we may have to read back. Sorted so most-strict
# wins on iteration when both happen to be present (shouldn't happen in
# practice — a single request emits exactly one variant).
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
# real authority — once the RT itself expires/rotates out, a refresh attempt
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
# lifetime to a server-side TTL that can change independently; revisit if the
# stale-cookie refresh churn ever matters.)
_RT_MAX_AGE = 30 * 24 * 60 * 60
_PKCE_MAX_AGE = 10 * 60
# Auto-SSO loop-guard marker TTL. Just long enough to cover one redirect
# round trip to the portal and back (a few seconds in practice); kept at 60s
# so a slow portal hop or a manual back-button still trips the guard, while a
# user returning minutes later gets a fresh silent attempt rather than being
# stuck on /login forever. The marker is also cleared explicitly on a
# successful callback and whenever the gate falls back to /login.
_SSO_ATTEMPT_MAX_AGE = 60
def _resolved_name(bare: str, *, use_https: bool, prefix: str) -> str:
"""Pick the cookie-prefix variant for the active request shape.
See module docstring for the prefix selection rules. Mismatch
between setter and reader would silently break sessions, so this
function is the single source of truth for naming.
"""
if not use_https:
return bare
if prefix:
# Path != "/" forbids __Host-; fall back to __Secure-.
return f"__Secure-{bare}"
return f"__Host-{bare}"
def _cookie_path(prefix: str) -> str:
"""Cookie ``Path`` attribute for the active deploy shape.
Under ``X-Forwarded-Prefix: /hermes`` we want ``Path=/hermes`` so:
a) the browser sends the cookie back on requests under the prefix
(browsers omit the cookie if request path doesn't start with
Path);
b) the cookie doesn't leak to other apps on the same origin
(``mission-control.tilos.com/billing/...``).
Direct-deploy (no proxy prefix) gets ``Path=/``.
"""
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 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
response.set_cookie(
_resolved_name(SESSION_PROVIDER_COOKIE, use_https=use_https, prefix=prefix),
provider,
max_age=_RT_MAX_AGE,
**_common_attrs(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 on the response.
``access_token_expires_in`` is in seconds. Use the provider's reported
TTL for the access token.
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
issues a 24h rotating refresh token (hermes #37247); a provider that
omits it returns ``Session.refresh_token == ""`` and we simply don't
persist the RT cookie — the session then behaves as access-token-only
until the AT expires. No other branch changes between the two cases.
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
or ``""`` for a direct deploy. It influences both the cookie name
(``__Host-`` vs ``__Secure-`` vs bare) and the ``Path`` attribute.
"""
response.set_cookie(
_resolved_name(SESSION_AT_COOKIE, use_https=use_https, prefix=prefix),
access_token,
max_age=access_token_expires_in,
**_common_attrs(use_https=use_https, prefix=prefix),
)
# Contract v1: empty refresh token means "don't persist RT cookie".
# Keeping a literal empty-value cookie around would be dead state at
# best, attack surface at worst.
if refresh_token:
response.set_cookie(
_resolved_name(SESSION_RT_COOKIE, use_https=use_https, prefix=prefix),
refresh_token,
max_age=_RT_MAX_AGE,
**_common_attrs(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.
Cookie-prefix rules make the deletion shape load-bearing: a Set-Cookie
for a ``__Host-``/``__Secure-`` name is rejected outright by the
browser unless it carries ``Secure`` (and ``__Host-`` additionally
requires ``Path=/``), so those deletions always carry the attributes
their name demands. The bare-name deletion mirrors the shape the
setter uses (``bare_attrs``) — under RFC 6265bis a deletion sent from
a secure origin may omit ``Secure`` and still delete a Secure cookie,
while a ``Secure`` deletion on a plain-HTTP origin can be ignored, so
matching the setter is the shape that works on both origins.
"""
for variant in _NAME_VARIANTS:
if variant == "__Host-":
# __Host- demands Secure AND Path=/ or the header is invalid.
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 clear_session_cookies(response: Response, *, prefix: str = "") -> None:
"""Emit Max-Age=0 deletions for both session cookies.
To delete a cookie reliably the deletion's ``Path`` must match the
set path AND the cookie name must match the variant the setter used.
We don't know which variant was originally set (cookie prefix
depends on the request that set it), so we emit deletions for every
plausible variant under the active path.
"""
bare_attrs = {
"path": _cookie_path(prefix), "httponly": True, "samesite": "lax",
}
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 _pkce_attrs(*, use_https: bool, prefix: str) -> dict:
"""Cookie attributes for the PKCE cookie's set AND clear paths.
Single source of truth so a deletion always matches the shape the
setter emitted for the same origin — 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 encode_pkce_payload(parts: dict[str, str]) -> str:
"""Serialise PKCE segments to the wire value: ``base64url(JSON)``.
The urlsafe base64 alphabet (``A-Za-z0-9-_``, padding stripped) is a
strict subset of the RFC 6265 cookie-octet set — no ``;`` (attribute
terminator), no ``"`` and no ``\\`` (the chars that make Python's
http.cookies emit the quoted ``\\073`` form, which strict cookie-aware
proxy hops such as Go's net/http reject outright). The ``=`` padding
is stripped because http.cookies treats ``=`` as outside its legal
unquoted set and would re-wrap the value in the quoted form this
codec exists to avoid; the parser restores the padding. JSON carries
the segments, so no delimiter can ever collide with segment values —
the delimiter/quoting bug class this codec replaces (see
:func:`parse_pkce_payload` for the two legacy formats it superseded).
"""
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:
# SameSite=None when HTTPS: the PKCE cookie is set on the /auth/login
# 302 response (redirecting to the IDP) and must survive the cross-site
# redirect chain (same-site → IDP → same-site callback). Chromium has a
# long-standing bug (crbug 40508226) where SameSite=Lax cookies set on a
# 302 in a cross-site redirect chain are intermittently dropped, causing
# "Missing PKCE state cookie" on the callback. SameSite=None + Secure
# sidesteps the bug — these cookies are explicitly designed for cross-site
# delivery and Chromium processes them reliably during redirects.
# Loopback HTTP degrades to Lax (SameSite=None requires Secure).
#
# Value encoding: ``payload`` is the segment dict
# (``{"provider": …, "state": …, "verifier": …, "next": …}``) and goes
# on the wire as base64url(JSON) via encode_pkce_payload() — plain
# RFC 6265 cookie-octets end to end, so every cookie-aware hop
# (browsers, Go net/http proxies, Python parsers) passes the value
# through untouched. Readers decode via parse_pkce_payload(), which
# also keeps a compatibility ladder for cookies minted by the two
# earlier wire formats during a rolling upgrade.
response.set_cookie(
_resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix),
encode_pkce_payload(payload),
max_age=_PKCE_MAX_AGE,
**_pkce_attrs(use_https=use_https, prefix=prefix),
)
def clear_pkce_cookie(
response: Response, *, use_https: bool, prefix: str = "",
) -> None:
"""Emit Max-Age=0 deletions for every plausible PKCE cookie variant.
A deletion is only honoured when its shape is acceptable to the
browser on the current origin: a ``Secure`` deletion can be dropped
on a plain-HTTP origin, while the ``__Host-``/``__Secure-`` name
variants REQUIRE ``Secure`` to be valid at all. So the bare-name
deletion mirrors the setter's shape for the active origin (Lax
without ``Secure`` over HTTP; ``SameSite=None; Secure`` over HTTPS,
matching :func:`set_pkce_cookie`), and the prefixed variants — which
can only ever have been set on an HTTPS origin — always 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 by checking every prefix variant in order.
The setter chooses one variant based on the active request shape;
the reader doesn't know which one fired (the request that READS
the cookie may not be the same shape as the request that SET it
in pathological cases). Trying all three guarantees we find 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."""
at = _read_with_fallback(request, SESSION_AT_COOKIE)
rt = _read_with_fallback(request, SESSION_RT_COOKIE)
return at, rt
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)
# base64url wire values are exactly the urlsafe alphabet (padding is
# stripped by the encoder; the decoder restores it). Used as a cheap
# pre-filter before attempting the JSON decode so legacy wire forms
# (which always contain ``%`` or ``;``) never even 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 + parse a PKCE cookie value into its segment dict.
Single inverse of :func:`set_pkce_cookie` /
:func:`encode_pkce_payload`. EVERY reader of the PKCE cookie must go
through this helper — a reader that interprets the raw wire value
itself parses zero segments and silently disables whatever check it
was feeding (provider dispatch, CSRF state, native-flow broker
binding).
Compatibility ladder — the PKCE cookie has a 10-minute TTL and is
opaque + server-set, so during a rolling upgrade a cookie minted by
one server version can arrive at another. Three formats, tried in
order; each rung is unambiguous:
1. **base64url(JSON)** (current): the wire value is pure urlsafe
base64 that decodes to a JSON object. Legacy forms can never
match — they always contain ``%`` (URL-encoded, #99176) or a raw
``;`` (oldest flat form), both outside the base64url alphabet.
2. **Oldest flat form** (pre-#99176): raw ``;`` between segments
(``provider=…;state=…;verifier=…``). Split as-is WITHOUT
unquoting the payload — the ``next`` segment carries its own
single URL-encoding, and unquoting here would turn a ``%3B``
inside it into a bogus delimiter and truncate the post-login
target. Neither newer format can contain a raw ``;``.
3. **URL-encoded flat form** (#99176): the whole flat payload passed
through ``quote(payload, safe="")`` — no raw ``;`` possible
(it is ``%3B``); unquote once, then split.
Rollout directions: OLD cookie → NEW server is handled here (rungs
2 and 3 parse both legacy forms correctly). NEW cookie → OLD server
(a rollback, or a mixed fleet routing the callback to a not-yet-
upgraded instance) fails the OAuth state check — the old reader
can't find a ``state`` segment in the base64url blob — and the user
simply retries login against the now-consistent fleet; no data loss,
nothing 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()}
if ";" in raw:
# Oldest flat form: already flat, split as-is (no unquote).
return dict(
seg.split("=", 1) for seg in raw.split(";") if "=" in seg
)
# #99176 URL-encoded flat form: unquote once, then split.
return dict(
seg.split("=", 1) for seg in unquote(raw).split(";") if "=" in seg
)
def set_sso_attempt_cookie(
response: Response, *, use_https: bool, prefix: str = "",
) -> None:
"""Set the one-shot auto-SSO loop-guard marker (Phase 1).
Written by the gate the moment it auto-initiates the portal OAuth
redirect on an unauthenticated document load. The value is a constant
(``"1"``) — only its presence matters. Short Max-Age so a stale marker
can't permanently suppress a future silent attempt.
"""
response.set_cookie(
_resolved_name(SSO_ATTEMPT_COOKIE, use_https=use_https, prefix=prefix),
"1",
max_age=_SSO_ATTEMPT_MAX_AGE,
**_common_attrs(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:
"""Emit Max-Age=0 deletions for the auto-SSO marker, every name variant.
Called on a successful callback and whenever the gate falls back to
/login, so the marker never lingers to suppress a later silent attempt.
"""
_clear_cookie_variants(
response, SSO_ATTEMPT_COOKIE,
prefix=prefix, https_samesite="lax",
bare_attrs={
"path": _cookie_path(prefix), "httponly": True, "samesite": "lax",
},
)
def detect_https(request: Request) -> bool:
"""Decide whether to set the ``Secure`` cookie flag.
Reads ``request.url.scheme`` — under uvicorn's ``proxy_headers=True``
(which start_server enables when the gate is active), this honours
``X-Forwarded-Proto`` from Fly's TLS terminator. Loopback traffic is
always HTTP so this returns False there.
"""
return request.url.scheme == "https"