Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions hermes_cli/dashboard_auth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/dashboard_auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions hermes_cli/dashboard_auth/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions plugins/dashboard_auth/basic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
72 changes: 72 additions & 0 deletions tests/hermes_cli/test_dashboard_auth_middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<pw-only>
(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"
15 changes: 15 additions & 0 deletions web/src/pages/CronPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down