nousresearch-hermes-agent-83536-99176
Dashboard authentication in `hermes_cli.dashboard_auth` has two independent problems: the PKCE state cookie does not survive the identity-provider round trip, and cookie deletions are rejected by browsers so state is left behind.
PKCE cookie wire format. `set_pkce_cookie` must URL-encode the whole flat payload so the value contains only RFC 6265 cookie-octets. For the payload `provider=stub;state=s;verifier=v` set over HTTPS with no prefix, the `__Host-hermes_session_pkce` Set-Cookie value must contain no `;`, no `"` and no `\`, every character must be a cookie-octet, and `urllib.parse.unquote(value)` must return exactly `provider=stub;state=s;verifier=v`. (The previous `http.cookies` quoted form, with `;` for `;`, is dropped whole by strict cookie-aware proxies.)
A single reader for both formats. Add `parse_pkce_payload(raw: str) -> dict[str, str]` to `hermes_cli.dashboard_auth.cookies`, and route every reader of the PKCE cookie through it (the OAuth callback and the native authorize flow in routes.py included). A literal `;` in `raw` is the exact discriminator for a cookie minted by a pre-encoding server, because the encoded form can never contain one: - When `raw` contains `;`, split it as it stands on `;`, split each segment on the first `=`, and do not URL-decode anything. So `"provider=stub;state=s123;verifier=v456;next=%2Fsessions%3Fx%3Da%3Bb%26project%3Dfoo"` returns `{"provider": "stub", "state": "s123", "verifier": "v456", "next": "%2Fsessions%3Fx%3Da%3Bb%26project%3Dfoo"}` with the `next` value still encoded. - Otherwise `unquote(raw)` first, then split the same way. So `quote("provider=stub;state=s123;verifier=v456;next=%2Fsessions", safe="")` returns `{"provider": "stub", "state": "s123", "verifier": "v456", "next": "%2Fsessions"}`. Segments without `=` are skipped. The login-callback round trip must work end to end: `/auth/login` sets the cookie on its 302, and `/auth/callback` with that cookie and the matching state returns 302, redirecting to the exact `next` target that was requested (for `next=/sessions?view=recent&project=foo`, `Location` is that string unchanged).
PKCE cookie attributes. Over HTTPS the PKCE cookie is `SameSite=None` with `Secure` and `HttpOnly`; `SameSite=Lax` is dropped intermittently by Chromium when set on a 302 inside a cross-site redirect chain. Over plain HTTP it falls back to the bare name with `SameSite=Lax` and no `Secure`, since `SameSite=None` is invalid without `Secure`. The session cookies stay `SameSite=Lax`.
Deletions must obey the cookie-prefix rules and match the setter. Clearing emits a `Max-Age=0` Set-Cookie for each name variant, `__Host-`, `__Secure-` and the bare name, where the `__Host-` and `__Secure-` deletions always carry `Secure` (and `__Host-` also `Path=/`), because a browser rejects a prefixed Set-Cookie that violates its prefix rules, and the bare-name deletion mirrors the attributes the setter used on that origin, because a `Secure` deletion can be ignored on a plain-HTTP origin. - `clear_pkce_cookie(response, *, use_https, prefix)` over HTTPS: all three deletions carry `SameSite=None` and `Secure`. Over HTTP: the bare deletion carries `SameSite=Lax` and no `Secure`, while the two prefixed deletions still carry `Secure`. - `clear_session_cookies` for each of the access-token, refresh-token and provider session cookie names: the `__Host-` deletion carries `Secure` and `Path=/`, the `__Secure-` deletion carries `Secure`, and the bare deletion carries no `Secure`. - The logout, callback and error paths in routes.py call `clear_pkce_cookie` with the request's detected scheme and prefix.
Native password-login flow. Authorizing with no provider selected brokers to the normal login flow, and selecting a password provider redirects to that provider's login flow. In both cases the PKCE cookie the route sets must be readable by `parse_pkce_payload` and its parsed payload must include a `broker` key, so the broker/provider consistency check is actually fed.
Hidden tests · 9 fail-to-pass, 20 pass-to-passrun after the agent submits, in a clean verifier
Test patch · 411 lines
diff --git a/tests/hermes_cli/test_dashboard_auth_cookies.py b/tests/hermes_cli/test_dashboard_auth_cookies.py
index 2cd48f4c4..4a5a0eea4 100644
--- a/tests/hermes_cli/test_dashboard_auth_cookies.py
+++ b/tests/hermes_cli/test_dashboard_auth_cookies.py
@@ -45,7 +45,7 @@ def _build_app(use_https: bool = True, prefix: str = ""):
def clear():
r = Response("ok")
clear_session_cookies(r, prefix=prefix)
- clear_pkce_cookie(r, prefix=prefix)
+ clear_pkce_cookie(r, use_https=use_https, prefix=prefix)
return r
return app
@@ -103,7 +103,7 @@ def test_session_cookies_use_bare_name_on_http():
)
# No Secure flag (HTTP).
at = next(c for c in cookies if c.startswith(f"{SESSION_AT_COOKIE}="))
- assert "Secure" not in at
+ assert "; Secure" not in at
@@ -133,5 +133,358 @@ def test_read_session_cookies_from_request_secure_prefix():
assert rt == "rt_value"
+# ---------------------------------------------------------------------------
+# PKCE cookie: regression for #83832 (field case: Traefik+Authentik chain)
+# ---------------------------------------------------------------------------
+#
+# The PKCE payload is a flat ``key=value;key=value`` string. A raw ``;``
+# is a cookie-attribute terminator, so Python's http.cookies emits the
+# value in RFC 6265 quoted form with each ``;`` escaped as the
+# backslash-octal ``\073``. Mainstream browsers echo that form back
+# verbatim and Python decodes it — but ``"`` and ``\`` are outside the
+# plain cookie-octet set, and cookie-aware proxy hops that parse and
+# re-emit the Cookie header (verified for Go's net/http, common in
+# Go-based proxy/IDP middleware) reject the value and drop the cookie
+# entirely. The callback
+# then 400s with "Missing PKCE state cookie" even though the browser
+# sent the cookie. The fix URL-encodes the payload in the setter so the
+# wire value contains only cookie-octets, and every reader decodes via
+# cookies.parse_pkce_payload(). These tests pin the wire shape and the
+# round trip.
+def test_set_pkce_cookie_url_encodes_payload_to_avoid_rfc6265_split():
+ """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).
+
+ Regression for #83832 / the Traefik+Authentik support case: the
+ callback failed with "Missing PKCE state cookie" because a proxy
+ hop dropped the quoted ``\\073`` form.
+ """
+ from urllib.parse import unquote
+ 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). The
+ # payload ``provider=stub;state=s;verifier=v`` is encoded as
+ # ``provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv``.
+ 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}"
+ )
+ # Round-trip the URL-encoding back to the original payload.
+ decoded = unquote(wire)
+ assert decoded == "provider=stub;state=s;verifier=v", (
+ f"URL-encoded payload didn't round-trip to the original: "
+ f"got {decoded!r}"
+ )
+
+
+def test_parse_pkce_payload_old_format_cookie_survives_rolling_upgrade():
+ """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_new_format_round_trips_setter_encoding():
+ """The new encoded wire form (no raw ``;`` possible — it is %3B)
+ decodes back to the exact original payload segments."""
+ 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"new-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 urllib.parse import 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",
+ )
+ # /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.
+
… [10965 more characters]Reference fix · 5 files, +195 −51the upstream merge, used only for grading calibration
The agent could not see this: the repository holds one commit and the sandbox has no network. Leak audit.
contributors/emails/breakout@protonmail.com, hermes_cli/dashboard_auth/base.py, hermes_cli/dashboard_auth/cookies.py, hermes_cli/dashboard_auth/routes.py, website/docs/user-guide/features/web-dashboard.md
diff --git a/contributors/emails/breakout@protonmail.com b/contributors/emails/breakout@protonmail.com
new file mode 100644
index 000000000000..27c9235471be
--- /dev/null
+++ b/contributors/emails/breakout@protonmail.com
@@ -0,0 +1 @@
+repfigit
diff --git a/hermes_cli/dashboard_auth/base.py b/hermes_cli/dashboard_auth/base.py
index e8b8a7730b1d..2d744c6cf3da 100644
--- a/hermes_cli/dashboard_auth/base.py
+++ b/hermes_cli/dashboard_auth/base.py
@@ -61,8 +61,16 @@ class LoginStart:
Portal's ``/oauth/authorize``). ``cookie_payload`` is a dict of cookie
name → serialised value that the auth route will ``Set-Cookie`` on the
response. Used for PKCE state, CSRF nonces, etc. Cookies set here MUST
- be HttpOnly + Secure (when over HTTPS) + SameSite=Lax with a TTL ≤ 10
- minutes (the login lifetime).
+ be HttpOnly + Secure (when over HTTPS) with a TTL ≤ 10 minutes (the
+ login lifetime).
+
+ SameSite: use ``Lax`` by default. The one exception is the PKCE state
+ cookie, which is ``SameSite=None; Secure`` over HTTPS — it is set on
+ the ``/auth/login`` 302 and has to survive the cross-site redirect
+ chain back from the IDP, which Chromium drops intermittently under
+ ``Lax`` (crbug 40508226). Over plain HTTP it stays ``Lax``, since
+ ``SameSite=None`` requires ``Secure``. See
+ :func:`hermes_cli.dashboard_auth.cookies.set_pkce_cookie`.
"""
redirect_url: str
diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py
index 8bcd9db78eb6..ed6f89a8e8b8 100644
--- a/hermes_cli/dashboard_auth/cookies.py
+++ b/hermes_cli/dashboard_auth/cookies.py
@@ -17,14 +17,24 @@
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
hint (HttpOnly, lifetime = 10 minutes)
-All three are ``SameSite=Lax`` (browser will send on cross-site GET
-top-level navigation, which we need for the IDP redirect back to
-``/auth/callback``) and live under the prefix's Path. ``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.
+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):
@@ -56,7 +66,7 @@
"""
from __future__ import annotations
-from typing import Optional, Tuple
+from typing import Literal, Optional, Tuple
from fastapi import Request
from fastapi.responses import Response
@@ -212,6 +222,46 @@ def set_session_cookies(
)
+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.
@@ -221,40 +271,69 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
depends on the request that set it), so we emit deletions for every
plausible variant under the active path.
"""
- path = _cookie_path(prefix)
- for variant in _NAME_VARIANTS:
- response.set_cookie(
- f"{variant}{SESSION_AT_COOKIE}", "", max_age=0,
- path=path, httponly=True, samesite="lax",
- )
- response.set_cookie(
- f"{variant}{SESSION_RT_COOKIE}", "", max_age=0,
- path=path, httponly=True, samesite="lax",
- )
- response.set_cookie(
- f"{variant}{SESSION_PROVIDER_COOKIE}", "", max_age=0,
- path=path, httponly=True, samesite="lax",
+ 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 set_pkce_cookie(
response: Response, *, payload: 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.
+
… [12021 more characters]GLM-5.3 Flash · attempt 2 · solved · 41 steps · $0.026
Submitted diff · 217 lines
diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py
index 8bcd9db..4eec04a 100644
--- a/hermes_cli/dashboard_auth/cookies.py
+++ b/hermes_cli/dashboard_auth/cookies.py
@@ -58,6 +58,8 @@ from __future__ import annotations
from typing import Optional, Tuple
+import urllib.parse
+
from fastapi import Request
from fastapi.responses import Response
@@ -212,8 +214,30 @@ def set_session_cookies(
)
+def _deletion_attrs(variant: str, *, path: str, samesite: str) -> dict:
+ """Attributes for a Max-Age=0 deletion Set-Cookie.
+
+ Browsers reject a prefixed Set-Cookie that violates its prefix rules
+ (``__Host-`` requires ``Secure`` + ``Path=/``; ``__Secure-`` requires
+ ``Secure``), and a rejected deletion leaves the cookie behind — so the
+ prefixed deletions ALWAYS carry ``Secure`` (and ``__Host-`` always
+ ``Path=/``), regardless of the current request's scheme. The bare-name
+ deletion mirrors whatever the setter used on this origin: a ``Secure``
+ deletion is ignored on a plain-HTTP origin.
+ """
+ attrs: dict = {
+ "max_age": 0,
+ "path": "/" if variant == "__Host-" else path,
+ "httponly": True,
+ "samesite": samesite,
+ }
+ if variant: # __Host- / __Secure- deletions always carry Secure.
+ attrs["secure"] = True
+ return attrs
+
+
def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
- """Emit Max-Age=0 deletions for both session cookies.
+ """Emit Max-Age=0 deletions for the 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.
@@ -223,38 +247,84 @@ def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
"""
path = _cookie_path(prefix)
for variant in _NAME_VARIANTS:
- response.set_cookie(
- f"{variant}{SESSION_AT_COOKIE}", "", max_age=0,
- path=path, httponly=True, samesite="lax",
- )
- response.set_cookie(
- f"{variant}{SESSION_RT_COOKIE}", "", max_age=0,
- path=path, httponly=True, samesite="lax",
- )
- response.set_cookie(
- f"{variant}{SESSION_PROVIDER_COOKIE}", "", max_age=0,
- path=path, httponly=True, samesite="lax",
- )
+ attrs = _deletion_attrs(variant, path=path, samesite="lax")
+ for name in (
+ SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE,
+ ):
+ response.set_cookie(f"{variant}{name}", "", **attrs)
def set_pkce_cookie(
response: Response, *, payload: str, use_https: bool, prefix: str = "",
) -> None:
+ """Set the PKCE state cookie.
+
+ The payload is a flat ``k=v;k=v`` string whose values may contain
+ characters outside the RFC 6265 cookie-octet set (``;``, ``"``,
+ ``\\`` ...). It is URL-encoded in full so the wire value contains only
+ cookie-octets — the ``http.cookies`` quoted form (``;`` for ``;``) is
+ dropped whole by strict cookie-aware proxies. :func:`parse_pkce_payload`
+ is the matching reader.
+ """
response.set_cookie(
_resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix),
- payload,
+ urllib.parse.quote(payload, safe=""),
max_age=_PKCE_MAX_AGE,
- **_common_attrs(use_https=use_https, prefix=prefix),
+ httponly=True,
+ # SameSite=None (which requires Secure) survives the IDP's
+ # cross-site redirect chain back to /auth/callback; Lax is dropped
+ # intermittently by Chromium when set on a 302 inside that chain.
+ # Over HTTP SameSite=None is invalid without Secure, so fall back
+ # to the bare name with SameSite=Lax.
+ samesite="none" if use_https else "lax",
+ secure=True if use_https else False,
+ path=_cookie_path(prefix),
)
-def clear_pkce_cookie(response: Response, *, prefix: str = "") -> None:
+def parse_pkce_payload(raw: str) -> dict[str, str]:
+ """Parse the PKCE cookie payload into a flat ``dict``.
+
+ A literal ``;`` in ``raw`` is the exact discriminator for a cookie
+ minted by a pre-encoding server (one that wrote the payload verbatim):
+ the encoded form can never contain a literal ``;``. Legacy payloads are
+ split as they stand with NO decoding; current payloads are URL-decoded
+ first, then split the same way. Segments without ``=`` are skipped.
+ """
+ if not raw:
+ return {}
+ text = raw if ";" in raw else urllib.parse.unquote(raw)
+ parts: dict = {}
+ for seg in text.split(";"):
+ if "=" not in seg:
+ continue
+ key, value = seg.split("=", 1)
+ if key:
+ parts[key] = value
+ return parts
+
+
+def clear_pkce_cookie(
+ response: Response, *, use_https: bool, prefix: str = "",
+) -> None:
+ """Emit Max-Age=0 deletions for the PKCE cookie, every name variant.
+
+ ``use_https`` must be the request's detected scheme so the bare-name
+ deletion mirrors the attributes the setter used on this origin.
+ """
path = _cookie_path(prefix)
for variant in _NAME_VARIANTS:
- response.set_cookie(
- f"{variant}{PKCE_COOKIE}", "", max_age=0,
- path=path, httponly=True, samesite="lax",
- )
+ if variant:
+ # Prefixed deletions always carry Secure (prefix rules) and
+ # SameSite=None to match the HTTPS setter.
+ samesite = "none"
+ else:
+ samesite = "none" if use_https else "lax"
+ attrs = _deletion_attrs(variant, path=path, samesite=samesite)
+ if not variant and use_https:
+ # SameSite=None is invalid without Secure, even on a deletion.
+ attrs["secure"] = True
+ response.set_cookie(f"{variant}{PKCE_COOKIE}", "", **attrs)
def _read_with_fallback(
diff --git a/hermes_cli/dashboard_auth/routes.py b/h
… [3278 more characters]$ find /repo -name dashboard_auth -o -name cookies.py | head; ls /repo
output · 79 lines
/repo/hermes_cli/dashboard_auth /repo/hermes_cli/dashboard_auth/cookies.py /repo/plugins/dashboard_auth /repo/tests/plugins/dashboard_auth AGENTS.md CONTRIBUTING.es.md CONTRIBUTING.md Dockerfile LICENSE README.es.md README.md README.ur-pk.md
67 more lines
README.zh-CN.md SECURITY.es.md SECURITY.md acp_adapter agent apps assets batch_runner.py cli-config.yaml.example cli.py constraints-termux.txt contributors cron datagen-config-examples docker docker-compose.windows.yml docker-compose.yml docs eslint.config.shared.mjs evals flake.lock flake.nix gateway hermes hermes_agent.egg-info hermes_bootstrap.py hermes_cli hermes_constants.py hermes_logging.py hermes_state.py hermes_state_common.py hermes_state_portability.py hermes_state_schema.py hermes_state_search.py hermes_time.py locales mcp-research-data mcp_serve.py mini_swe_runner.py model_tools.py native nix optional-mcps optional-skills package-lock.json package.json plugins providers pyproject.toml registration_lifecycle.py run_agent.py scripts setup-hermes.sh setup.py skills tests tests-js tools toolset_distributions.py toolsets.py trajectory_compressor.py tui_gateway ui-tui utils.py uv.lock web website
$ ls /repo/hermes_cli/dashboard_auth /repo/tests/plugins/dashboard_auth; wc -l /repo/hermes_cli/dashboard_auth/*.py
output · 34 lines
/repo/hermes_cli/dashboard_auth: __init__.py audit.py base.py cookies.py login_page.py middleware.py native_flow.py prefix.py public_paths.py registry.py routes.py
22 more lines
token_auth.py
ws_tickets.py
/repo/tests/plugins/dashboard_auth:
test_basic_provider.py
test_drain_provider.py
test_nous_provider.py
test_self_hosted_provider.py
48 /repo/hermes_cli/dashboard_auth/__init__.py
95 /repo/hermes_cli/dashboard_auth/audit.py
306 /repo/hermes_cli/dashboard_auth/base.py
338 /repo/hermes_cli/dashboard_auth/cookies.py
537 /repo/hermes_cli/dashboard_auth/login_page.py
591 /repo/hermes_cli/dashboard_auth/middleware.py
306 /repo/hermes_cli/dashboard_auth/native_flow.py
232 /repo/hermes_cli/dashboard_auth/prefix.py
60 /repo/hermes_cli/dashboard_auth/public_paths.py
167 /repo/hermes_cli/dashboard_auth/registry.py
1097 /repo/hermes_cli/dashboard_auth/routes.py
194 /repo/hermes_cli/dashboard_auth/token_auth.py
161 /repo/hermes_cli/dashboard_auth/ws_tickets.py
4132 total$ cat /repo/hermes_cli/dashboard_auth/cookies.py /repo/hermes_cli/dashboard_auth/prefix.py
output · 26 lines
"""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 a14 more lines
fresh access token transparently on AT expiry. A
provider that omits the refresh token (empty string)
degrades gracefully to access-token-on
…[8630 chars omitted]…
C_URL", "")
env_clean = _normalise_public_url(env_raw)
if env_clean:
return env_clean
_warn_if_malformed("HERMES_DASHBOARD_PUBLIC_URL env var", env_raw)
cfg_raw = str(_load_dashboard_section().get("public_url", ""))
cfg_clean = _normalise_public_url(cfg_raw)
if not cfg_clean:
_warn_if_malformed("dashboard.public_url in config.yaml", cfg_raw)
return cfg_clean$ sed -n 100,340p /repo/hermes_cli/dashboard_auth/cookies.py
output · 34 lines
# 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 this22 more lines
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 ``Pa
…[7248 chars omitted]…
t_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"$ grep -n "pkce\|PKCE\|clear_session\|clear_pkce\|read_pkce\|set_pkce" /repo/hermes_cli/dashboard_auth/routes.py /repo/hermes_cli/dashboard_auth/native_flow.py /repo/hermes_cli/dashboard_auth/middleware.py /repo/hermes_cli/dashboard_auth/login_page.py | head -60
output · 15 lines
/repo/hermes_cli/dashboard_auth/routes.py:10: GET /auth/login?provider=N → 302 to IDP, sets PKCE cookie
/repo/hermes_cli/dashboard_auth/routes.py:40: clear_pkce_cookie,
/repo/hermes_cli/dashboard_auth/routes.py:41: clear_session_cookies,
/repo/hermes_cli/dashboard_auth/routes.py:44: read_pkce_cookie,
/repo/hermes_cli/dashboard_auth/routes.py:46: set_pkce_cookie,
/repo/hermes_cli/dashboard_auth/routes.py:225: # Pack the provider name into the PKCE cookie so the callback can
/repo/hermes_cli/dashboard_auth/routes.py:228: pkce = ls.cookie_payload.get("hermes_session_pkce", "")
/repo/hermes_cli/dashboard_auth/routes.py:229: if "provider=" not in pkce:
/repo/hermes_cli/dashboard_auth/routes.py:230: pkce = f"provider={provider};{pkce}" if pkce else f"provider={provider}"
/repo/hermes_cli/dashboard_auth/routes.py:231: # Carry ``next=`` through the round trip
…[4906 chars omitted]…
desktop secret is3 more lines
/repo/hermes_cli/dashboard_auth/native_flow.py:42:interactive ``/login`` form (broker_state in the PKCE cookie) and a successful /repo/hermes_cli/dashboard_auth/native_flow.py:51: * **PKCE binding (RFC 7636).** A gateway code is redeemable only by the client /repo/hermes_cli/dashboard_auth/native_flow.py:85:_PENDING_TTL_SECONDS = 600 # 10 minutes — mirrors the PKCE cookie lifetime.
$ sed -n 200,260p /repo/hermes_cli/dashboard_auth/routes.py; echo ----; sed -n 370,470p /repo/hermes_cli/dashboard_auth/routes.py; echo ----; sed -n 560,620p /repo/hermes_cli/dashboard_auth/routes.py; echo ----; sed -n 740,910p /repo/hermes_cli/dashboard_auth/routes.py
output · 39 lines
if safe_next:
login_url = f"{login_url}?next={quote(safe_next, safe='')}"
return RedirectResponse(url=login_url, status_code=302)
try:
ls = p.start_login(redirect_uri=_redirect_uri(request))
except ProviderError as e:
audit_log(
AuditEvent.LOGIN_FAILURE,
provider=provider,
reason="provider_unreachable",
ip=_client_ip(request),27 more lines
)
raise HTTPException(
status_code=503,
detail=f"Provider unreachable: {e}",
)
audit_log(
AuditEvent.LOGIN_START,
provider=provider,
ip=_client_ip(request),
)
resp = RedirectResponse(url=ls.redirect_url, status_code=302)
# Pack the provider name into the PKCE cookie so the callback can
# find it without a separate cookie. Provider may or may not have
# already includ
…[8629 chars omitted]…
prefix = _prefix(request)
resp = RedirectResponse(url=f"{prefix}/login", status_code=302)
clear_session_cookies(resp, prefix=prefix)
clear_pkce_cookie(resp, prefix=prefix)
return resp
# ---------------------------------------------------------------------------
# Auth-required: identity probe for the SPA
# ---------------------------------------------------------------------------$ sed -n 430,560p /repo/hermes_cli/dashboard_auth/routes.py; echo ====; sed -n 690,760p /repo/hermes_cli/dashboard_auth/routes.py
output · 35 lines
state: str = "",
error: str = "",
error_description: str = "",
):
pkce_raw = read_pkce_cookie(request)
if not pkce_raw:
audit_log(
AuditEvent.LOGIN_FAILURE,
reason="missing_pkce_cookie",
ip=_client_ip(request),
)
raise HTTPException(23 more lines
status_code=400,
detail="Missing PKCE state cookie",
)
# Parse ``provider=...;state=...;verifier=...;next=...`` — the
# ``next`` segment is optional (only present when /auth/login was
# given a next= query). All keys live in the same flat namespace;
# ``next`` carries a URL-encoded path so it never contains ``;``.
parts = dict(
seg.split("=", 1) for seg in pkce_raw.split(";") if "=" in seg
)
provider_name = parts.get("provider", "")
expected_state = parts.get("state", "")
verifier = parts.get("verifie
…[6505 chars omitted]…
so without this
# check a flow started for provider A could be completed with provider
# B's credentials, binding B's session into A's pending authorization.
# Enforce equality BEFORE verifying credentials: nothing is minted, the
# pending authorization is preserved, and the user can submit the form
# the flow was actually started for.
broker_state = ""
cookie_provider = ""$ sed -n 756,800p /repo/hermes_cli/dashboard_auth/routes.py; echo ====; grep -n "native/authorize\|def native_authorize\|provider" /repo/hermes_cli/dashboard_auth/routes.py | sed -n '1,60p'
output · 31 lines
# Enforce equality BEFORE verifying credentials: nothing is minted, the
# pending authorization is preserved, and the user can submit the form
# the flow was actually started for.
broker_state = ""
cookie_provider = ""
pkce_raw = read_pkce_cookie(request)
if pkce_raw:
pkce_parts = dict(
seg.split("=", 1) for seg in pkce_raw.split(";") if "=" in seg
)
broker_state = pkce_parts.get("broker", "")
cookie_provider = pkce_parts.get("provider", "")19 more lines
if broker_state and cookie_provider != body.provider:
audit_log(
AuditEvent.NATIVE_TOKEN_FAILURE,
provider=body.provider,
reason="provider_mismatch",
ip=ip,
)
raise HTTPException(
status_code=400,
detail=(
"This native sign-in was started for a different provider
…[3977 chars omitted]…
n pkce:
417: pkce = f"provider={p.name};{pkce}" if pkce else f"provider={p.name}"
446: # Parse ``provider=...;state=...;verifier=...;next=...`` — the
453: provider_name = parts.get("provider", "")
461: # RFC 8252 native-app flow: /auth/native/authorize stashed a broker_state
467: p = get_provider(provider_name)
471: detail=f"Unknown provider in cookie: {provider_name!r}",$ sed -n 289,380p /repo/hermes_cli/dashboard_auth/routes.py
output · 27 lines
@router.get("/auth/native/authorize", name="auth_native_authorize")
async def auth_native_authorize(
request: Request,
provider: str = "",
code_challenge: str = "",
code_challenge_method: str = "",
redirect_uri: str = "",
state: str = "",
):
"""Begin an RFC 8252 native-app login for the desktop app.
The desktop opens THIS url in the system browser with its own PKCE15 more lines
``code_challenge`` (S256), a loopback ``redirect_uri``, and a CSRF
``state``. We stash a pending broker authorization, then hand off to the
EXISTING upstream PKCE round trip (``provider.start_login`` → IDP →
``/auth/callback``), carrying the broker_state in the same PKCE cookie the
cookie flow uses. On the callback we mint a loopback code (see
``auth_callback``); no browser session cookie is ever set for the desktop.
Password providers have no upstream IDP round
…[3133 chars omitted]…
if getattr(p, "supports_password", False):
# Password provider: no IDP to redirect through. Land the system
# browser on the interactive /login form with the broker_state in
# the PKCE cookie (the same server-controlled channel the OAuth
# branch uses); /auth/password-login picks it up on success and
# 302s the browser to the desktop's loopback redirect_uri. The$ grep -rln "parse_pkce_payload\|native/authorize\|clear_pkce" /repo/tests | head; ls /repo/tests/hermes_cli 2>/dev/null | head -40
output · 42 lines
/repo/tests/hermes_cli/test_dashboard_auth_cookies.py /repo/tests/hermes_cli/test_dashboard_auth_native_flow.py __init__.py conftest.py conftest_dashboard_auth.py fixtures test_25106_global_switch_persists_base_url_api_mode.py test_active_sessions.py test_actual_provider.py test_agent_env_advertisement.py test_agent_import.py test_agent_plugins.py
30 more lines
test_ai_gateway_models.py test_anthropic_model_flow_stale_oauth.py test_anthropic_oauth_flow.py test_anthropic_oauth_routes_to_messages_api.py test_anthropic_picker_curated.py test_anthropic_pool_model_discovery.py test_anthropic_provider_persistence.py test_api_key_providers.py test_api_mode_aliases.py test_apply_model_switch_result_context.py test_apply_profile_override.py test_approval_transport.py test_approvals_command.py test_approvals_suggest.py test_approvals_test.py test_arcee_provider.py test_argparse_flag_propagation.py test_at_context_completion_filter.py test_atomic_json_write.py test_atomic_yaml_write.py test_auth_codex_provider.py test_auth_codex_quota_probe.py test_auth_codex_self_heal.py test_auth_commands.py test_auth_loopback_ssh_hint.py test_auth_nous_provider.py test_auth_profile_fallback.py test_auth_provider_gate.py test_auth_provider_scope.py test_auth_qwen_provider.py
$ sed -n 1,120p /repo/tests/hermes_cli/test_dashboard_auth_cookies.py; grep -c "" /repo/tests/hermes_cli/test_dashboard_auth_cookies.py
output · 56 lines
"""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,44 more lines
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",
…[2637 chars omitted]…
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 = {
137$ sed -n 120,137p /repo/tests/hermes_cli/test_dashboard_auth_cookies.py; echo ===; grep -rn "clear_pkce\|set_pkce\|pkce" /repo/tests/hermes_cli/test_dashboard_auth_native_flow.py | head -30; echo ===; grep -rln "auth/login\|auth/callback" /repo/tests/hermes_cli | head
output · 40 lines
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)28 more lines
assert at == "at_value"
assert rt == "rt_value"
===
47:def _make_pkce() -> tuple[str, str]:
195: _verifier, challenge = _make_pkce()
237: _verifier, challenge = _make_pkce()
250: _verifier, challenge = _make_pkce()
264: _verifier, challenge = _make_pkce()
281: _verifier, challenge = _make_pkce()
300: verifier, challenge = _make_pkce()
383:def test_status_advertises_native_pkce_for_password_only_gateway(
389: assert "native_pkce" in body["auth_flows"]
397: _verifier, challenge = _make_pkce()
410: assert "pkce" in set_cookie
…[275 chars omitted]…
rd_auth_401_reauth.py
/repo/tests/hermes_cli/test_dashboard_auth_middleware.py
/repo/tests/hermes_cli/test_dashboard_auth_native_flow.py
/repo/tests/hermes_cli/test_dashboard_auth_prefix.py
/repo/tests/hermes_cli/test_dashboard_auth_stub_provider.py
/repo/tests/hermes_cli/test_dashboard_auth_ws_auth.py
/repo/tests/hermes_cli/test_dashboard_register.py
/repo/tests/hermes_cli/test_mcp_dashboard_oauth.py$ sed -n 1,120p /repo/tests/hermes_cli/test_dashboard_auth_stub_provider.py; grep -c "" /repo/tests/hermes_cli/test_dashboard_auth_stub_provider.py
output · 47 lines
"""Contract test for the StubAuthProvider used in dashboard-auth E2E tests.
Phase 2 of the dashboard-OAuth plan. Validates the stub against the
provider protocol so subsequent phases that depend on its behavior
have a guarantee.
"""
from __future__ import annotations
import pytest
from hermes_cli.dashboard_auth.base import (
InvalidCodeError, RefreshExpiredError, assert_protocol_compliance,35 more lines
)
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
def _pkce_payload(ls) -> dict:
"""Parse ``state=...;verifier=...`` out of the LoginStart cookie payload."""
return dict(
item.split("=", 1)
for item in ls.cookie_payload["hermes_session_pkce"].split(";")
)
def test_stub_complies_with_protocol():
assert assert_protocol_compliance(StubAuthProvider) is None
def test_stub_complete_login_rejects_mismatched_state():
p = StubAuthProvide
…[61 chars omitted]…
llback")
with pytest.raises(InvalidCodeError):
p.complete_login(
code="stub_code",
state="WRONG",
code_verifier="anything",
redirect_uri="https://x.fly.dev/auth/callback",
)
def test_stub_verify_tampered_token_returns_none():
p = StubAuthProvider()
assert p.verify_session(access_token="garbage-not-a-real-token") is None
45$ sed -n 180,540p /repo/tests/hermes_cli/test_dashboard_auth_native_flow.py
output · 35 lines
loop = urlparse(r2.headers["location"])
assert f"{loop.scheme}://{loop.netloc}" == redirect_uri.rsplit("/", 1)[0] or \
loop.netloc in redirect_uri
loop_qs = parse_qs(loop.query)
# No session cookie must be set on the native callback response.
set_cookie = r2.headers.get("set-cookie", "")
assert "hermes_session_at" not in set_cookie, (
f"native callback must NOT set a session cookie; got {set_cookie!r}"
)
return loop_qs["code"][0], loop_qs["state"][0]
23 more lines
def test_native_authorize_rejects_non_loopback_redirect(gated_client):
_verifier, challenge = _make_pkce()
r = gated_client.get(
"/auth/native/authorize",
params={
"provider": "stub",
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": "https://evil.example.com/steal",
"state": "
…[8629 chars omitted]…
ke_pkce()
# Native flow initiated for provider A ("testpw").
cookies = _start_native_password_login(pw_gated_client, challenge=challenge)
# Valid credentials for provider B ("testpw2") must NOT complete A's
# pending authorization.
r = pw_gated_client.post(
"/auth/password-login",
json={
"provider": "testpw2", "username": "admin", "password": "hunter2",$ grep -n "auth/login\|provider\|password-login" /repo/hermes_cli/dashboard_auth/login_page.py | head -40
output · 27 lines
3:No React, no JavaScript dependency. Listed providers come from the
4:registry; clicking a provider sends a GET to
5:``/auth/login?provider=<name>``.
18:``class="provider-btn"`` anchor href to walk the OAuth flow. That
26:from hermes_cli.dashboard_auth import list_session_providers
187: .provider-list {{
194: .provider-btn {{
216: .provider-btn:hover {{
219: .provider-btn:active {{
223: .provider-btn:focus-visible {{
228: /* Password provider form — same visual language as the OAuth buttons:
230: .provider-form {{15 more lines
274: .provider-form .provider-btn {{
309: <div class="provider-list">
310:{provider_buttons}
395:providers are available.</p>
396:<p>Configure the bundled username/password provider or an OAuth provider.
407:# Inline script that wires every password provider form to POST JSON to
408:# ``/auth/password-login`` and navigate on success. Emitted ONLY when at
409:# le
…[848 chars omitted]…
t handler) to POST JSON to ``/auth/password-login`` and navigate
511: defence in depth. The provider ``name`` is emitted in a ``data-``
515: pname = html.escape(provider.name, quote=True)
516: plabel = html.escape(provider.display_name)
519: f' <form class="provider-form" data-provider="{pname}" '
535: f' <button class="provider-btn" type="submit">Sign in</button>\n'$ grep -rn "clear_pkce_cookie\|clear_session_cookies\|clear_sso_attempt" /repo --include=*.py | grep -v tests/
output · 14 lines
/repo/hermes_cli/dashboard_auth/cookies.py:51: ``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT /repo/hermes_cli/dashboard_auth/cookies.py:215:def clear_session_cookies(response: Response, *, prefix: str = "") -> None: /repo/hermes_cli/dashboard_auth/cookies.py:251:def clear_pkce_cookie(response: Response, *, prefix: str = "") -> None: /repo/hermes_cli/dashboard_auth/cookies.py:316:def clear_sso_attempt_cookie(response: Response, *, prefix: str = "") -> None: /repo/hermes_cli/dashboard_auth/middleware.py:33: clear_sso_attempt_cookie, /repo/hermes_cli/dashboard_auth/middleware.py:203: clear_sso_attempt_cookie(resp, prefix=prefix_from_request(request)) /repo/hermes_cli/dashboard_auth/middleware.py:514: from hermes_cli.dashboard_auth.cookies import clear_session_cookies /repo/hermes_cli/dashboard_auth/middleware.py:516: clear_session_cookie …[562 chars omitted]… fix(request)) /repo/hermes_cli/dashboard_auth/routes.py:604: clear_sso_attempt_cookie(resp, prefix=_prefix(request)) /repo/hermes_cli/dashboard_auth/routes.py:857: clear_pkce_cookie(resp, prefix=_prefix(request))
2 more lines
/repo/hermes_cli/dashboard_auth/routes.py:901: clear_session_cookies(resp, prefix=prefix) /repo/hermes_cli/dashboard_auth/routes.py:902: clear_pkce_cookie(resp, prefix=prefix)
$ grep -n "next_from_cookie\|unquote" /repo/hermes_cli/dashboard_auth/routes.py
output · 4 lines
460: next_from_cookie = parts.get("next", "")
590: landing = _validate_post_login_target(next_from_cookie) or "/"
620: from urllib.parse import unquote
621: decoded = unquote(raw)$ sed -n 615,640p /repo/hermes_cli/dashboard_auth/routes.py; grep -n "_validate_post_login_target" /repo/hermes_cli/dashboard_auth/routes.py | head -3; sed -n 640,680p /repo/hermes_cli/dashboard_auth/routes.py
output · 36 lines
URL (an attacker could craft a /auth/callback URL with their own
``next=https://evil.example``).
"""
if not raw:
return ""
from urllib.parse import unquote
decoded = unquote(raw)
if not decoded.startswith("/") or decoded.startswith("//"):
return ""
# Don't loop back to login pages or auth flow.
if any(
decoded == p or decoded.startswith(p)24 more lines
for p in ("/login", "/auth/", "/api/auth/")
):
return ""
# Reject any ``/api/*`` target. The gate's ``_safe_next_target``
# already filters these out before they reach the cookie, but a
# malicious or stale ``next=`` value that re-enters via the
# callback URL must not be honoured: a successful redirect to an
# API endpoint renders raw JSON in the browser address bar — never
# a useful post-login destination, and indistinguishable from an
# at
…[1609 chars omitted]…
rather than letting unattributable traffic through
unmetered.
"""
now = time.monotonic()
cutoff = now - _PW_RATE_WINDOW_SEC
key = ip or "_unknown_"
with _pw_attempts_lock:
bucket = _pw_attempts[key]
while bucket and bucket[0] < cutoff:
bucket.popleft()
if len(bucket) >= _PW_RATE_MAX_ATTEMPTS:
return True
bucket.append(now)Now let me implement. First cookies.py:
$ cd /repo && python - <<'PY'
output · 1 lines
ok
$ cd /repo && python - <<'PY'
output · 1 lines
3 9
$ cd /repo && cat > /tmp/repro.py <<'PY'
output · 5 lines
Traceback (most recent call last):
File "/tmp/repro.py", line 48, in <module>
val = v.split("Set-Cookie: ",1)[1].split(";")[0].split("=",1)[1]
~~~~~~~~~~~~~~~~~~~~~~~~~^^^
IndexError: list index out of range$ cd /repo && python - <<'PY'
output · 5 lines
Traceback (most recent call last):
File "/tmp/repro.py", line 54, in <module>
assert "samesite=none" in v.lower() and "secure" in v and "httponly" in v.lower(), v
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: __Host-hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=none; Secure$ cd /repo && sed -i 's/assert "samesite=none" in v.lower() and "secure" in v and/assert "samesite=none" in v.lower() and "secure" in v.lower() and/' /tmp/repro.py && python /tmp/repro.py
output · 8 lines
HTTPS set ok: __Host-hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=none; Secure
HTTP set ok: hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=lax
clear: __Host-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
Traceback (most recent call last):
File "/tmp/repro.py", line 70, in <module>
assert "secure" in h.lower() and "path=/" in h
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError$ cd /repo && sed -i 's/assert "secure" in h.lower() and "path=\/" in h, h/assert "secure" in h.lower() and "path=\/" in h.lower(), h/; s/assert "secure" in h.lower() and "path=\/" in h$/assert "secure" in h.lower() and "path=\/" in h.lower()/' /tmp/repro.py && python /tmp/repro.py
output · 10 lines
HTTPS set ok: __Host-hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=none; Secure
HTTP set ok: hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=lax
clear: __Host-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
clear: __Secure-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
clear: hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none
Traceback (most recent call last):
File "/tmp/repro.py", line 74, in <module>
assert "secure" not in h and "samesite=lax" in h.lower()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError$ cd /repo && python - <<'PY'
output · 10 lines
HTTPS set ok: __Host-hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=none; Secure
HTTP set ok: hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=lax
clear: __Host-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
clear: __Secure-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
clear: hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
Traceback (most recent call last):
File "/tmp/repro.py", line 74, in <module>
assert "secure" not in h and "samesite=lax" in h.lower()
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError$ cd /repo && python - <<'PY'
output · 10 lines
HTTPS set ok: __Host-hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=none; Secure
HTTP set ok: hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=lax
clear: __Host-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
clear: __Secure-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
clear: hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure
Traceback (most recent call last):
File "/tmp/repro.py", line 83, in <module>
assert "secure" in h, h
^^^^^^^^^^^^^
AssertionError: __Host-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure$ cd /repo && sed -n 76,100p /tmp/repro.py
output · 25 lines
r = client.get("/clear-http")
for h in r.headers.get_list("set-cookie"):
if "pkce" not in h: continue
name = h.split("=")[0]
if name == PKCE_COOKIE:
assert "samesite=lax" in h.lower() and "secure" not in h, h
else:
assert "secure" in h, h
print("clear-http ok")
r = client.get("/clear-sess")
for h in r.headers.get_list("set-cookie"):13 more lines
name = h.split("=")[0]
if "__Host-" in name:
assert "secure" in h.lower() and "path=/" in h.lower(), h
elif "__Secure-" in name:
assert "secure" in h.lower(), h
else:
assert "secure" not in h, h
print("clear-sess ok")
# parser
assert parse_pkce_payload("provider=stub;state=s123;verifier=v456;next=%2Fsessions%3Fx%3Da%3Bb%26project%3Dfoo") == {"provider":"stub","state":"s123","verifier":"v456","next":"%2Fsessions%3Fx%3Da%3Bb%26project%3Dfoo"}
enc = urllib.parse.quote("provider=stub;state=s123;verifier=v456;next=%2Fsessions", safe="")
assert parse_pkce_payload(enc) == {"provider":"stub","state":"s123","verifier":"v456","next":"%2Fsessions"}$ cd /repo && python - <<'PY'
output · 3 lines
'__Host-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure' '__Secure-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure' 'hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=lax'
$ cd /repo && sed -i '83s/assert "secure" in h, h/assert "secure" in h.lower(), h/' /tmp/repro.py && python /tmp/repro.py
output · 8 lines
HTTPS set ok: __Host-hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=none; Secure HTTP set ok: hermes_session_pkce=provider%3Dstub%3Bstate%3Ds%3Bverifier%3Dv; HttpOnly; Max-Age=600; Path=/; SameSite=lax clear: __Host-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure clear: __Secure-hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure clear: hermes_session_pkce=""; HttpOnly; Max-Age=0; Path=/; SameSite=none; Secure clear-http ok clear-sess ok parser ok
$ cd /repo && sed -n 1,120p tests/hermes_cli/conftest_dashboard_auth.py
output · 47 lines
"""Stub auth provider + shared fixtures for dashboard-auth tests.
NOT a pytest conftest.py — this is an importable helper module. Phase 2
of the dashboard-OAuth plan; used by Phase 3's end-to-end gate tests.
Import via::
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
The stub bounces straight back to the callback with a fake code so tests
can complete the OAuth round trip in-process without external network.
35 more lines
Tokens are HMAC-signed JSON blobs (not real JWTs) — just enough structure
for ``verify_session`` to detect tampering and expiry.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import time
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
InvalidCodeError,
LoginStart,
RefreshExpiredError,
Session,
)
_STUB_SECRET = b"stub-test-secret-not-for-prod"
# Le
…[2991 chars omitted]…
raise InvalidCodeError("stub state/verifier mismatch")
del self._state_to_verifier[state]
now = int(time.time())
exp = now + self._default_ttl
return Session(
user_id="stub-user-1",
email="stub@example.test",
display_name="Stub User",
org_id="stub-org-1",
provider=self.name,
expires_at=exp,$ cd /repo && grep -n "gated_client\|def register_provider\|clear_providers\|base_url\|app" tests/hermes_cli/conftest_dashboard_auth.py | sed -n 1,40p; grep -rn "gated_client" tests/hermes_cli/conftest.py | head
output · 4 lines
34:# Length of HMAC-SHA256 digest. We append this many trailing bytes of 38:# digest happens to contain that byte — ``bytes.rsplit`` then splits at 48: appended as a fixed-length suffix (no separator) so binary HMAC bytes 86: # state → verifier mapping, cleared on complete_login
$ cd /repo && grep -rln "def gated_client" tests; grep -rn "gated_client" tests/hermes_cli/test_dashboard_auth_native_flow.py | head -3; sed -n 1,80p tests/hermes_cli/test_dashboard_auth_native_flow.py
output · 33 lines
tests/hermes_cli/test_dashboard_auth_native_flow.py
tests/hermes_cli/test_dashboard_auth_status_endpoint.py
128:def gated_client():
194:def test_native_authorize_rejects_non_loopback_redirect(gated_client):
196: r = gated_client.get(
"""E2E + unit tests for the RFC 8252 native-app (system-browser + loopback +
PKCE) dashboard-auth flow.
Covers:
* ``native_flow`` broker unit behaviour — PKCE binding, single-use codes,
expiry, capacity, replay resistance.
* The full ``/auth/native/authorize`` → ``/auth/callback`` →21 more lines
``/auth/native/token`` round trip in-process against ``StubAuthProvider``.
* ``/api/status`` capability advertisement (``auth_flows``).
* Cookieless bearer authentication of a gated route (the whole point of the
feature — a desktop authenticates REST with ``Authorization: Bearer`` and
sets/needs no cookie).
* ``/auth/native/refresh`` token rotatio
…[1730 chars omitted]…
native authorize must reject a password provider before "
"calling start_login"
)
class _SecondStubProvider(StubAuthProvider):
"""A second brokerable OAuth provider, so tests can create an ambiguous
multi-provider deployment."""
name = "stub2"
display_name = "Stub IdP Two (test only)"
# ---------------------------------------------------------------------------$ cd /repo && sed -n 100,180p tests/hermes_cli/test_dashboard_auth_native_flow.py
output · 45 lines
def _stub_session(exp_offset: int = 3600) -> Session:
now = int(time.time())
return Session(
user_id="u1",
email="u1@example.test",
display_name="U One",
org_id="org1",
provider="stub",
expires_at=now + exp_offset,
access_token="at-opaque",
refresh_token="rt-opaque",
)33 more lines
# ---------------------------------------------------------------------------
# Route-level E2E against StubAuthProvider
# ---------------------------------------------------------------------------
@pytest.fixture
def gated_client():
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
…[1334 chars omitted]…
d (holds broker_state + verifier).
cookies = r.cookies
# 2. Browser hits the gateway callback.
r2 = client.get(
"/auth/callback",
params={"code": cb_qs["code"][0], "state": cb_qs["state"][0]},
cookies=cookies,
)
assert r2.status_code == 302, r2.text
# 3. The callback 302s to the desktop's loopback redirect_uri.
loop = urlparse(r2.headers["location"])$ cd /repo && cat > /tmp/repro_e2e.py <<'PY'
output · 3 lines
login set-cookie: __Host-hermes_session_pkce=provider%3Dstub%3Bstate%3D3pJ8RCcU23iQc8UuakPxEw%3Bverifier%3D5TP3yfJOLjk_Vg2BlwSgbbkayLqp3klim2q3akpsAik%3Bnext%3D%252Fsessions%253Fview%253Drecent%2526project%253Dfoo; HttpOnly; Max-Age=600; Path=/; SameSite=none; Secure 302 /sessions?view=recent&project=foo e2e round trip OK
$ cd /repo && python -m pytest tests/hermes_cli/test_dashboard_auth_cookies.py tests/hermes_cli/test_dashboard_auth_native_flow.py tests/hermes_cli/test_dashboard_auth_stub_provider.py tests/hermes_cli/test_dashboard_auth_middleware.py tests/hermes_cli/test_dashboard_auth_401_reauth.py tests/hermes_cli/test_dashboard_auth_prefix.py t
output · 14 lines
tests/hermes_cli/test_dashboard_auth_native_flow.py::test_bearer_authenticates_gated_route_without_cookie tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_password_login_full_roundtrip tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_password_login_wrong_password_keeps_pending tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_password_login_wrong_password_keeps_pending tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_password_login_expired_broker_returns_400 tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_password_login_rejects_cross_provider_completion tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_password_login_rejects_cross_provider_completion /usr/local/lib/python3.12/site-packages/starlette/testclient.py:454: DeprecationWarning: Setting per-request cookies=<...> is being deprecate …[190 chars omitted]… able/how-to/capture-warnings.html =========================== short test summary info ============================ FAILED tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_authorize_empty_provider_password_only_brokers_to_login
2 more lines
FAILED tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_authorize_password_provider_redirects_to_login 2 failed, 109 passed, 8 warnings in 45.99s
$ cd /repo && python -m pytest tests/hermes_cli/test_dashboard_auth_native_flow.py::test_native_authorize_password_provider_redirects_to_login -q 2>&1 | sed -n 1,40p; sed -n 395,470p tests/hermes_cli/test_dashboard_auth_native_flow.py
output · 32 lines
F [100%]
=================================== FAILURES ===================================
__________ test_native_authorize_password_provider_redirects_to_login __________
pw_gated_client = <starlette.testclient.TestClient object at 0x733b0a763260>
def test_native_authorize_password_provider_redirects_to_login(
pw_gated_client,
):
"""Empty ``provider`` auto-picks the single password provider and lands
the system browser on /login with the broker in the PKCE cookie."""
_verifier, challenge = _make_pkce()20 more lines
r = pw_gated_client.get(
"/auth/native/authorize",
params={
"code_challenge": challenge,
"code_challenge_method": "S256",
"redirect_uri": "http://127.0.0.1:53999/cb",
"state": "des
…[4143 chars omitted]…
verifier": verifier},
).json()
assert tokens["provider"] == "testpw"
assert tokens["user_id"] == "admin"
# Cookieless bearer auth of a gated route — the point of the flow.
r2 = pw_gated_client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {tokens['access_token']}"},
)
assert r2.status_code == 200, r2.text
assert r2.json()["user_id"] == "admin"The two failures are the stale assertions expecting the old unencoded wire format (`broker=` literal), which the issue explicitly replaces with full URL-encoding — the encoded cookie is correct per the issue. Let me run the remaining related tests:
$ cd /repo && python -m pytest tests/hermes_cli/test_dashboard_register.py tests/hermes_cli/test_mcp_dashboard_oauth.py tests/plugins/dashboard_auth -q 2>&1 | tail -4
output · 4 lines
_PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]] -- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html 119 passed, 1 warning in 15.67s
$ cd /repo && sed -i 's/def parse_pkce_payload(raw: str) -> dict:/def parse_pkce_payload(raw: str) -> dict[str, str]:/' hermes_cli/dashboard_auth/cookies.py && python -c "from hermes_cli.dashboard_auth.cookies import parse_pkce_payload; print(parse_pkce_payload('a=1'))" && git diff --statoutput · 4 lines
{'a': '1'}
hermes_cli/dashboard_auth/cookies.py | 110 ++++++++++++++++++++++++++++-------
hermes_cli/dashboard_auth/routes.py | 17 +++---
2 files changed, 97 insertions(+), 30 deletions(-)$ cd /repo && git diff
output · 34 lines
diff --git a/hermes_cli/dashboard_auth/cookies.py b/hermes_cli/dashboard_auth/cookies.py index 8bcd9db..4eec04a 100644 --- a/hermes_cli/dashboard_auth/cookies.py +++ b/hermes_cli/dashboard_auth/cookies.py @@ -58,6 +58,8 @@ from __future__ import annotations from typing import Optional, Tuple +import urllib.parse + from fastapi import Request from fastapi.responses import Response
22 more lines
@@ -212,8 +214,30 @@ def set_session_cookies(
)
+def _deletion_attrs(variant: str, *, path: str, samesite: str) -> dict:
+ """Attributes for a Max-Age=0 deletion Set-Cookie.
+
+ Browsers reject a prefixed Set-Cookie that violates its prefix rules
+ (``__Host-`` requires ``Secure`` + ``Path=/``; ``__Secure-`` requires
+ ``Secure``), and a rejected deletion leaves the cookie behind — so the
+ prefixed deletions ALWAYS carry ``Secure`` (and ``__Host-`` always
+ ``Path=/``), re
…[7981 chars omitted]…
s_in = max(60, session.expires_at - int(time.time()))
@@ -899,7 +896,7 @@ async def auth_logout(request: Request):
prefix = _prefix(request)
resp = RedirectResponse(url=f"{prefix}/login", status_code=302)
clear_session_cookies(resp, prefix=prefix)
- clear_pkce_cookie(resp, prefix=prefix)
+ clear_pkce_cookie(resp, use_https=detect_https(request), prefix=prefix)
return resp$ cd /repo && grep -n "cookies" hermes_cli/dashboard_auth/__init__.py | head
All checks done. Summary of the fix:
$ done: {"notes": "Encoded PKCE cookie wire format (quote safe=\"\"), added parse_pkce_payload with legacy `;` discriminator and routed callback/password-login readers through it; PKCE cookie SameSite=None+Secure on HTTPS / Lax bare on HTTP; prefix-rule-compliant deletions for PKCE and session cookies with clear_pkce_cookie now taking use_output · 3 lines
…[66 chars omitted]… dashboard-auth tests pass except two stale literal-\"broker=\" wire assertions in test_dashboard_auth_native_flow.py that conflict with the issue-mandated encoding."} submitted.