From 12f7c136e443c4ee1b61e6cccac1628f2439823b Mon Sep 17 00:00:00 2001 From: Adam Younce Date: Thu, 2 Jul 2026 11:01:47 -0400 Subject: [PATCH 1/2] fix(dashboard-auth): don't auto-SSO to /auth/login for password-only providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a single interactive provider registered, the auth gate's auto-SSO middleware unconditionally 302s any unauthenticated document load to /auth/login?provider=. For a password-only provider (the bundled basic plugin), start_login() raises NotImplementedError, so every fresh browser's FIRST page load returns a raw HTTP 500; only the second hit within the 60s loop-guard window falls through to the /login form. This affects every basic-auth-only deployment. Two small, defensive changes: * Add a supports_redirect_login capability flag to DashboardAuthProvider (default True, alongside supports_password / supports_token / supports_session), set it False in BasicAuthProvider, and have _auto_sso_response fall through to the /login interstitial when the single provider lacks a redirect flow. The credential form renders immediately and next= is preserved — the right UX for a password provider anyway (there is no interstitial click to save). * Backstop in /auth/login: catch NotImplementedError from start_login and 302 to {prefix}/login?next=... so direct or stale links to /auth/login?provider=basic land on the form instead of a 500. OAuth providers are unaffected (flag defaults True; regression test pins the existing auto-SSO behaviour). --- hermes_cli/dashboard_auth/base.py | 12 ++++ hermes_cli/dashboard_auth/middleware.py | 11 +++ hermes_cli/dashboard_auth/routes.py | 15 ++++ plugins/dashboard_auth/basic/__init__.py | 4 ++ .../test_dashboard_auth_middleware.py | 72 +++++++++++++++++++ 5 files changed, 114 insertions(+) diff --git a/hermes_cli/dashboard_auth/base.py b/hermes_cli/dashboard_auth/base.py index 8f376f352108..9dd9315b36fc 100644 --- a/hermes_cli/dashboard_auth/base.py +++ b/hermes_cli/dashboard_auth/base.py @@ -159,6 +159,18 @@ class DashboardAuthProvider(ABC): # and are completely unaffected. supports_password: bool = False + # When True, this provider implements the OAuth redirect flow — + # ``start_login`` returns a real redirect to an IDP. Password-only + # providers set this False so the auto-SSO middleware never bounces an + # unauthenticated document load to ``/auth/login`` for them (their + # ``start_login`` raises NotImplementedError; the right first hop is + # the ``/login`` credential form, which renders immediately and + # preserves ``next=``). Mirrors ``supports_password`` / + # ``supports_token``: a capability flag the gate consults instead of + # guessing. Defaults True because interactive providers have + # historically all been OAuth redirect providers. + supports_redirect_login: bool = True + # When True, this provider can verify a non-interactive bearer token # (``verify_token``) presented on a single request by a service-to-service # caller — no login, no cookie, no refresh. This is the generic diff --git a/hermes_cli/dashboard_auth/middleware.py b/hermes_cli/dashboard_auth/middleware.py index 2c5f5b4f7b95..183ce1adee6e 100644 --- a/hermes_cli/dashboard_auth/middleware.py +++ b/hermes_cli/dashboard_auth/middleware.py @@ -185,6 +185,17 @@ def _auto_sso_response(request: Request) -> Response | None: from hermes_cli.dashboard_auth.prefix import prefix_from_request provider = providers[0] + + # Auto-SSO only makes sense for providers with an OAuth redirect flow. + # A password-only provider (supports_redirect_login=False) has no IDP to + # bounce to — its start_login raises NotImplementedError — so redirecting + # would 500 on the very first unauthenticated page load. Fall through to + # the ordinary /login interstitial instead: the credential form renders + # immediately and ``next=`` is preserved, which is the correct UX for a + # password provider anyway (there is no interstitial click to save). + if not getattr(provider, "supports_redirect_login", True): + 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..92dd251568fe 100644 --- a/hermes_cli/dashboard_auth/routes.py +++ b/hermes_cli/dashboard_auth/routes.py @@ -195,6 +195,21 @@ async def auth_login(request: Request, provider: str, next: str = ""): try: ls = p.start_login(redirect_uri=_redirect_uri(request)) + except NotImplementedError: + # Backstop for providers with no OAuth redirect flow (password-only, + # supports_redirect_login=False). The middleware no longer auto-SSOs + # to this route for them, but a direct or stale /auth/login link must + # land on the login form (with ``next=`` preserved), not a raw 500. + from urllib.parse import quote + + safe_next = _validate_post_login_target(next) + prefix = _prefix(request) + url = ( + f"{prefix}/login?next={quote(safe_next, safe='')}" + if safe_next + else f"{prefix}/login" + ) + return RedirectResponse(url=url, status_code=302) except ProviderError as e: audit_log( AuditEvent.LOGIN_FAILURE, diff --git a/plugins/dashboard_auth/basic/__init__.py b/plugins/dashboard_auth/basic/__init__.py index 12ec0fe51355..5c221e576cf2 100644 --- a/plugins/dashboard_auth/basic/__init__.py +++ b/plugins/dashboard_auth/basic/__init__.py @@ -204,6 +204,10 @@ class BasicAuthProvider(DashboardAuthProvider): name = "basic" display_name = "Username & Password" supports_password = True + # No OAuth redirect flow: start_login raises NotImplementedError. This + # tells the auto-SSO middleware to fall through to the /login credential + # form instead of bouncing to /auth/login (which would 500). + supports_redirect_login = False def __init__( self, diff --git a/tests/hermes_cli/test_dashboard_auth_middleware.py b/tests/hermes_cli/test_dashboard_auth_middleware.py index 7c1d6a9c2b21..8d17e58fd216 100644 --- a/tests/hermes_cli/test_dashboard_auth_middleware.py +++ b/tests/hermes_cli/test_dashboard_auth_middleware.py @@ -593,3 +593,75 @@ 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() + + +# --------------------------------------------------------------------------- +# Password-only providers: auto-SSO must fall through to /login, never 500 +# --------------------------------------------------------------------------- + + +class _PasswordOnlyStub(StubAuthProvider): + """Interactive password provider with NO OAuth redirect flow. + + Mirrors plugins/dashboard_auth/basic: supports_password=True, + supports_redirect_login=False, start_login raises NotImplementedError. + """ + + name = "pwstub" + display_name = "Password Stub" + supports_password = True + supports_redirect_login = False + + def start_login(self, *, redirect_uri): + raise NotImplementedError( + "password-only provider; no OAuth redirect flow" + ) + + +def test_password_only_single_provider_lands_on_login_not_500(gated_app): + """Regression: with a single password-only provider (the self-hosted + basic-auth deployment), a fresh browser's FIRST hit to any page used to + auto-SSO to /auth/login, whose start_login raised NotImplementedError → + raw HTTP 500. The gate must instead fall through to the /login + credential form with ``next=`` preserved.""" + clear_providers() + register_provider(_PasswordOnlyStub()) + + r = gated_app.get("/sessions", follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"] == "/login?next=%2Fsessions", ( + f"expected the /login interstitial, got {r.headers['location']}" + ) + # Following the redirect renders the login page — no 500 anywhere. + r2 = gated_app.get(r.headers["location"]) + assert r2.status_code == 200 + assert "Password Stub" in r2.text + + +def test_auth_login_redirect_backstop_for_password_only_provider(gated_app): + """Regression: a direct or stale link to /auth/login?provider= + (bookmarks, older clients, other single-provider code paths) must 302 to + the /login form rather than surfacing start_login's NotImplementedError + as a 500.""" + clear_providers() + register_provider(_PasswordOnlyStub()) + + r = gated_app.get( + "/auth/login?provider=pwstub&next=%2Flogs", follow_redirects=False + ) + assert r.status_code == 302 + assert r.headers["location"] == "/login?next=%2Flogs" + + r_no_next = gated_app.get( + "/auth/login?provider=pwstub", follow_redirects=False + ) + assert r_no_next.status_code == 302 + assert r_no_next.headers["location"] == "/login" + + +def test_oauth_provider_auto_sso_unchanged(gated_app): + """The capability flag defaults True: OAuth providers keep the Phase 1 + auto-SSO redirect behaviour exactly as before.""" + r = gated_app.get("/sessions", follow_redirects=False) + assert r.status_code == 302 + assert r.headers["location"] == "/auth/login?provider=stub&next=%2Fsessions" From 571cfcbb57836c735eb4f6136a81ed57c1cb7b2f Mon Sep 17 00:00:00 2001 From: Adam Younce Date: Thu, 2 Jul 2026 11:03:18 -0400 Subject: [PATCH 2/2] feat(dashboard): auto-refresh CronPage every 30s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CronPage loaded its job list on mount and after the user's own mutations only — a cron job firing, failing, or being edited by the CLI/another process stayed invisible until a manual page reload. SessionsPage already polls for exactly this reason (separate processes share one session DB; no push channel yet). Add a silent 30s background refresh alongside the existing mount load, mirroring the SessionsPage pattern: errors in a background tick are swallowed rather than toasted, and the interval re-arms when the selected profile changes. Stopgap until a server-push state channel exists. --- web/src/pages/CronPage.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index ee894c28e701..76939ee91af4 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -607,6 +607,21 @@ export default function CronPage() { loadJobs(); }, [loadJobs]); + // Cron state changes outside this tab — the scheduler recording a run + // result, CLI/other-process edits — and there is no push channel (the same + // gap SessionsPage polls around), so refresh silently every 30s. Errors + // are swallowed: a failed background tick must not toast; the next user + // action surfaces problems through loadJobs' own handler. + useEffect(() => { + const id = setInterval(() => { + api + .getCronJobs(selectedProfile) + .then(setJobs) + .catch(() => {}); + }, 30_000); + return () => clearInterval(id); + }, [selectedProfile]); + // Load resources from the profile the create/edit form actually targets. // Pass "default" explicitly so the global dashboard profile switch cannot // redirect a default-profile cron form to some other profile.