Files
hermes-agent/tests/hermes_cli/test_dashboard_auth_middleware.py
brooklyn! fbff2b8a7a fix(auth): audit API 401s and name stale app-token mint failures
A bearer 401 from the dashboard gate was returned to the client but
never written to dashboard-auth.log. Record session_rejected with the
client-facing reason, path, and IP, and never the bearer.

When that rejection is the app's saved bearer, the desktop mint error
says the app token is invalid instead of telling the user to
re-authenticate the server OAuth session.

Fixes #103117
2026-09-24 05:33:01 -05:00

421 lines
16 KiB
Python

"""End-to-end behavioural tests for the dashboard auth gate.
Uses ``StubAuthProvider`` so the OAuth round trip can complete in-process
without any external IDP. Exercises:
* `/api/status` flips from public (loopback) to gated (auth_required)
* `/` redirects to /login when no cookie present
* `/api/auth/providers` is the public bootstrap endpoint
* `/login` renders HTML listing all providers
* /assets/* still passes through unauthenticated
* Full /auth/login → /auth/callback → / round trip with the stub
* Invalid / missing cookies return 401 (api) or 302 (html)
* Zero-providers + gate-on fails closed
"""
from __future__ import annotations
import json
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def gated_app():
"""Configure web_server.app for gated mode + register the stub provider."""
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
# Use https base_url so cookies pick up Secure flag and host_header
# matches the bound interface.
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
# ---------------------------------------------------------------------------
# Allowlist (public) routes
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"next_value",
[
"<script>alert(1)</script>",
"javascript:alert(1)",
"../../etc/passwd",
"canary\r\nSet-Cookie: injected=1",
],
)
def test_empty_provider_login_page_is_safe_through_real_route(
gated_app, next_value
):
clear_providers()
response = gated_app.get("/login", params={"next": next_value})
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "no-store" in response.headers["cache-control"]
assert "Sign-in unavailable" in response.text
assert "username/password provider" in response.text
assert "OAuth provider" in response.text
assert "--insecure" not in response.text
assert next_value not in response.text
def test_gated_status_is_public(gated_app):
"""``/api/status`` MUST be public under the OAuth gate.
Regression guard for the wildcard-subdomain rollout: NAS
(``fly-provider.ts`` ``getInstanceRuntimeStatus``) hits
``/api/status`` without a cookie as its sole liveness probe. A 401
here surfaces every healthy agent as STARTING/down in the portal
UI. The endpoint returns only version + gateway/auth-gate metadata
(no user data, no session content), so it stays in the shared
``PUBLIC_API_PATHS`` allowlist under both the legacy ``_SESSION_TOKEN``
gate and the OAuth gate.
The body also reports the gate's shape (``auth_required``,
``auth_providers``) so the SPA's StatusPage and external monitors
can distinguish loopback / gated / no-providers without a separate
round trip.
"""
r = gated_app.get("/api/status")
assert r.status_code == 200, (
f"Expected 200, got {r.status_code}: {r.text}"
)
body = r.json()
assert body["auth_required"] is True
assert "version" in body
assert "gateway_state" in body
@pytest.mark.parametrize("path", [
"/api/health",
"/api/config/defaults",
"/api/config/schema",
"/api/model/info",
"/api/dashboard/themes",
"/api/dashboard/plugins",
])
def test_other_public_api_paths_are_public_under_gate(gated_app, path):
"""The remaining ``PUBLIC_API_PATHS`` entries must also bypass the
gate. They're documented as non-sensitive read-only endpoints that
the SPA pre-loads before login (themes, config schema, model
metadata). A 401 / 302-to-login here would block the dashboard
shell from rendering pre-auth.
Accept any non-auth-failure status: 200 when the route succeeds,
or any route-specific error (e.g. 400 / 404 / 500 from a missing
dependency) — but NEVER 401, and NEVER a 302 to ``/login``.
"""
r = gated_app.get(path, follow_redirects=False)
assert r.status_code != 401, (
f"{path} returned 401 under the OAuth gate — should be public"
)
if r.status_code == 302:
location = r.headers.get("location", "")
assert "/login" not in location, (
f"{path} redirected to {location} — should be public, "
"not bounced to /login"
)
# ---------------------------------------------------------------------------
# OAuth round trip
# ---------------------------------------------------------------------------
def _complete_stub_login(client) -> None:
"""Walk the stub OAuth round trip so ``client`` carries a valid session.
TestClient persists Set-Cookie across calls, so after this returns the
client's cookie jar holds ``hermes_session_at`` / ``hermes_session_rt``
and subsequent gated requests authenticate.
"""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
state = r1.headers["location"].split("state=")[1]
r2 = client.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r2.status_code == 302
def test_gated_require_token_endpoint_accepts_cookie_session(gated_app):
"""Regression: ``_require_token`` endpoints must work under the OAuth gate.
In gated mode the legacy ``_SESSION_TOKEN`` is NOT injected into the SPA
(it authenticates with the session cookie). Endpoints that call
``_require_token`` directly — plugin install/enable/disable,
``/api/dashboard/plugins/hub``, and others — used to re-check the absent
token and 401 every cookie-authenticated request, making them permanently
unreachable behind the gate (the dashboard surfaced a
``401: {"detail":"Unauthorized"}`` popup on plugin install). The fix makes
``_require_token`` defer to the gate, which has already verified the cookie
and attached ``request.state.session`` before the handler runs.
We POST a deliberately invalid plugin identifier: a passing auth layer
lets the request reach the handler, which rejects the identifier with a
400. The assertion is simply "not 401" — proving auth succeeded without
coupling to the validation message.
"""
_complete_stub_login(gated_app)
r = gated_app.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "definitely not a valid identifier",
"force": False, "enable": False},
)
assert r.status_code != 401, (
"A _require_token endpoint 401'd a cookie-authenticated request under "
f"the OAuth gate (the install-popup bug). Body: {r.text}"
)
# And specifically: it reached the handler's own validation.
assert r.status_code == 400, (
f"Expected the install handler's 400 (bad identifier), got "
f"{r.status_code}: {r.text}"
)
# A representative spread of the OTHER ``_require_token`` endpoints (there are
# 14 in total). The install popup was just the reported symptom; the same bug
# made API-key reveal, provider validation, the OAuth-provider connect flow,
# and the rest of plugin management unreachable behind the gate. Each entry is
# (method, path, json_body); we assert only that a logged-in request is NOT
# 401'd — i.e. it cleared the auth layer and reached the handler. The
# handler's own status (400/404/429/etc.) is route-specific and not asserted.
_GATED_REQUIRE_TOKEN_ROUTES = [
("get", "/api/dashboard/plugins/hub", None),
("post", "/api/env/reveal", {"key": "NONEXISTENT_ENV_VAR_FOR_TEST"}),
("post", "/api/providers/validate", {"key": "OPENAI_API_KEY", "value": ""}),
("delete", "/api/providers/oauth/__not_a_real_provider__", None),
("post", "/api/dashboard/agent-plugins/__nope__/enable", None),
]
def test_login_non_interactive_provider_returns_404_not_500(gated_app):
"""Regression: a token-only provider (drain) has no login flow, so
/auth/login?provider=drain-secret must 404 (not 500 on start_login) and it
must not appear in the /api/auth/providers bootstrap.
"""
import secrets
import plugins.dashboard_auth.drain as drain_plugin
register_provider(
drain_plugin.DrainSecretProvider(secret=secrets.token_urlsafe(48))
)
r = gated_app.get(
"/auth/login?provider=drain-secret&next=%2F", follow_redirects=False
)
assert r.status_code == 404, (
f"drain-secret login should 404, not 500: {r.status_code} {r.text}"
)
bootstrap = gated_app.get("/api/auth/providers")
assert bootstrap.status_code == 200
names = {p["name"] for p in bootstrap.json()["providers"]}
assert "drain-secret" not in names
assert "stub" in names
def test_callback_invalid_code_returns_400(gated_app):
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
r2 = gated_app.get(
f"/auth/callback?code=BAD_CODE&state={state}",
follow_redirects=False,
)
assert r2.status_code == 400
# ---------------------------------------------------------------------------
# Cookie validation
# ---------------------------------------------------------------------------
def test_invalid_cookie_returns_401_on_api(gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage-not-a-real-token")
r = gated_app.get("/api/sessions")
assert r.status_code == 401
def test_bearer_401_audits_client_facing_reason(gated_app, tmp_path):
"""A rejected app bearer is a 401 the operator can see in the audit log.
The reason written to disk is the same reason returned to the client.
The bearer value itself must not appear in the log.
"""
bearer = "stale-app-bearer-do-not-log"
response = gated_app.post(
"/api/auth/ws-ticket",
headers={"Authorization": f"Bearer {bearer}"},
)
assert response.status_code == 401
body = response.json()
assert body["reason"] == "invalid_or_expired_session"
log_path = tmp_path / "hermes_test" / "logs" / "dashboard-auth.log"
raw = log_path.read_text(encoding="utf-8")
assert bearer not in raw
rejected = [
event
for line in raw.splitlines()
if line.strip()
for event in [json.loads(line)]
if event.get("event") == "session_rejected"
]
assert rejected, raw
latest = rejected[-1]
assert latest["reason"] == body["reason"]
assert latest["path"] == "/api/auth/ws-ticket"
assert latest.get("ip")
# ---------------------------------------------------------------------------
# Identity probe
# ---------------------------------------------------------------------------
def test_api_auth_me_returns_session_after_login(gated_app):
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
r = gated_app.get("/api/auth/me")
assert r.status_code == 200
body = r.json()
assert body["user_id"] == "stub-user-1"
assert body["email"] == "stub@example.test"
assert body["display_name"] == "Stub User"
assert body["provider"] == "stub"
assert body["org_id"] == "stub-org-1"
assert "expires_at" in body
def test_api_auth_me_requires_auth(gated_app):
# No cookies.
r = gated_app.get("/api/auth/me")
assert r.status_code == 401
# ---------------------------------------------------------------------------
# Zero-providers fail-closed
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Multi-provider verify: a ProviderError from one provider must not abort the
# chain when another provider can verify the token.
# ---------------------------------------------------------------------------
class _UnreachableProvider(StubAuthProvider):
"""A provider whose IDP is unreachable: verify_session always raises.
Models the real-world bug — a self-hosted-OIDC session hits the ``nous``
provider first, which tries to reach Nous Portal's JWKS; if that's
unreachable ``nous`` raises ProviderError. The gate must keep trying the
remaining providers rather than 503-ing the whole request.
"""
name = "unreachable"
display_name = "Unreachable IdP (test only)"
def verify_session(self, *, access_token: str):
from hermes_cli.dashboard_auth.base import ProviderError
raise ProviderError("simulated: IDP/JWKS unreachable")
def refresh_session(self, *, refresh_token: str):
from hermes_cli.dashboard_auth.base import ProviderError
raise ProviderError("simulated: IDP/JWKS unreachable")
def _mint_stub_at(stub: StubAuthProvider) -> str:
"""Mint a valid access-token cookie value from a StubAuthProvider via its
own login round trip (so the HMAC signature matches what verify expects)."""
ls = stub.start_login(redirect_uri="https://fly-app.fly.dev/auth/callback")
state = dict(
seg.split("=", 1)
for seg in ls.cookie_payload["hermes_session_pkce"].split(";")
if "=" in seg
)["state"]
verifier = dict(
seg.split("=", 1)
for seg in ls.cookie_payload["hermes_session_pkce"].split(";")
if "=" in seg
)["verifier"]
session = stub.complete_login(
code="stub_code",
state=state,
code_verifier=verifier,
redirect_uri="https://fly-app.fly.dev/auth/callback",
)
return session.access_token
@pytest.fixture
def _gated_state():
"""Bare gated app-state setup WITHOUT registering any provider, so each
test controls provider registration order itself. Yields a factory that
builds the TestClient after providers are registered."""
clear_providers()
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
def _client() -> TestClient:
return 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
def test_all_providers_unreachable_returns_503(_gated_state):
"""If NO provider can verify the token AND at least one was unreachable,
surface 503 (transient outage) rather than forcing a needless re-login."""
register_provider(_UnreachableProvider())
client = _gated_state()
# Any non-empty cookie — the unreachable provider raises before parsing.
client.cookies.set(SESSION_AT_COOKIE, "some-opaque-token")
r = client.get("/api/auth/me")
assert r.status_code == 503
assert "unreachable" in r.text.lower()