531 lines
22 KiB
Python
531 lines
22 KiB
Python
"""Tests for the dashboard-auth cookie helpers."""
|
|
from __future__ import annotations
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.responses import Response
|
|
from fastapi.testclient import TestClient
|
|
from starlette.requests import Request
|
|
|
|
from hermes_cli.dashboard_auth.cookies import (
|
|
PKCE_COOKIE,
|
|
SESSION_AT_COOKIE,
|
|
SESSION_PROVIDER_COOKIE,
|
|
SESSION_RT_COOKIE,
|
|
clear_pkce_cookie,
|
|
clear_session_cookies,
|
|
read_pkce_cookie,
|
|
read_session_cookies,
|
|
read_session_provider,
|
|
set_pkce_cookie,
|
|
set_session_cookies,
|
|
)
|
|
|
|
|
|
def _build_app(use_https: bool = True, prefix: str = ""):
|
|
app = FastAPI()
|
|
|
|
@app.get("/set")
|
|
def set_endpoint():
|
|
r = Response("ok")
|
|
set_session_cookies(
|
|
r, access_token="AT", refresh_token="RT",
|
|
access_token_expires_in=3600, use_https=use_https,
|
|
prefix=prefix, provider="nous",
|
|
)
|
|
return r
|
|
|
|
@app.get("/set-pkce")
|
|
def set_pkce():
|
|
r = Response("ok")
|
|
set_pkce_cookie(
|
|
r,
|
|
payload={"provider": "stub", "state": "s", "verifier": "v"},
|
|
use_https=use_https, prefix=prefix,
|
|
)
|
|
return r
|
|
|
|
@app.get("/clear")
|
|
def clear():
|
|
r = Response("ok")
|
|
clear_session_cookies(r, prefix=prefix)
|
|
clear_pkce_cookie(r, use_https=use_https, prefix=prefix)
|
|
return r
|
|
|
|
return app
|
|
|
|
|
|
# Cookie name resolution helpers used throughout — the bare name resolves
|
|
# to a request-shape-dependent variant (__Host- / __Secure- / bare).
|
|
# Tests pin a specific shape so a regression in the name-resolution
|
|
# logic fails loudly rather than silently breaking sessions.
|
|
|
|
|
|
def test_session_cookies_use_host_prefix_on_https_direct():
|
|
"""HTTPS + no proxy prefix → __Host- prefix (strongest spec
|
|
hardening: bound to exact origin, requires Path=/, requires Secure)."""
|
|
client = TestClient(_build_app(use_https=True, prefix=""))
|
|
r = client.get("/set")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
at = next(c for c in cookies if c.startswith(f"__Host-{SESSION_AT_COOKIE}="))
|
|
rt = next(c for c in cookies if c.startswith(f"__Host-{SESSION_RT_COOKIE}="))
|
|
provider = next(c for c in cookies if c.startswith(f"__Host-{SESSION_PROVIDER_COOKIE}=nous"))
|
|
for c in (at, rt, provider):
|
|
assert "HttpOnly" in c
|
|
assert "samesite=lax" in c.lower()
|
|
assert "Secure" in c
|
|
assert "Path=/" in c
|
|
|
|
|
|
def test_session_cookies_use_secure_prefix_when_proxied():
|
|
"""HTTPS + /hermes prefix → __Secure- prefix (__Host- forbids
|
|
Path != "/"; __Secure- keeps the Secure-required hardening)."""
|
|
client = TestClient(_build_app(use_https=True, prefix="/hermes"))
|
|
r = client.get("/set")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
at = next(c for c in cookies if c.startswith(f"__Secure-{SESSION_AT_COOKIE}="))
|
|
assert "Path=/hermes" in at
|
|
assert "Secure" in at
|
|
# __Host- variant must NOT be emitted on the prefix path.
|
|
assert not any(
|
|
c.startswith(f"__Host-{SESSION_AT_COOKIE}=") for c in cookies
|
|
)
|
|
|
|
|
|
def test_session_cookies_use_bare_name_on_http():
|
|
"""Loopback HTTP dev: __Host- / __Secure- both require Secure, which
|
|
we can't set on HTTP. Use bare cookie names."""
|
|
client = TestClient(_build_app(use_https=False))
|
|
r = client.get("/set")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
# Bare name present; no __Host- / __Secure- variant emitted.
|
|
assert any(c.startswith(f"{SESSION_AT_COOKIE}=") for c in cookies)
|
|
assert not any(
|
|
c.startswith(f"__Host-{SESSION_AT_COOKIE}=")
|
|
or c.startswith(f"__Secure-{SESSION_AT_COOKIE}=")
|
|
for c in cookies
|
|
)
|
|
# No Secure flag (HTTP).
|
|
at = next(c for c in cookies if c.startswith(f"{SESSION_AT_COOKIE}="))
|
|
assert "; Secure" not in at
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_session_cookies_from_request_secure_prefix():
|
|
"""Reader also finds cookies set with the __Secure- variant
|
|
(HTTPS behind a proxy prefix)."""
|
|
scope = {
|
|
"type": "http",
|
|
"method": "GET",
|
|
"path": "/",
|
|
"headers": [(
|
|
b"cookie",
|
|
f"__Secure-{SESSION_AT_COOKIE}=at_value; "
|
|
f"__Secure-{SESSION_RT_COOKIE}=rt_value".encode(),
|
|
)],
|
|
}
|
|
req = Request(scope)
|
|
at, rt = read_session_cookies(req)
|
|
assert at == "at_value"
|
|
assert rt == "rt_value"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PKCE cookie codec: base64url(JSON) wire format
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# History (three serialization fixes at this exact spot): the payload was
|
|
# originally a flat ``key=value;key=value`` string. A raw ``;`` is a
|
|
# cookie-attribute terminator, so Python's http.cookies emitted the value
|
|
# in RFC 6265 quoted form with each ``;`` escaped as ``\073`` — a form
|
|
# strict cookie-aware proxy hops (verified for Go's net/http) reject,
|
|
# dropping the cookie entirely (#83832, Traefik+Authentik field case).
|
|
# #99176 URL-encoded the whole flat payload to stay inside the
|
|
# cookie-octet set. The current codec removes the delimiter problem at
|
|
# the root: the payload is a dict, serialised as base64url(JSON) — the
|
|
# urlsafe alphabet is a strict subset of the cookie-octets, and JSON
|
|
# means no segment value can ever collide with a delimiter. Readers keep
|
|
# a compatibility ladder for both legacy wire forms (10-minute TTL,
|
|
# rolling upgrades). These tests pin the wire shape, the round trip, and
|
|
# every ladder rung.
|
|
|
|
|
|
def test_set_pkce_cookie_wire_value_is_cookie_octet_base64url_json():
|
|
"""The wire-level cookie value must contain only plain RFC 6265
|
|
cookie-octets: no raw ``;`` (attribute terminator), no ``"`` and no
|
|
``\\`` (the http.cookies quoted form that strict cookie-aware proxy
|
|
parsers — verified for Go's net/http — reject, dropping the whole
|
|
cookie). With base64url(JSON) the value is drawn from the urlsafe
|
|
base64 alphabet, a strict subset of the cookie-octet set.
|
|
|
|
Regression lineage: #83832 / the Traefik+Authentik support case —
|
|
the callback failed with "Missing PKCE state cookie" because a
|
|
proxy hop dropped the quoted ``\\073`` form.
|
|
"""
|
|
import base64
|
|
import json
|
|
|
|
client = TestClient(_build_app(use_https=True, prefix=""))
|
|
r = client.get("/set-pkce")
|
|
pkce_set = next(
|
|
c for c in r.headers.get_list("set-cookie")
|
|
if c.startswith(f"__Host-{PKCE_COOKIE}=")
|
|
)
|
|
# Take just the cookie name=value pair, ignore the attributes.
|
|
pkce_value = pkce_set.split(";", 1)[0]
|
|
wire = pkce_value.split("=", 1)[1]
|
|
# No unquoted literal ``;`` in the value (attribute terminator).
|
|
assert ";" not in wire, (
|
|
f"unquoted ; leaked into the cookie value: {pkce_value!r}"
|
|
)
|
|
# The real field failure (Traefik/Authentik): the http.cookies quoted
|
|
# form ``"...\073..."`` is not made of cookie-octets, and Go's
|
|
# net/http drops any cookie whose value contains ``"`` or ``\``.
|
|
# Pin the whole value to the plain RFC 6265 cookie-octet set.
|
|
assert '"' not in wire and "\\" not in wire, (
|
|
f"non-cookie-octet chars leaked into the wire value: {wire!r}"
|
|
)
|
|
cookie_octets = (
|
|
"!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
|
"[]^_`abcdefghijklmnopqrstuvwxyz{|}~"
|
|
)
|
|
assert all(ch in cookie_octets for ch in wire), (
|
|
f"non-cookie-octet chars in the wire value: {wire!r}"
|
|
)
|
|
# And tighter than cookie-octets: pure urlsafe base64 (padding is
|
|
# stripped by the encoder — ``=`` is outside http.cookies' legal
|
|
# unquoted set and would trigger the quoted form).
|
|
b64url = (
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
"0123456789-_"
|
|
)
|
|
assert all(ch in b64url for ch in wire), (
|
|
f"non-base64url chars in the wire value: {wire!r}"
|
|
)
|
|
# Round-trip the codec back to the original segment dict.
|
|
decoded = json.loads(
|
|
base64.urlsafe_b64decode(wire + "=" * (-len(wire) % 4))
|
|
)
|
|
assert decoded == {"provider": "stub", "state": "s", "verifier": "v"}, (
|
|
f"base64url(JSON) payload didn't round-trip: got {decoded!r}"
|
|
)
|
|
|
|
|
|
def test_encode_parse_pkce_payload_round_trips_hostile_values():
|
|
"""The codec must round-trip segment values containing every char
|
|
that broke the two previous formats — ``;`` ``=`` ``"`` ``\\`` ``%``
|
|
— byte-for-byte. This is the bug class the JSON codec kills: with
|
|
delimiter-based formats, these bytes collide with the framing.
|
|
"""
|
|
from hermes_cli.dashboard_auth.cookies import (
|
|
encode_pkce_payload,
|
|
parse_pkce_payload,
|
|
)
|
|
|
|
payload = {
|
|
"provider": "stub",
|
|
"state": 's;t="a\\te"',
|
|
"verifier": "v=1%3B;x",
|
|
"next": "/sessions?x=a;b&project=foo%25",
|
|
}
|
|
assert parse_pkce_payload(encode_pkce_payload(payload)) == payload
|
|
|
|
|
|
def test_parse_pkce_payload_old_format_cookie_survives_rolling_upgrade():
|
|
"""Compat ladder rung 2 — oldest flat form (pre-#99176). Mixed-version
|
|
window (10-minute PKCE TTL): a cookie minted by a pre-encoding server
|
|
arrives at the new reader — after starlette's cookie-header
|
|
unquoting — as the FLAT form with raw ``;`` between segments and a
|
|
single-encoded ``next``. The reader must split it as-is, NOT
|
|
payload-decode it first: decoding early would turn an old ``next``
|
|
value containing ``%3B`` into a bogus delimiter and truncate the
|
|
post-login target.
|
|
"""
|
|
from hermes_cli.dashboard_auth.cookies import parse_pkce_payload
|
|
|
|
old = (
|
|
"provider=stub;state=s123;verifier=v456;"
|
|
"next=%2Fsessions%3Fx%3Da%3Bb%26project%3Dfoo"
|
|
)
|
|
parts = parse_pkce_payload(old)
|
|
assert parts == {
|
|
"provider": "stub",
|
|
"state": "s123",
|
|
"verifier": "v456",
|
|
# Preserved verbatim — still single-encoded, exactly what the
|
|
# old reader produced; the downstream next-validator unquotes.
|
|
"next": "%2Fsessions%3Fx%3Da%3Bb%26project%3Dfoo",
|
|
}, f"old-format cookie mis-parsed: {parts!r}"
|
|
|
|
|
|
def test_parse_pkce_payload_99176_url_encoded_format_survives_upgrade():
|
|
"""Compat ladder rung 3 — the #99176 URL-encoded flat form
|
|
(``quote(payload, safe='')`` over the whole flat string; no raw
|
|
``;`` possible — it is %3B). A cookie minted by a #99176-era server
|
|
during the 10-minute mixed-version window must decode to the exact
|
|
original segments: unquote once, then split.
|
|
"""
|
|
from urllib.parse import quote
|
|
|
|
from hermes_cli.dashboard_auth.cookies import parse_pkce_payload
|
|
|
|
payload = "provider=stub;state=s123;verifier=v456;next=%2Fsessions"
|
|
wire = quote(payload, safe="")
|
|
assert ";" not in wire
|
|
parts = parse_pkce_payload(wire)
|
|
assert parts == {
|
|
"provider": "stub",
|
|
"state": "s123",
|
|
"verifier": "v456",
|
|
"next": "%2Fsessions",
|
|
}, f"#99176-format wire value mis-parsed: {parts!r}"
|
|
|
|
|
|
def test_pkce_cookie_round_trip_preserves_all_segments():
|
|
"""End-to-end: the browser stores the Set-Cookie, the server
|
|
reads it back via ``read_pkce_cookie``, the OAuth callback
|
|
in routes.py decodes the URL-encoded value and parses every
|
|
segment. Pre-fix, the quoted ``\\073`` wire form was dropped
|
|
whole by strict proxy-hop cookie parsers, so the callback saw
|
|
no PKCE cookie at all.
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from conftest_dashboard_auth import StubAuthProvider # type: ignore
|
|
from hermes_cli import web_server
|
|
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
|
from hermes_cli.dashboard_auth.cookies import parse_pkce_payload
|
|
|
|
clear_providers()
|
|
register_provider(StubAuthProvider())
|
|
prev_host = getattr(web_server.app.state, "bound_host", None)
|
|
prev_port = getattr(web_server.app.state, "bound_port", None)
|
|
prev_required = getattr(web_server.app.state, "auth_required", None)
|
|
web_server.app.state.bound_host = "fly-app.fly.dev"
|
|
web_server.app.state.bound_port = 443
|
|
web_server.app.state.auth_required = True
|
|
try:
|
|
client = TestClient(
|
|
web_server.app, base_url="https://fly-app.fly.dev",
|
|
)
|
|
# /auth/login sets the PKCE cookie with provider / state / verifier
|
|
# packed by the login handler. Capture both the PKCE value and
|
|
# the state that the IDP saw.
|
|
r1 = client.get(
|
|
"/auth/login?provider=stub", follow_redirects=False,
|
|
)
|
|
assert r1.status_code == 302
|
|
pkce_set = next(
|
|
c for c in r1.headers.get_list("set-cookie")
|
|
if "hermes_session_pkce" in c
|
|
)
|
|
# Pull just the name=value portion so we can echo it back as
|
|
# a Cookie header.
|
|
pkce_kv = pkce_set.split(";", 1)[0]
|
|
# Decode through the real reader inverse: base64url(JSON).
|
|
encoded_value = pkce_kv.split("=", 1)[1]
|
|
parts = parse_pkce_payload(encoded_value)
|
|
# The login handler packs provider, state, and verifier
|
|
# into the payload. All three must survive intact.
|
|
assert parts.get("provider") == "stub"
|
|
assert parts.get("state")
|
|
assert parts.get("verifier")
|
|
# And the encoded wire value must NOT have a literal, unquoted ``;``
|
|
# between segments.
|
|
assert ";" not in encoded_value, (
|
|
f"literal ; in wire cookie value: {encoded_value!r}"
|
|
)
|
|
|
|
# Round-trip via /auth/callback — the success path confirms
|
|
# the callback decoded the URL-encoded value and matched
|
|
# the state. (302 to the post-login page = success.)
|
|
state = r1.headers["location"].split("state=")[1]
|
|
r2 = client.get(
|
|
f"/auth/callback?code=stub_code&state={state}",
|
|
headers={"cookie": pkce_kv},
|
|
follow_redirects=False,
|
|
)
|
|
assert r2.status_code == 302, (
|
|
f"OIDC callback failed — the PKCE cookie round trip is broken. "
|
|
f"Body: {r2.text!r}"
|
|
)
|
|
finally:
|
|
clear_providers()
|
|
web_server.app.state.bound_host = prev_host
|
|
web_server.app.state.bound_port = prev_port
|
|
web_server.app.state.auth_required = prev_required
|
|
|
|
|
|
def test_pkce_callback_works_when_next_query_includes_encoded_path():
|
|
"""The ``next=`` segment carries a URL-encoded path (e.g. a
|
|
relative URL containing ``;`` from a query parameter on the
|
|
post-login target). The setter URL-encodes the whole payload
|
|
(so the ``;`` in the next= value doesn't trip RFC 6265), and
|
|
the reader decodes the next= value back to its original form
|
|
for the redirect."""
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from conftest_dashboard_auth import StubAuthProvider # type: ignore
|
|
from hermes_cli import web_server
|
|
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
|
from urllib.parse import quote, unquote
|
|
|
|
clear_providers()
|
|
register_provider(StubAuthProvider())
|
|
prev_host = getattr(web_server.app.state, "bound_host", None)
|
|
prev_port = getattr(web_server.app.state, "bound_port", None)
|
|
prev_required = getattr(web_server.app.state, "auth_required", None)
|
|
web_server.app.state.bound_host = "fly-app.fly.dev"
|
|
web_server.app.state.bound_port = 443
|
|
web_server.app.state.auth_required = True
|
|
try:
|
|
client = TestClient(
|
|
web_server.app, base_url="https://fly-app.fly.dev",
|
|
)
|
|
# next= with a /sessions?view=recent&project=foo target.
|
|
# The login handler URL-encodes the next= value once, then
|
|
# the setter URL-encodes the whole payload. The reader
|
|
# decodes the payload back, and the routes callback then
|
|
# passes the next= through.
|
|
next_target = "/sessions?view=recent&project=foo"
|
|
r1 = client.get(
|
|
f"/auth/login?provider=stub&next={quote(next_target, safe='')}",
|
|
follow_redirects=False,
|
|
)
|
|
assert r1.status_code == 302
|
|
pkce_kv = next(
|
|
c for c in r1.headers.get_list("set-cookie")
|
|
if "hermes_session_pkce" in c
|
|
).split(";", 1)[0]
|
|
# Drive the callback — must succeed (302 to the post-login
|
|
# target, NOT a 400 "Missing PKCE state cookie").
|
|
state = r1.headers["location"].split("state=")[1]
|
|
r2 = client.get(
|
|
f"/auth/callback?code=stub_code&state={state}",
|
|
headers={"cookie": pkce_kv},
|
|
follow_redirects=False,
|
|
)
|
|
assert r2.status_code == 302, (
|
|
f"callback failed with next= present: {r2.text!r}"
|
|
)
|
|
# The post-login redirect carries EXACTLY the original target:
|
|
# login-side single-encode + setter whole-payload encode must be
|
|
# symmetrically undone by parse_pkce_payload + the validator's
|
|
# unquote. Pin the exact byte shape — a relaxed substring match
|
|
# would hide an encode/decode imbalance.
|
|
assert r2.headers.get("location") == next_target, (
|
|
f"post-login redirect didn't carry the exact next= target: "
|
|
f"{r2.headers.get('location')!r} != {next_target!r}"
|
|
)
|
|
finally:
|
|
clear_providers()
|
|
web_server.app.state.bound_host = prev_host
|
|
web_server.app.state.bound_port = prev_port
|
|
web_server.app.state.auth_required = prev_required
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# PKCE cookie set/clear contract — the OAuth round trip crosses sites, so the
|
|
# attribute shape (SameSite / Secure) is load-bearing, not cosmetic. These
|
|
# tests pin the full Set-Cookie header shape for both origins so a regression
|
|
# in either direction (cookie dropped by Chromium mid-redirect, or a stale
|
|
# cookie surviving a clear) fails loudly.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_pkce_cookie_https_is_samesite_none_secure():
|
|
"""HTTPS: the PKCE cookie must be SameSite=None + Secure.
|
|
|
|
The cookie is set on the /auth/login 302 and must survive the
|
|
cross-site redirect chain through the IDP back to /auth/callback.
|
|
Chromium intermittently drops SameSite=Lax cookies set on a 302 in a
|
|
cross-site chain (crbug 40508226); SameSite=None is the fix.
|
|
"""
|
|
client = TestClient(_build_app(use_https=True, prefix=""))
|
|
r = client.get("/set-pkce")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
pkce = next(c for c in cookies if c.startswith(f"__Host-{PKCE_COOKIE}="))
|
|
assert "samesite=none" in pkce.lower()
|
|
assert "; Secure" in pkce
|
|
assert "HttpOnly" in pkce
|
|
|
|
|
|
def test_pkce_cookie_http_stays_lax_without_secure():
|
|
"""Loopback HTTP dev: SameSite=None requires Secure, which HTTP can't
|
|
carry — so the setter degrades to bare-name Lax without Secure."""
|
|
client = TestClient(_build_app(use_https=False, prefix=""))
|
|
r = client.get("/set-pkce")
|
|
cookies = r.headers.get_list("set-cookie")
|
|
pkce = next(c for c in cookies if c.startswith(f"{PKCE_COOKIE}="))
|
|
assert "samesite=lax" in pkce.lower()
|
|
assert "; Secure" not in pkce
|
|
|
|
|
|
def test_clear_pkce_cookie_https_matches_set_shape():
|
|
"""HTTPS clear: every name variant is deleted with SameSite=None +
|
|
Secure — matching the HTTPS setter so the browser honours the
|
|
deletion for whichever variant was actually set."""
|
|
client = TestClient(_build_app(use_https=True, prefix=""))
|
|
cookies = client.get("/clear").headers.get_list("set-cookie")
|
|
for name in (f"__Host-{PKCE_COOKIE}", f"__Secure-{PKCE_COOKIE}", PKCE_COOKIE):
|
|
deletion = next(c for c in cookies if c.startswith(f'{name}="'))
|
|
assert "Max-Age=0" in deletion
|
|
assert "samesite=none" in deletion.lower()
|
|
assert "; Secure" in deletion
|
|
|
|
|
|
def test_clear_pkce_cookie_http_bare_deletion_is_insecure_lax():
|
|
"""HTTP clear: the bare-name deletion must mirror the HTTP setter's
|
|
shape (Lax, no Secure). A Secure deletion can be ignored by browsers
|
|
on a plain-HTTP origin, leaving a stale PKCE cookie behind. The
|
|
__Host-/__Secure- variants require Secure to be valid at all, so
|
|
those deletions keep it regardless of origin."""
|
|
client = TestClient(_build_app(use_https=False, prefix=""))
|
|
cookies = client.get("/clear").headers.get_list("set-cookie")
|
|
bare = next(
|
|
c for c in cookies
|
|
if c.startswith(f'{PKCE_COOKIE}="')
|
|
and not c.startswith("__")
|
|
)
|
|
assert "Max-Age=0" in bare
|
|
assert "samesite=lax" in bare.lower()
|
|
assert "; Secure" not in bare
|
|
for name in (f"__Host-{PKCE_COOKIE}", f"__Secure-{PKCE_COOKIE}"):
|
|
deletion = next(c for c in cookies if c.startswith(f'{name}="'))
|
|
assert "Max-Age=0" in deletion
|
|
assert "; Secure" in deletion
|
|
|
|
|
|
def test_clear_session_cookies_prefixed_deletions_carry_secure():
|
|
"""__Host-/__Secure- deletions must carry Secure (and __Host- Path=/):
|
|
browsers reject a prefixed Set-Cookie that violates its prefix rules,
|
|
so an insecure deletion for __Host-hermes_session_at is silently
|
|
ignored and the session cookie survives logout on HTTPS origins."""
|
|
client = TestClient(_build_app(use_https=True, prefix=""))
|
|
cookies = client.get("/clear").headers.get_list("set-cookie")
|
|
for name in (SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE):
|
|
host = next(c for c in cookies if c.startswith(f'__Host-{name}="'))
|
|
assert "; Secure" in host
|
|
assert "Path=/;" in host or host.rstrip().endswith("Path=/")
|
|
secure = next(c for c in cookies if c.startswith(f'__Secure-{name}="'))
|
|
assert "; Secure" in secure
|
|
bare = next(
|
|
c for c in cookies
|
|
if c.startswith(f'{name}="') and not c.startswith("__")
|
|
)
|
|
# Bare-name deletion mirrors the bare setter (Lax, no Secure) so
|
|
# it still works on plain-HTTP origins.
|
|
assert "; Secure" not in bare
|
|
assert "Max-Age=0" in bare
|