mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(dashboard-auth): Phase 7 — SPA AuthWidget + /api/status auth fields
Phase 7 surfaces the OAuth gate state to users.
web/src/components/AuthWidget.tsx (new):
Sidebar widget that fetches /api/auth/me on mount and renders a
compact 'Logged in as <user_id…> via <provider>' row with a logout
icon. Contract V1 (Nous Portal) emits no email/display_name claims,
so user_id is the display value (truncated to 14 chars + ellipsis);
display_name and email fallthroughs are forward-compat for OQ-C1.
Renders nothing on 401 from /api/auth/me — that's the signal the
gate isn't engaged (loopback mode), in which case the widget would
be confusing.
Logout POSTs /auth/logout (which clears cookies + redirects to
/login) then full-page-navigates to /login itself; the SPA's fetch
wrapper doesn't follow that redirect, so the navigation is explicit.
web/src/App.tsx: mounts <AuthWidget /> above <SidebarFooter />.
Component is self-hiding in loopback mode so there's no need for a
conditional mount.
web/src/lib/api.ts:
- getAuthMe() + logout() helpers
- AuthMeResponse type
- StatusResponse gets optional auth_required + auth_providers fields
so the existing StatusPage can render a gated/loopback badge.
hermes_cli/web_server.py: /api/status payload now includes
- auth_required: bool — whether app.state.auth_required is True
- auth_providers: list[str] — registered DashboardAuthProvider names
Lazy-imports list_providers so early-startup status calls don't
crash if the dashboard_auth module is still being set up.
tests/hermes_cli/test_dashboard_auth_status_endpoint.py: 3 new tests
covering the new status fields in both gated and loopback modes plus
a regression that no existing field got dropped from the payload.
The hermes status CLI is unchanged in this commit — that command
tracks model providers + OAuth credentials, not running-dashboard
state. The /api/status endpoint is the canonical place to query
dashboard auth-gate state, consumed by the React StatusPage already.
This commit is contained in:
parent
5e9308b5b8
commit
2fc4615fc4
5 changed files with 319 additions and 0 deletions
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
106
tests/hermes_cli/test_dashboard_auth_status_endpoint.py
Normal file
106
tests/hermes_cli/test_dashboard_auth_status_endpoint.py
Normal file
|
|
@ -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}"
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<AuthWidget />
|
||||
<SidebarFooter />
|
||||
</aside>
|
||||
|
||||
|
|
|
|||
150
web/src/components/AuthWidget.tsx
Normal file
150
web/src/components/AuthWidget.tsx
Normal file
|
|
@ -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<AuthMeResponse | null>(null);
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
"px-5 py-2 text-[0.65rem] tracking-[0.05em] text-muted-foreground/70",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!me) {
|
||||
// Loading. Reserve the row height so the sidebar doesn't flicker
|
||||
// when the data arrives.
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-9 px-5 py-2 text-[0.65rem] text-muted-foreground/40",
|
||||
className,
|
||||
)}
|
||||
aria-busy="true"
|
||||
>
|
||||
…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-between gap-2",
|
||||
"px-5 py-2",
|
||||
"border-t border-current/10",
|
||||
"text-[0.65rem] tracking-[0.05em]",
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
aria-label={`Logged in as ${label}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-mono text-foreground/90" title={me.user_id}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground/70">
|
||||
via {me.provider}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className={cn(
|
||||
"shrink-0 rounded p-1.5 text-muted-foreground/70",
|
||||
"transition-colors hover:bg-current/10 hover:text-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-current/40",
|
||||
)}
|
||||
aria-label="Log out"
|
||||
title="Log out"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -153,6 +153,27 @@ export async function buildWsAuthParam(): Promise<[string, string]> {
|
|||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/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<AuthMeResponse>("/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<PaginatedSessions>(`/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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue