diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e2211c3c307..a077401313e 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -653,6 +653,19 @@ async def get_status(): except Exception: pass + # Dashboard auth gate (Phase 7): surface whether the gate is engaged + # and which providers are registered so ``hermes status`` and the + # SPA's StatusPage can show "OAuth gate ON via Nous Research" or + # "loopback only — no auth gate" with no extra round trips. + auth_required = bool(getattr(app.state, "auth_required", False)) + auth_providers: list[str] = [] + try: + from hermes_cli.dashboard_auth import list_providers as _list_providers + auth_providers = [p.name for p in _list_providers()] + except Exception: + # Module not importable yet (early startup) — leave as []. + pass + return { "version": __version__, "release_date": __release_date__, @@ -669,6 +682,8 @@ async def get_status(): "gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at, "active_sessions": active_sessions, + "auth_required": auth_required, + "auth_providers": auth_providers, } diff --git a/tests/hermes_cli/test_dashboard_auth_status_endpoint.py b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py new file mode 100644 index 00000000000..3b10917a1d4 --- /dev/null +++ b/tests/hermes_cli/test_dashboard_auth_status_endpoint.py @@ -0,0 +1,106 @@ +"""Phase 7 — /api/status exposes auth-gate state + AuthWidget integration. + +The dashboard's status endpoint now reports ``auth_required`` and +``auth_providers`` so the AuthWidget + StatusPage can render the +correct "gated / loopback" badge without a separate round trip. This +test asserts both shapes (gated and loopback). + +The AuthWidget itself is .tsx — no Python test here. The widget's +behaviour (renders nothing on 401, shows truncated user_id, etc.) is +documented in AuthWidget.tsx; covered manually via the Phase 4.2 +smoke test against staging Portal. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from hermes_cli import web_server +from hermes_cli.dashboard_auth import clear_providers, register_provider +from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider + +# These tests mutate ``web_server.app.state.auth_required`` so they share +# the same xdist group as the other dashboard-auth gated_app tests. +pytestmark = pytest.mark.xdist_group("dashboard_auth_app_state") + + +@pytest.fixture +def gated_client(): + 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 + 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 + + +@pytest.fixture +def loopback_client(): + 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 = "127.0.0.1" + web_server.app.state.bound_port = 8080 + web_server.app.state.auth_required = False + client = TestClient(web_server.app, base_url="http://127.0.0.1:8080") + yield client + 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 _login(client: TestClient) -> None: + """Drive the stub OAuth round trip so the gated client is authed.""" + 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_status_reports_auth_required_in_gated_mode(gated_client): + _login(gated_client) + r = gated_client.get("/api/status") + assert r.status_code == 200 + body = r.json() + assert body["auth_required"] is True + assert body["auth_providers"] == ["stub"] + + +def test_status_reports_auth_disabled_in_loopback_mode(loopback_client): + r = loopback_client.get("/api/status") + assert r.status_code == 200 + body = r.json() + assert body["auth_required"] is False + # Loopback mode has no registered providers (the Nous plugin's env + # vars aren't set in test). + assert body["auth_providers"] == [] + + +def test_status_preserves_existing_fields(loopback_client): + """Defence-in-depth: adding auth_required/auth_providers must not + have dropped any previous field (the dashboard's React StatusPage + relies on the full payload shape).""" + r = loopback_client.get("/api/status") + body = r.json() + expected_keys = { + "version", "release_date", "hermes_home", "config_path", "env_path", + "config_version", "latest_config_version", "gateway_running", + "gateway_pid", "gateway_health_url", "gateway_state", + "gateway_platforms", "gateway_exit_reason", "gateway_updated_at", + "active_sessions", "auth_required", "auth_providers", + } + missing = expected_keys - set(body.keys()) + assert not missing, f"/api/status dropped fields: {missing}" diff --git a/web/src/App.tsx b/web/src/App.tsx index aeac02ae789..6220ed26313 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -52,6 +52,7 @@ import { cn } from "@/lib/utils"; import { Backdrop } from "@/components/Backdrop"; import { SidebarFooter } from "@/components/SidebarFooter"; import { SidebarStatusStrip } from "@/components/SidebarStatusStrip"; +import { AuthWidget } from "@/components/AuthWidget"; import { PageHeaderProvider } from "@/contexts/PageHeaderProvider"; import { useSystemActions } from "@/contexts/useSystemActions"; import type { SystemAction } from "@/contexts/system-actions-context"; @@ -583,6 +584,7 @@ export default function App() { + diff --git a/web/src/components/AuthWidget.tsx b/web/src/components/AuthWidget.tsx new file mode 100644 index 00000000000..94d1b572c68 --- /dev/null +++ b/web/src/components/AuthWidget.tsx @@ -0,0 +1,150 @@ +/** + * AuthWidget — sidebar "Logged in as …" affordance for the dashboard + * OAuth gate (Phase 7 of .hermes/plans/2026-05-21-dashboard-oauth-auth.md). + * + * Renders nothing in loopback / --insecure mode. In gated mode, fetches + * /api/auth/me on mount and surfaces: + * + * - the user_id (truncated to 14 chars + ellipsis) since the Nous Portal + * contract V1 doesn't emit email/display_name claims (Contract Anchor + * C4 in the plan; the API responds with empty strings for those + * fields, so we use user_id as the display value) + * - the provider's display_name (looked up from /api/auth/providers, + * defaults to the bare provider key) + * - a logout button that POSTs /auth/logout and full-page-navigates to + * /login (the dashboard becomes inaccessible again) + * + * Failure modes: + * - 401 from /api/auth/me means we're not gated (or the gate is on but + * we have no cookie — in that case the gate's middleware would have + * redirected us before App.tsx renders, so we won't see this). The + * widget renders nothing. + * - Network error: shows a minimal "auth status unavailable" message + * so the user knows the widget tried. + */ + +import { useEffect, useState } from "react"; +import { api, type AuthMeResponse } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { LogOut } from "lucide-react"; + +interface AuthWidgetProps { + className?: string; +} + +/** Truncate ``user_id`` to fit a small UI without revealing the full + * opaque identifier. 14 chars is enough to disambiguate users in a + * small org and short enough to fit a single sidebar row. */ +function truncateUserId(id: string): string { + if (id.length <= 14) return id; + return `${id.slice(0, 14)}…`; +} + +export function AuthWidget({ className }: AuthWidgetProps) { + const [me, setMe] = useState(null); + const [hidden, setHidden] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + api + .getAuthMe() + .then((data) => { + if (cancelled) return; + setMe(data); + }) + .catch((err: unknown) => { + if (cancelled) return; + // 401 from /api/auth/me means the gate isn't engaged in this + // process (loopback mode) — render nothing. fetchJSON throws an + // Error with the status code as a prefix; the global 401 + // handler only redirects on the structured envelope, so a plain + // 401 from /api/auth/me with no envelope bubbles up here. + const msg = err instanceof Error ? err.message : String(err); + if (msg.startsWith("401:") || msg.startsWith("403:")) { + setHidden(true); + return; + } + setError("auth status unavailable"); + }); + return () => { + cancelled = true; + }; + }, []); + + if (hidden) return null; + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!me) { + // Loading. Reserve the row height so the sidebar doesn't flicker + // when the data arrives. + return ( +
+ … +
+ ); + } + + const handleLogout = () => { + void api.logout(); + }; + + // Prefer display_name → email → truncated user_id. Contract V1 only + // populates user_id; the fallthroughs are forward-compat for a future + // Portal that adds a userinfo endpoint (OQ-C1 in the plan). + const label = me.display_name || me.email || truncateUserId(me.user_id); + + return ( +
+
+ + {label} + + + via {me.provider} + +
+ +
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4e61972f226..9f001c0aa7b 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -153,6 +153,27 @@ export async function buildWsAuthParam(): Promise<[string, string]> { export const api = { getStatus: () => fetchJSON("/api/status"), + /** + * Identity probe for the dashboard auth gate (Phase 7). + * + * Returns the verified Session as JSON when gated mode is active and a + * valid cookie is attached. Loopback mode is unaffected — the endpoint + * still exists but is never useful there (no Session, no cookie). The + * AuthWidget component swallows 401s from this call: if the gate isn't + * engaged, /api/auth/me returns 401 and the widget renders nothing. + */ + getAuthMe: () => fetchJSON("/api/auth/me"), + logout: () => + fetch(`${BASE}/auth/logout`, { + method: "POST", + credentials: "include", + }).then((r) => { + // /auth/logout returns 302 → /login. Follow that with a full-page + // navigation rather than letting fetch() opaquely consume the + // redirect — the SPA needs to leave the protected area. + window.location.assign("/login"); + return r; + }), getSessions: (limit = 20, offset = 0) => fetchJSON(`/api/sessions?limit=${limit}&offset=${offset}`), getSessionMessages: (id: string) => @@ -433,6 +454,23 @@ export const api = { }), }; +/** Identity payload returned by ``GET /api/auth/me`` (Phase 7). + * + * Returned by the dashboard's gated middleware when a valid session cookie + * is attached. ``email`` and ``display_name`` are empty strings under the + * Nous Portal contract V1 (the access token has no email/name claims — + * see Contract Anchor C4 in the plan). The AuthWidget surfaces a + * truncated ``user_id`` instead. + */ +export interface AuthMeResponse { + user_id: string; + email: string; + display_name: string; + org_id: string; + provider: string; + expires_at: number; +} + export interface ActionResponse { name: string; ok: boolean; @@ -456,6 +494,14 @@ export interface PlatformStatus { export interface StatusResponse { active_sessions: number; + /** Phase 7: ``true`` when the dashboard's OAuth gate is engaged + * (public bind, no ``--insecure``). Read alongside ``auth_providers`` + * to render a "gated / loopback" badge. */ + auth_required?: boolean; + /** Phase 7: registered ``DashboardAuthProvider`` names (e.g. ``["nous"]``). + * Empty in loopback mode; empty + ``auth_required=true`` is a + * fail-closed state (the dashboard will refuse to bind). */ + auth_providers?: string[]; config_path: string; config_version: number; env_path: string;