diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 2c5f5b4f7b95..899235a66a17 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -179,12 +179,15 @@ def _auto_sso_response(request: Request) -> Response | None: # token-only credentials (drain/service providers) are never candidates. providers = list_session_providers() if len(providers) != 1: - # Zero → nothing to redirect to. Two+ → user must choose at /login. + # Zero -> nothing to redirect to. Two+ -> user must choose at /login. return None from hermes_cli.dashboard_auth.prefix import prefix_from_request provider = providers[0] + # Password-only providers have no OAuth redirect flow; skip auto-redirect. + if getattr(provider, "supports_password", False): + return None prefix = prefix_from_request(request) next_param = _safe_next_target(request) from urllib.parse import quote diff --git a/hermes_cli/dashboard_auth/routes.py b/hermes_cli/dashboard_auth/routes.py index 568a11957be4..ca9c589d881f 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -195,6 +195,11 @@ async def auth_login(request: Request, provider: str, next: str = ""): try: ls = p.start_login(redirect_uri=_redirect_uri(request)) + except NotImplementedError as e: + raise HTTPException( + status_code=400, + detail=str(e), + ) except ProviderError as e: audit_log( AuditEvent.LOGIN_FAILURE, diff --git a/tests/hermes_cli/test_dashboard_auth_middleware.py b/tests/hermes_cli/test_dashboard_auth_middleware.py index 7c1d6a9c2b21..97be9e883431 100644 --- a/tests/hermes_cli/test_dashboard_auth_middleware.py +++ b/tests/hermes_cli/test_dashboard_auth_middleware.py @@ -325,6 +325,59 @@ def test_login_non_interactive_provider_returns_404_not_500(gated_app): assert "stub" in names +def test_password_only_provider_prevents_auto_oauth_redirect_and_gates_login(gated_app): + """Regression: when the only session provider is password-only, + the auto-SSO redirect must NOT 500 on start_login(). + + Previously _auto_sso_response() picked the sole provider and called + start_login(), which raised NotImplementedError, surfacing as 500. + After the fix the middleware skips auto-redirect and falls through + to the /login interstitial, and /auth/login returns 400 instead of 500. + """ + clear_providers() + from hermes_cli.dashboard_auth.base import DashboardAuthProvider + class PasswordOnlyProvider(DashboardAuthProvider): + name = "basic-like" + display_name = "Password Only" + supports_password = True + # supports_session defaults to True, so list_session_providers includes us + def start_login(self, *, redirect_uri): + raise NotImplementedError("password-only") + def complete_login(self, *, code, state, code_verifier, redirect_uri): + raise NotImplementedError + def verify_session(self, *, access_token): + return None + def refresh_session(self, *, refresh_token): + raise NotImplementedError + def revoke_session(self, *, refresh_token): + pass + + register_provider(PasswordOnlyProvider()) + + try: + r = gated_app.get("/", follow_redirects=False) + assert r.status_code == 302, f"Expected 302, got {r.status_code}: {r.text}" + # Must land on /login, NOT /auth/login?provider=basic-like (which would 500) + assert "/login" in r.headers["location"], r.headers["location"] + assert "auth/login" not in r.headers["location"], r.headers["location"] + + login = gated_app.get(r.headers["location"], follow_redirects=False) + assert login.status_code == 200, login.text + # The login interstitial should contain the password form, not an error page + assert "Sign in" in login.text or "password" in login.text.lower(), login.text[:200] + + # Direct access to /auth/login?provider=basic-like should 400, not 500 + auth_login = gated_app.get( + "/auth/login?provider=basic-like", follow_redirects=False + ) + assert auth_login.status_code == 400, ( + f"Expected 400 on start_login for password-only provider, got {auth_login.status_code}: {auth_login.text}" + ) + finally: + clear_providers() + register_provider(StubAuthProvider()) + + def test_callback_without_pkce_cookie_returns_400(gated_app): # No prior /auth/login → no PKCE cookie. r = gated_app.get(