Hermes-agent
This commit is contained in:
@@ -0,0 +1,643 @@
|
||||
"""Path-prefix (X-Forwarded-Prefix) awareness for the dashboard-auth gate.
|
||||
|
||||
Mission-control style deployments reverse-proxy the dashboard at a path
|
||||
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> local Caddy ->
|
||||
:9119), injecting ``X-Forwarded-Prefix: /hermes`` on every request.
|
||||
|
||||
The dashboard already honours this for the SPA bundle (rewriting asset
|
||||
URLs and the bootstrap ``__HERMES_BASE_PATH__``). The OAuth gate must
|
||||
honour it too:
|
||||
|
||||
1. The gate's ``Location:`` redirect to /login (in
|
||||
``_unauth_response``) needs to be ``/hermes/login`` so the browser
|
||||
follows it through the proxy.
|
||||
2. The 401 JSON envelope's ``login_url`` needs the same prefix so the
|
||||
SPA's full-page navigation lands at the proxied login page.
|
||||
3. ``_redirect_uri`` (the OAuth callback URL handed to the IDP) must
|
||||
reconstruct the public URL including the prefix, otherwise the IDP
|
||||
redirects back to ``/auth/callback`` instead of
|
||||
``/hermes/auth/callback`` and the user gets 404.
|
||||
4. Cookies must use ``Path=/hermes`` when behind a prefix so they
|
||||
don't leak to other apps on the same origin AND so they get sent
|
||||
back to the dashboard on subsequent requests under the prefix.
|
||||
5. The ``__Host-`` cookie prefix requires ``Path=/`` — when behind an
|
||||
X-Forwarded-Prefix we use ``__Secure-`` instead (matches every
|
||||
hardening property except scope, which the explicit ``Path``
|
||||
covers).
|
||||
|
||||
These tests document the wire-level contract so a regression in any of
|
||||
those rules surfaces before a Mission Control deploy.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
# Same xdist group as the other dashboard-auth tests — they all mutate
|
||||
# web_server.app.state.auth_required at module level.
|
||||
pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state")
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.dashboard_auth import clear_providers, register_provider
|
||||
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_app_proxied():
|
||||
"""web_server.app configured for gated mode with proxy_headers + a
|
||||
public Host that simulates the Mission Control reverse proxy.
|
||||
|
||||
The ``base_url`` sets ``host:scheme`` defaults so we don't have to
|
||||
pass them on every request. ``X-Forwarded-Prefix`` is passed
|
||||
per-request because the TestClient doesn't have a way to default
|
||||
request headers.
|
||||
"""
|
||||
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 = "mission-control.tilos.com"
|
||||
web_server.app.state.bound_port = 443
|
||||
web_server.app.state.auth_required = True
|
||||
client = TestClient(
|
||||
web_server.app,
|
||||
base_url="https://mission-control.tilos.com",
|
||||
)
|
||||
yield client
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_app_direct():
|
||||
"""web_server.app configured for gated mode WITHOUT a proxy prefix,
|
||||
for the Fly-direct deploy shape (no path mounting).
|
||||
"""
|
||||
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
|
||||
client = TestClient(
|
||||
web_server.app,
|
||||
base_url="https://fly-app.fly.dev",
|
||||
)
|
||||
yield client
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate middleware: Location: header and 401 envelope respect prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGateRedirectsCarryPrefix:
|
||||
def test_html_redirect_to_login_carries_prefix(self, gated_app_proxied):
|
||||
r = gated_app_proxied.get(
|
||||
"/sessions",
|
||||
headers={"x-forwarded-prefix": "/hermes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
# /login redirect must include the prefix or the browser will
|
||||
# follow it to mission-control.tilos.com/login (which the proxy
|
||||
# doesn't route to the dashboard).
|
||||
assert r.headers["location"].startswith("/hermes/login"), (
|
||||
f"Location header lost prefix: {r.headers['location']!r}"
|
||||
)
|
||||
|
||||
def test_api_401_envelope_login_url_carries_prefix(self, gated_app_proxied):
|
||||
r = gated_app_proxied.get(
|
||||
"/api/sessions",
|
||||
headers={"x-forwarded-prefix": "/hermes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 401
|
||||
body = r.json()
|
||||
# SPA does window.location.assign(body.login_url); this MUST
|
||||
# include the prefix.
|
||||
assert body["login_url"].startswith("/hermes/login"), (
|
||||
f"401 envelope login_url lost prefix: {body['login_url']!r}"
|
||||
)
|
||||
|
||||
def test_no_prefix_header_keeps_unprefixed_paths(self, gated_app_direct):
|
||||
"""When no X-Forwarded-Prefix is sent, the Location header must
|
||||
NOT gain a phantom prefix — the Fly-direct deploy shape has no
|
||||
proxy at all."""
|
||||
r = gated_app_direct.get("/sessions", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/login?next=%2Fsessions"
|
||||
|
||||
def test_malformed_prefix_header_is_ignored(self, gated_app_proxied):
|
||||
"""A hostile proxy injects ``X-Forwarded-Prefix: <script>``;
|
||||
the normaliser rejects it and the gate falls back to unprefixed
|
||||
URLs. Defence against header-injection HTML inside Location."""
|
||||
r = gated_app_proxied.get(
|
||||
"/sessions",
|
||||
headers={"x-forwarded-prefix": "<script>alert(1)</script>"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
assert "<script>" not in r.headers["location"]
|
||||
assert r.headers["location"].startswith("/login")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /auth/login: the OAuth redirect_uri reflects the proxy prefix
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOAuthRedirectUriRespectsPrefix:
|
||||
def test_redirect_uri_includes_prefix_in_authorize_url(
|
||||
self, gated_app_proxied
|
||||
):
|
||||
"""The IDP returns the user to the redirect_uri we sent. If we
|
||||
don't include the prefix, the IDP redirects to
|
||||
``https://mission-control.tilos.com/auth/callback`` instead of
|
||||
``https://mission-control.tilos.com/hermes/auth/callback`` — the
|
||||
former routes to the MC frontend, not the dashboard, so the
|
||||
user gets 404."""
|
||||
r = gated_app_proxied.get(
|
||||
"/auth/login?provider=stub",
|
||||
headers={"x-forwarded-prefix": "/hermes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
location = r.headers["location"]
|
||||
# The stub IDP's redirect_url echoes the redirect_uri back. The
|
||||
# real IDP would consume it and later use it to redirect the
|
||||
# user, so the byte-exact value MUST include the prefix.
|
||||
from urllib.parse import urlparse
|
||||
# Stub returns ``{redirect_uri}?code=stub_code&state=...`` — so
|
||||
# we read up to the first ``?``.
|
||||
redirect_uri = location.split("?", 1)[0]
|
||||
# Absolute https URL including prefix.
|
||||
parsed = urlparse(redirect_uri)
|
||||
assert parsed.scheme == "https"
|
||||
assert parsed.netloc == "mission-control.tilos.com"
|
||||
assert parsed.path == "/hermes/auth/callback", (
|
||||
f"redirect_uri dropped prefix: {redirect_uri!r}"
|
||||
)
|
||||
|
||||
def test_redirect_uri_no_prefix_when_direct_deploy(
|
||||
self, gated_app_direct
|
||||
):
|
||||
r = gated_app_direct.get(
|
||||
"/auth/login?provider=stub", follow_redirects=False
|
||||
)
|
||||
assert r.status_code == 302
|
||||
redirect_uri = r.headers["location"].split("?", 1)[0]
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(redirect_uri)
|
||||
assert parsed.netloc == "fly-app.fly.dev"
|
||||
assert parsed.path == "/auth/callback"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPublicUrlOverride:
|
||||
"""``dashboard.public_url`` (env override:
|
||||
``HERMES_DASHBOARD_PUBLIC_URL``) lets an operator force the absolute
|
||||
base URL the OAuth ``redirect_uri`` is built from.
|
||||
|
||||
When set, it is the *complete authority* — scheme + host + optional
|
||||
path prefix. ``X-Forwarded-Prefix`` is ignored on that code path
|
||||
because the operator has explicitly declared the public URL and we
|
||||
no longer need to guess from proxy headers. This is the relief
|
||||
valve for deploys behind reverse proxies that don't set
|
||||
``X-Forwarded-Host`` / ``X-Forwarded-Proto`` / ``X-Forwarded-Prefix``
|
||||
correctly (or at all) — manual nginx setups, on-prem ingresses,
|
||||
Fly.io deploys with custom domains where the proxy header chain is
|
||||
incomplete.
|
||||
|
||||
When unset, the existing ``proxy_headers=True`` + X-Forwarded-Prefix
|
||||
reconstruction path runs untouched. Existing Fly.io deploys
|
||||
continue to work without configuration.
|
||||
|
||||
Precedence (mirrors ``client_id``):
|
||||
|
||||
env (non-empty) > config.yaml > reconstructed from request
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def patch_config(self, monkeypatch):
|
||||
"""Replace ``hermes_cli.config.load_config`` with a stub
|
||||
returning the given ``public_url``. Pass ``None`` to set no
|
||||
config-side value."""
|
||||
|
||||
def _set(public_url) -> None:
|
||||
cfg = {}
|
||||
if public_url is not None:
|
||||
cfg = {"dashboard": {"public_url": public_url}}
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.config.load_config", lambda: cfg
|
||||
)
|
||||
|
||||
return _set
|
||||
|
||||
def _redirect_uri(self, gated_app, *, headers=None) -> str:
|
||||
"""Drive /auth/login and read the redirect_uri the IDP saw."""
|
||||
r = gated_app.get(
|
||||
"/auth/login?provider=stub",
|
||||
headers=headers or {},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302, r.text
|
||||
# Stub IDP echoes redirect_uri back as the prefix of the
|
||||
# Location header (`{redirect_uri}?code=stub_code&state=…`).
|
||||
return r.headers["location"].split("?", 1)[0]
|
||||
|
||||
def test_public_url_env_overrides_request_reconstruction(
|
||||
self, gated_app_direct, patch_config, monkeypatch
|
||||
):
|
||||
"""``HERMES_DASHBOARD_PUBLIC_URL`` wins over the URL the
|
||||
request would otherwise reconstruct to. Critical for deploys
|
||||
whose proxy headers don't match the public URL."""
|
||||
patch_config(None)
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_PUBLIC_URL", "https://custom.example",
|
||||
)
|
||||
redirect_uri = self._redirect_uri(gated_app_direct)
|
||||
assert redirect_uri == "https://custom.example/auth/callback", (
|
||||
f"public_url env var didn't override reconstruction "
|
||||
f"(got {redirect_uri!r})"
|
||||
)
|
||||
|
||||
def test_public_url_config_yaml_used_when_env_unset(
|
||||
self, gated_app_direct, patch_config, monkeypatch
|
||||
):
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_PUBLIC_URL", raising=False)
|
||||
patch_config("https://from-config.example")
|
||||
redirect_uri = self._redirect_uri(gated_app_direct)
|
||||
assert redirect_uri == "https://from-config.example/auth/callback"
|
||||
|
||||
def test_env_overrides_config_public_url(
|
||||
self, gated_app_direct, patch_config, monkeypatch
|
||||
):
|
||||
"""Precedence pin — env wins over config.yaml. Fly.io / CI
|
||||
secret injection depends on this ordering."""
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_PUBLIC_URL", "https://from-env.example",
|
||||
)
|
||||
patch_config("https://from-config.example")
|
||||
redirect_uri = self._redirect_uri(gated_app_direct)
|
||||
assert redirect_uri == "https://from-env.example/auth/callback", (
|
||||
"env var must override config.yaml — Fly secret injection "
|
||||
"depends on this precedence"
|
||||
)
|
||||
|
||||
def test_public_url_with_path_prefix_baked_in(
|
||||
self, gated_app_direct, patch_config, monkeypatch
|
||||
):
|
||||
"""When public_url already carries a path prefix
|
||||
(``https://example.com/hermes``), the OAuth callback URL is
|
||||
the path appended verbatim. The operator is declaring the
|
||||
whole authority; we trust them."""
|
||||
patch_config(None)
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/hermes",
|
||||
)
|
||||
redirect_uri = self._redirect_uri(gated_app_direct)
|
||||
assert redirect_uri == "https://example.com/hermes/auth/callback"
|
||||
|
||||
def test_public_url_ignores_x_forwarded_prefix(
|
||||
self, gated_app_proxied, patch_config, monkeypatch
|
||||
):
|
||||
"""X-Forwarded-Prefix is the auto-reconstruction signal; when
|
||||
public_url is set we no longer need to guess, and stacking the
|
||||
prefix on top would double-prefix in the common case where
|
||||
the operator already baked their prefix into public_url."""
|
||||
patch_config(None)
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/already-prefixed",
|
||||
)
|
||||
redirect_uri = self._redirect_uri(
|
||||
gated_app_proxied,
|
||||
headers={"x-forwarded-prefix": "/should-be-ignored"},
|
||||
)
|
||||
assert (
|
||||
redirect_uri == "https://example.com/already-prefixed/auth/callback"
|
||||
), (
|
||||
f"public_url should suppress X-Forwarded-Prefix layering, "
|
||||
f"got {redirect_uri!r}"
|
||||
)
|
||||
|
||||
def test_public_url_strips_trailing_slash(
|
||||
self, gated_app_direct, patch_config, monkeypatch
|
||||
):
|
||||
"""``https://example.com/`` and ``https://example.com`` must
|
||||
produce identical results — no ``//auth/callback`` double slash."""
|
||||
patch_config(None)
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_PUBLIC_URL", "https://example.com/",
|
||||
)
|
||||
redirect_uri = self._redirect_uri(gated_app_direct)
|
||||
assert redirect_uri == "https://example.com/auth/callback"
|
||||
|
||||
def test_malformed_public_url_falls_through_to_reconstruction(
|
||||
self, gated_app_direct, patch_config, monkeypatch
|
||||
):
|
||||
"""Defence against header injection: a public_url that doesn't
|
||||
parse as ``http(s)://host[/path]`` is dropped and we fall back
|
||||
to request reconstruction. The login flow continues to work
|
||||
rather than dispatching the user to a hostile URL."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
patch_config(None)
|
||||
for bad in [
|
||||
"javascript:alert(1)",
|
||||
"ftp://example.com",
|
||||
"example.com", # missing scheme
|
||||
"https://", # missing host
|
||||
'https://example.com/"injected', # quote char
|
||||
"https://example.com/\nhttps://evil", # CRLF injection
|
||||
]:
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", bad)
|
||||
redirect_uri = self._redirect_uri(gated_app_direct)
|
||||
# Fell through to request reconstruction — netloc is the
|
||||
# bound host, NOT the hostile value.
|
||||
parsed = urlparse(redirect_uri)
|
||||
assert parsed.netloc == "fly-app.fly.dev", (
|
||||
f"malformed public_url={bad!r} leaked into redirect_uri: "
|
||||
f"{redirect_uri!r}"
|
||||
)
|
||||
assert parsed.path == "/auth/callback"
|
||||
|
||||
def test_empty_public_url_env_treated_as_unset(
|
||||
self, gated_app_direct, patch_config, monkeypatch
|
||||
):
|
||||
"""Same defensive behaviour as the other env vars in this
|
||||
plugin — an empty env var doesn't shadow a valid config.yaml
|
||||
entry."""
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", "")
|
||||
patch_config("https://from-config.example")
|
||||
redirect_uri = self._redirect_uri(gated_app_direct)
|
||||
assert redirect_uri == "https://from-config.example/auth/callback"
|
||||
|
||||
def test_scheme_less_public_url_env_warns_operator(
|
||||
self, patch_config, monkeypatch, caplog
|
||||
):
|
||||
"""A non-empty env var that's missing its scheme (the #1 cause
|
||||
of "I set HERMES_DASHBOARD_PUBLIC_URL but the callback is still
|
||||
http://") must emit an operator-facing WARNING rather than being
|
||||
silently discarded. Regression for #42780."""
|
||||
import logging
|
||||
|
||||
from hermes_cli.dashboard_auth import prefix as prefix_mod
|
||||
|
||||
# Reset the per-value dedup cache so the warning fires in-test
|
||||
# regardless of test ordering.
|
||||
prefix_mod._warned_malformed_public_urls.clear()
|
||||
patch_config(None)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", "hermes.domain.com")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=prefix_mod.__name__):
|
||||
result = prefix_mod.resolve_public_url()
|
||||
|
||||
assert result == "" # scheme-less value is still rejected
|
||||
warnings = [
|
||||
r.getMessage()
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING
|
||||
]
|
||||
assert any(
|
||||
"HERMES_DASHBOARD_PUBLIC_URL" in m
|
||||
and "hermes.domain.com" in m
|
||||
and "scheme" in m
|
||||
for m in warnings
|
||||
), f"expected a scheme warning, got: {warnings!r}"
|
||||
|
||||
def test_scheme_less_public_url_warning_is_deduplicated(
|
||||
self, patch_config, monkeypatch, caplog
|
||||
):
|
||||
"""resolve_public_url runs per-request; the malformed-value
|
||||
warning must fire at most once per distinct value so a
|
||||
misconfigured deploy doesn't flood the logs."""
|
||||
import logging
|
||||
|
||||
from hermes_cli.dashboard_auth import prefix as prefix_mod
|
||||
|
||||
prefix_mod._warned_malformed_public_urls.clear()
|
||||
patch_config(None)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_PUBLIC_URL", "hermes.domain.com")
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=prefix_mod.__name__):
|
||||
for _ in range(5):
|
||||
prefix_mod.resolve_public_url()
|
||||
|
||||
scheme_warnings = [
|
||||
r
|
||||
for r in caplog.records
|
||||
if r.levelno == logging.WARNING
|
||||
and "hermes.domain.com" in r.getMessage()
|
||||
]
|
||||
assert len(scheme_warnings) == 1, (
|
||||
f"expected exactly one warning across 5 calls, "
|
||||
f"got {len(scheme_warnings)}"
|
||||
)
|
||||
|
||||
def test_valid_public_url_emits_no_warning(
|
||||
self, patch_config, monkeypatch, caplog
|
||||
):
|
||||
"""A correctly-formed value must not produce a spurious warning."""
|
||||
import logging
|
||||
|
||||
from hermes_cli.dashboard_auth import prefix as prefix_mod
|
||||
|
||||
prefix_mod._warned_malformed_public_urls.clear()
|
||||
patch_config(None)
|
||||
monkeypatch.setenv(
|
||||
"HERMES_DASHBOARD_PUBLIC_URL", "https://hermes.domain.com"
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger=prefix_mod.__name__):
|
||||
result = prefix_mod.resolve_public_url()
|
||||
|
||||
assert result == "https://hermes.domain.com"
|
||||
assert not [
|
||||
r for r in caplog.records if r.levelno == logging.WARNING
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cookies: Path attribute + __Host- / __Secure- prefix rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCookiePathRespectsPrefix:
|
||||
"""Cookies must use ``Path=<prefix>`` when behind a proxy so they:
|
||||
|
||||
a) get sent back to the dashboard on subsequent requests (browser
|
||||
only sends a cookie if the request path starts with the cookie's
|
||||
Path attribute);
|
||||
b) don't leak to other apps mounted alongside the dashboard
|
||||
(e.g. ``mission-control.tilos.com/billing/...``).
|
||||
|
||||
When the cookie's Path can be ``/`` (no prefix, Fly-direct), we use
|
||||
the ``__Host-`` cookie prefix for additional hardening — it binds
|
||||
the cookie to the exact host (no Domain attribute) and requires Secure.
|
||||
"""
|
||||
|
||||
def test_pkce_cookie_uses_prefix_path(self, gated_app_proxied):
|
||||
r = gated_app_proxied.get(
|
||||
"/auth/login?provider=stub",
|
||||
headers={"x-forwarded-prefix": "/hermes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
cookies = r.headers.get_list("set-cookie")
|
||||
pkce = next(c for c in cookies if "hermes_session_pkce" in c)
|
||||
# Browser only sends cookie back if the request path is under
|
||||
# the cookie's Path attribute, so we need /hermes here. Bare
|
||||
# /-rooted cookies would still be sent but would also be sent
|
||||
# to /billing/... etc.
|
||||
assert "Path=/hermes" in pkce, (
|
||||
f"PKCE cookie has wrong Path: {pkce!r}"
|
||||
)
|
||||
|
||||
def test_pkce_cookie_uses_secure_prefix_when_proxied(
|
||||
self, gated_app_proxied
|
||||
):
|
||||
"""Behind a proxy with Path != /, ``__Host-`` is disallowed
|
||||
(the spec requires Path=/). Fall back to ``__Secure-``, which
|
||||
carries the same Secure-required guarantee but allows any Path.
|
||||
"""
|
||||
r = gated_app_proxied.get(
|
||||
"/auth/login?provider=stub",
|
||||
headers={"x-forwarded-prefix": "/hermes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
cookies = r.headers.get_list("set-cookie")
|
||||
# The PKCE cookie name carries the __Secure- prefix.
|
||||
pkce_candidates = [
|
||||
c for c in cookies
|
||||
if c.startswith("__Secure-hermes_session_pkce=")
|
||||
]
|
||||
assert pkce_candidates, (
|
||||
f"PKCE cookie missing __Secure- prefix: {cookies!r}"
|
||||
)
|
||||
|
||||
def test_pkce_cookie_uses_host_prefix_when_direct(
|
||||
self, gated_app_direct
|
||||
):
|
||||
"""Fly-direct deploy: Path=/ is available, so we can use the
|
||||
stricter ``__Host-`` prefix. This binds the cookie to the
|
||||
exact origin (no Domain attribute) — best practice for
|
||||
single-host single-app deploys."""
|
||||
r = gated_app_direct.get(
|
||||
"/auth/login?provider=stub", follow_redirects=False
|
||||
)
|
||||
cookies = r.headers.get_list("set-cookie")
|
||||
pkce_candidates = [
|
||||
c for c in cookies
|
||||
if c.startswith("__Host-hermes_session_pkce=")
|
||||
]
|
||||
assert pkce_candidates, (
|
||||
f"PKCE cookie missing __Host- prefix on direct deploy: "
|
||||
f"{cookies!r}"
|
||||
)
|
||||
# __Host- requires Path=/ and Secure (cookies spec); both must
|
||||
# be present even if a regression flips one off.
|
||||
pkce = pkce_candidates[0]
|
||||
assert "Path=/" in pkce
|
||||
assert "Secure" in pkce
|
||||
|
||||
def test_loopback_cookies_unprefixed(self):
|
||||
"""Loopback HTTP dev: no Secure, no __Host- / __Secure-.
|
||||
The bare cookie name is the right choice — neither prefix is
|
||||
spec-compatible without Secure."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import Response
|
||||
from hermes_cli.dashboard_auth.cookies import set_pkce_cookie
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/set")
|
||||
def _set():
|
||||
r = Response("ok")
|
||||
set_pkce_cookie(r, payload="x", use_https=False)
|
||||
return r
|
||||
|
||||
client = TestClient(app)
|
||||
r = client.get("/set")
|
||||
cookies = r.headers.get_list("set-cookie")
|
||||
# Bare cookie name, no prefix.
|
||||
assert any(c.startswith("hermes_session_pkce=") for c in cookies), (
|
||||
f"Loopback cookie should be bare-named: {cookies!r}"
|
||||
)
|
||||
# And no __Host- / __Secure- variant accidentally emitted.
|
||||
assert not any(
|
||||
c.startswith("__Host-") or c.startswith("__Secure-")
|
||||
for c in cookies
|
||||
)
|
||||
|
||||
def test_cookies_read_back_round_trip_through_prefix(
|
||||
self, gated_app_proxied
|
||||
):
|
||||
"""The end-to-end property: after a successful OAuth round
|
||||
trip via the proxy, the session-AT cookie carries the
|
||||
__Secure- prefix AND Path=/hermes, so the next request under
|
||||
the same prefix is authenticated.
|
||||
|
||||
Note on TestClient semantics: starlette's TestClient sees the
|
||||
literal request path (``/auth/login``, ``/auth/callback``) —
|
||||
not the public path the proxy displays to the browser
|
||||
(``/hermes/auth/login``, ``/hermes/auth/callback``). A cookie
|
||||
set with ``Path=/hermes`` would therefore NOT be sent back on
|
||||
the second request through TestClient even though it WOULD be
|
||||
sent by a real browser hitting ``/hermes/auth/callback``. To
|
||||
avoid baking that mismatch into the test, we inspect the
|
||||
``Set-Cookie`` header on the callback's response WITHOUT
|
||||
depending on the PKCE cookie round-tripping through
|
||||
TestClient's jar — we drive /auth/callback with an explicit
|
||||
Cookie header that carries the PKCE value from /auth/login.
|
||||
"""
|
||||
# /auth/login sets the PKCE cookie. Capture it from Set-Cookie.
|
||||
r1 = gated_app_proxied.get(
|
||||
"/auth/login?provider=stub",
|
||||
headers={"x-forwarded-prefix": "/hermes"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
pkce_set = next(
|
||||
c for c in r1.headers.get_list("set-cookie")
|
||||
if "hermes_session_pkce" in c
|
||||
)
|
||||
# Parse "__Secure-hermes_session_pkce=...; HttpOnly; ...".
|
||||
pkce_kv = pkce_set.split(";", 1)[0] # "__Secure-hermes_session_pkce=value"
|
||||
state = r1.headers["location"].split("state=")[1]
|
||||
|
||||
# Round-trip the cookie by hand because TestClient's jar won't
|
||||
# automatically send a Path=/hermes cookie to a /auth/callback
|
||||
# request path.
|
||||
r2 = gated_app_proxied.get(
|
||||
f"/auth/callback?code=stub_code&state={state}",
|
||||
headers={
|
||||
"x-forwarded-prefix": "/hermes",
|
||||
"cookie": pkce_kv,
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert r2.status_code == 302, r2.text
|
||||
cookies = r2.headers.get_list("set-cookie")
|
||||
at_cookies = [
|
||||
c for c in cookies
|
||||
if c.startswith("__Secure-hermes_session_at=")
|
||||
]
|
||||
assert at_cookies, (
|
||||
f"session_at missing __Secure- prefix: {cookies!r}"
|
||||
)
|
||||
assert "Path=/hermes" in at_cookies[0]
|
||||
assert "Secure" in at_cookies[0]
|
||||
assert "HttpOnly" in at_cookies[0]
|
||||
Reference in New Issue
Block a user