From e294b00fcc9fecbdf6d73847b02db3565b55e7b9 Mon Sep 17 00:00:00 2001 From: Jon Date: Tue, 7 Jul 2026 13:05:12 +0000 Subject: [PATCH] fix(dashboard-auth): route password-capable providers to /login instead of /auth/login BasicAuthProvider and other password-capable providers do not implement the OAuth redirect flow in start_login(). The auto-sso middleware was sending them to /auth/login, where start_login() raises NotImplementedError and the request returns HTTP 500. Restrict the OAuth auto-redirect to providers that actually need it. When a provider exposes password login, redirect to the unified login form instead. Fixes auth redirection bug for dashboard instances using basic auth. --- hermes_cli/dashboard_auth/middleware.py | 21 +++++ .../test_dashboard_auth_middleware.py | 84 +++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 2c5f5b4f7b95..1a7ca9ff5f6a 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -185,6 +185,27 @@ def _auto_sso_response(request: Request) -> Response | None: from hermes_cli.dashboard_auth.prefix import prefix_from_request provider = providers[0] + if getattr(provider, "supports_password", False): + # Password-capable providers do not need the OAuth redirect entry + # point. Send the browser to the unified login page instead so the + # credential form can render without hitting the unimplemented + # start_login flow. + prefix = prefix_from_request(request) + next_param = _safe_next_target(request) + resp = RedirectResponse( + url=( + f"{prefix}/login?next={next_param}" + if next_param + else f"{prefix}/login" + ), + status_code=302, + ) + from hermes_cli.dashboard_auth.cookies import detect_https + set_sso_attempt_cookie( + resp, use_https=detect_https(request), prefix=prefix + ) + return resp + prefix = prefix_from_request(request) next_param = _safe_next_target(request) from urllib.parse import quote diff --git a/tests/hermes_cli/test_dashboard_auth_middleware.py b/tests/hermes_cli/test_dashboard_auth_middleware.py index 7c1d6a9c2b21..97c9eca051e9 100644 --- a/tests/hermes_cli/test_dashboard_auth_middleware.py +++ b/tests/hermes_cli/test_dashboard_auth_middleware.py @@ -593,3 +593,87 @@ def test_unverifiable_token_with_reachable_providers_redirects(_gated_state): r = client.get("/api/auth/me") assert r.status_code == 401 assert "unreachable" not in r.text.lower() + + +class _PasswordOnlyProvider(StubAuthProvider): + """A password-capable provider: same provider flags as the bundled basic plugin.""" + + name = "password-only" + display_name = "Password Only" + supports_password = True + supports_session = True + + def complete_password_login(self, *, username: str, password: str) -> "Session": + from hermes_cli.dashboard_auth.base import InvalidCredentialsError, Session + import time + + if username != "admin" or password != "hunter2": + raise InvalidCredentialsError("bad creds") + exp = int(time.time()) + 3600 + return Session( + user_id="admin", + email="", + display_name="admin", + org_id="", + provider=self.name, + expires_at=exp, + access_token="testpw.at." + "0" * 60, + refresh_token="testpw.rt." + "0" * 60, + ) + + +def _complete_password_login(client, username="admin", password="hunter2"): + r = client.post( + "/auth/password-login", + json={ + "provider": "password-only", + "username": username, + "password": password, + "next": "", + }, + ) + assert r.status_code == 200, r.text + return r.json()["next"] + + +def test_password_only_provider_auto_ss_redirects_to_login(_gated_state): + """An unauthenticated browser load must not auto-redirect to /auth/login + for a password-capable provider; it should land on /login so the + credential form can render.""" + register_provider(_PasswordOnlyProvider()) + client = _gated_state() + r = client.get("/", follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"] in ("/login", "/login?next=%2F") + + +def test_password_only_provider_login_form_renders(_gated_state): + register_provider(_PasswordOnlyProvider()) + client = _gated_state() + r = client.get("/login") + assert r.status_code == 200 + assert "Sign in with Password Only" in r.text + assert "/auth/password-login" in r.text + assert "/auth/login?provider=password-only" not in r.text + + +def test_password_only_provider_successful_login_returns_ok(_gated_state): + """POST /auth/password-login returns ok and sets session cookies.""" + register_provider(_PasswordOnlyProvider()) + client = _gated_state() + r = client.post( + "/auth/password-login", + json={ + "provider": "password-only", + "username": "admin", + "password": "hunter2", + "next": "", + }, + ) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert any( + cookie.lower().startswith("hermes_session_at=") + for cookie in client.cookies + )