Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,525 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user