Adds a 'Contract Anchor' section at the top of the plan summarizing the 11 material findings from nous-account-service PR #180's published contract. Rewrites Phase 4 (Nous provider) and Phase 6 (re-auth UX) in-place; the v1 drafts are preserved inline marked 'rejected — preserved for archeology' for reviewer context. Phases 0–3 (already shipped) are unaffected — they set up gate engagement and cookie plumbing only. The cookies module's RT cookie becomes dead in Phase 6 task 6.3 and is removed there. Key contract-driven reversals: - client_id is per-instance (agent:{id}), env-injected — not static - audience is bare client_id, not 'hermes-cli:' prefixed - scope is 'agent_dashboard:access' only - JWT claims do NOT include email/name — surface user_id instead - no refresh tokens in V1 — 401 → redirect to /login - JWKS-only verification, no userinfo fallback - redirect_uri is exact-match per AgentInstance, not wildcard Phase 7's AuthWidget needs to display user_id (truncated) instead of email; one-line annotation added at the top of that phase.
187 KiB
Dashboard OAuth Authentication Implementation Plan
For Hermes: Use
subagent-driven-developmentskill to implement this plan task-by-task.
Goal: Put an OAuth login gate in front of hermes dashboard whenever it binds to a non-loopback host without --insecure. Default provider is Nous Portal (authorization-code + PKCE); the provider layer is plugin-extensible so others (Google, GitHub, self-hosted OIDC, header-trust) can be added later without modifying core.
Architecture:
Three new layers slot into the existing hermes_cli/web_server.py:
- A dashboard-auth provider registry in
hermes_cli/dashboard_auth/exposing a smallDashboardAuthProviderprotocol (name,display_name,start_login(),complete_login(callback_params),verify_session(cookies),refresh_session(cookies),revoke_session(cookies)). Providers are registered through a newctx.register_dashboard_auth_provider(provider)plugin hook, mirroringregister_image_gen_provider/register_memory_provider. - A default Nous provider plugin at
plugins/dashboard-auth-nous/that talks to the Portal's authorization-code + PKCE endpoints (developed innous-account-service). Token verification reuses the existing/api/oauth/accountuserinfo path; refresh reuses the device-flowPOST /oauth/tokenwithgrant_type=refresh_token. Bundled with Hermes so the default install Just Works™. - An auth gate in
hermes_cli/web_server.py:- A FastAPI middleware (ordered before
auth_middleware) that gates everything whenauth.required is True, except routes under/auth/, the narrow login-bootstrap public API, and the/loginHTML. - Three new routes (
/auth/login,/auth/callback,/auth/logout) plus/api/auth/providers,/api/auth/me,/api/auth/refresh. - HttpOnly
SecureSameSite=Laxcookies:hermes_session_at(Portal access JWT) andhermes_session_rt(Portal refresh token, opaque). Plushermes_session_pkcefor the short-lived PKCE state during the OAuth round-trip. - PTY/WS auth: server-side endpoint
/api/auth/ws-ticketmints a short-lived single-use ticket from a valid cookie; the SPA passes it as?ticket=to/api/ptyand/api/ws. The existing_SESSION_TOKENbecomes legacy and is unused in auth-required mode (the SPA reads its cookie identity, not the injected script).
- A FastAPI middleware (ordered before
Tech Stack:
- Python: FastAPI, httpx, PyJWT (already in deps via
josein NAS but we'll usepyjwtsincecryptographyis already pulled in), cryptography (existing). - Frontend: minimal server-rendered Jinja-style HTML for
/login(no React bundle); the existing React SPA gets a small "load cookie identity from/api/auth/meinstead ofwindow.__HERMES_SESSION_TOKEN__" change. - Portal side: depends on a new authorization-code endpoint pair on
portal.nousresearch.com. This plan documents the contract; cross-repo work happens innous-account-service.
⚠️ Contract Anchor (Plan v2 — re-validated 2026-05-21)
Source of truth: nous-account-service PR #180 docs/agent-dashboard-oauth-contract.md (link).
The Portal's published contract differs from our initial assumptions in several material ways. This plan was revised after fetching PR #180 to bring our design into compliance. Phases 0–3 (already implemented) are NOT affected — they only set up the gate-engagement plumbing, not the OAuth payload specifics. Phases 4, 6, and 7 are rewritten below; the original drafts are kept inline as "Original (rejected)" blocks so reviewers can see why each call was made.
Material findings from the contract
| # | Topic | Original plan said | Contract requires |
|---|---|---|---|
| C1 | client_id |
Static hermes-dashboard (one OAuth client for all dashboards) |
Per-instance synthesized: agent:{AgentInstance.id}. Portal injects HERMES_DASHBOARD_OAUTH_CLIENT_ID at provisioning time. Dashboard MUST NOT attempt OAuth if missing. |
| C2 | Audience claim | aud = "hermes-cli:hermes-dashboard" |
aud = client_id (bare, no prefix). Each dashboard verifies tokens issued specifically to itself. |
| C3 | Scopes | openid profile email inference:invoke tool:invoke |
agent_dashboard:access only (or omit; default). No OIDC scopes are honoured for this flow. |
| C4 | Token claims | email, email_verified, name, session_id |
These are NOT emitted. Available claims: iss, aud, exp, sub (user_id), client_id, agent_instance_id, org_id, scope, token_use, product_id, nous_client, plus optional rate-limit hints. No email, no display name. |
| C5 | Refresh tokens | Refresh-in-cookie (Phase 6 silent-refresh) | No refresh tokens in V1. 401 → re-auth via full /oauth/authorize redirect. |
| C6 | Token TTL | Not pinned | 900 seconds (15 minutes). With no refresh, users re-auth every 15 min of active use. |
| C7 | Verification mode | JWKS or userinfo fallback | JWKS only — GET /.well-known/jwks.json, RS256. There is no userinfo endpoint in the contract. |
| C8 | Redirect URI | https://*.fly.dev/auth/callback wildcard |
Two shapes only: exact https://{flyAppName}.fly.dev/auth/callback (per AgentInstance.flyAppName) OR http://{localhost,127.0.0.1}:{any-port}/auth/callback (unconditional carve-out, including prod). |
| C9 | Agent-instance check | Not in plan | Contract recommends defense-in-depth: after aud passes, verify claims["agent_instance_id"] == HERMES_DASHBOARD_AGENT_INSTANCE_ID (or extract from client_id). |
| C10 | Env var names | HERMES_DASHBOARD_AUTH_NOUS_PORTAL_URL |
Portal injects HERMES_DASHBOARD_PORTAL_URL at provisioning. Match exactly. |
| C11 | oauth_contract_version claim |
Not in plan | Contract says 1; verifiers must check before trusting other claims. (Current code does NOT actually emit this — flagged back to Portal team. Treat as tolerant: if missing, proceed with a warning log; if present and != 1, refuse.) |
What this kills
- Phase 6 (silent refresh) is entirely deleted. Replaced by a thinner "401 → re-auth redirect" UX. The refresh-token cookie (
hermes_session_rt) is removed from the design — there is no refresh token to put in it. - The "userinfo fallback verification mode" in Phase 4 is deleted. JWKS is mandatory.
- The
Session.email/Session.display_namefields stay (we already shipped them in Phase 1) but the Nous provider will populate them with empty strings + the user's opaque Portalsubclaim as a fallback display value. Phase 7's AuthWidget shows the truncateduser_id, not a name/email. - The plan's promise of "all claims in the access token" is broken by the contract — but in our favor: less round-tripping, just less to display.
What stays the same
- All of Phases 0–3 (gate engagement, plugin hook, middleware, cookies for the access token only, routes, login page) are unaffected. The cookie machinery already only requires the access token to be set; we'll simply pass
refresh_token=""everywhere. - The "fail closed if zero providers" + "fail closed if
HERMES_DASHBOARD_OAUTH_CLIENT_IDis missing" stories combine cleanly: in the gated-public path, both must be true. - Multi-provider plugin extensibility stays — only Nous ships, but the architecture supports adding a
Custom OIDCprovider later.
Decision Log updates (resolving the above)
| Original Q | New resolution |
|---|---|
| QC — Refresh strategy (c2) | REVERSED to c1 (no refresh). Contract V1 has no refresh tokens. |
| Q5 — Redirect URI (wildcard) | REVERSED. Each AgentInstance has a single canonical Fly URL it was provisioned at; the dashboard reads its own HERMES_DASHBOARD_OAUTH_CLIENT_ID to derive the instance id and uses request.url_for("auth_callback") (under proxy_headers=True) for the redirect. Localhost dev: only localhost and 127.0.0.1 work, never 0.0.0.0. |
| Q6 — JWT claims (all in access token) | PARTIALLY HONORED. All claims that DO exist are in the JWT — but email/name aren't in the contract. AuthWidget surfaces user_id (truncated) instead. |
| Q12 — Operator setup (zero config) | REVISED. Zero config for the bundled flow (Portal-managed Fly agent), because the Portal injects the env vars at provisioning. The dashboard is NOT meant to be self-hosted with OAuth in V1 — operator-owned dashboards stay loopback-only or --insecure. |
Open Questions added
| OQ-C1 | Should the AuthWidget make a separate authenticated call to a Portal userinfo endpoint to surface email/name? Punt for V1; show truncated user_id. Revisit if a Portal /api/oauth/userinfo endpoint lands. |
| OQ-C2 | The contract documents an oauth_contract_version claim but the issuer code doesn't emit it. Flag back to Portal team in PR #180 review. Hermes implementation logs a warning and proceeds if absent (tolerant); refuses if present and != 1. |
| OQ-C3 | Staging Portal lives at portal.rewbs.uk. Smoke test against that before considering Phase 4 done. |
Background From The Codebase
Findings from grepping hermes_cli/web_server.py, hermes_cli/auth.py, hermes_cli/plugins.py, and the Portal nous-account-service repo:
Current state
_SESSION_TOKEN(hermes_cli/web_server.py:86) is generated fresh at server start and injected into the SPAindex.htmlvia<script>window.__HERMES_SESSION_TOKEN__="…"</script>(_serve_index, ~line 3685). Every browser that canGET /reads it. The token gates all/api/...routes except_PUBLIC_API_PATHSviaauth_middleware(~line 237).- DNS-rebinding defense lives in
host_header_middleware(~line 207). It compares the inboundHostheader againstapp.state.bound_hostset bystart_server. We must preserve this; the new auth gate is an additional layer on top. --insecureand--hostwire throughhermes_cli/main.py:13140–13157→cmd_dashboard(~10282) →start_server(host=, allow_public=getattr(args, "insecure", False))(~10338).start_server(web_server.py:4514) raisesSystemExitifhost not in _LOCALHOST and not allow_public./api/statusis in_PUBLIC_API_PATHSbecause the sidebar polls it pre-token; it returns version, profile, gateway state, etc. The login page only needs version + auth-bootstrap info, so we will narrow what's public.- PTY auth (
/api/pty,/api/ws,/api/pub,/api/events) uses the SPA-injected token as a?token=query param (web_server.py:3530, 3562, 3591). Browsers cannot setAuthorizationon WebSocket upgrade, so query-param auth stays — but the token source flips from "injected script" to "server-minted short-lived ticket from cookie". - Plugin registry is at
hermes_cli/plugins.py(PluginContextclass). It already hasregister_context_engine,register_image_gen_provider,register_memory_provider,register_video_gen_provider(~lines 499, 531, 558). Addingregister_dashboard_auth_providerfollows the exact same pattern. - Existing Nous OAuth in Hermes is device flow only (
hermes_cli/auth.py:73–190,PROVIDER_REGISTRY["nous"]). It already speaksportal.nousresearch.com, knowsclient_id="hermes-cli", scopesinference:invoke tool:invoke, and persists tokens to~/.hermes/auth.jsonunderproviders.nous. The dashboard auth flow is distinct from this: it's a user-identity session for the operator, not an inference credential for the agent. They share Portal infrastructure but live in different stores (cookies vs.auth.json) and use different client_ids and scopes. auth.jsonkeys forproviders.nous(confirmed from disk):access_token,refresh_token,client_id,portal_base_url,inference_base_url,token_type,scope,obtained_at,expires_at,agent_key,agent_key_expires_at,tls,agent_key_id,agent_key_expires_in,agent_key_reused,agent_key_obtained_at,expires_in. The dashboard session does NOT need agent_key fields — those are inference-side.
Portal endpoints already shipped
In /home/ben/nous/nous-account-service/src/app/api/oauth/:
POST /api/oauth/device/code— device-flow code request (existing, NOT used by dashboard).POST /api/oauth/device/verify— device-flow approval (existing, NOT used by dashboard).POST /api/oauth/token— token exchange + refresh (existing). Acceptsgrant_type=urn:ietf:params:oauth:grant-type:device_codetoday; needs to also acceptgrant_type=authorization_code(cross-repo work).GET /api/oauth/account— userinfo (existing, returns{userId, orgId, ...}after JWT validation). Reusable as-is for verifying a JWT carried in the dashboard cookie.
Portal endpoints to be developed (cross-repo dependency)
This plan assumes but does not implement the Portal side. The contract Hermes will speak:
GET https://portal.nousresearch.com/oauth/authorize?response_type=code&client_id=hermes-dashboard&redirect_uri=<dashboard_callback>&scope=openid+profile+email+inference%3Ainvoke+tool%3Ainvoke&state=<csrf>&code_challenge=<S256>&code_challenge_method=S256— browser-redirect endpoint that prompts the logged-in Portal user to approve the Hermes dashboard, then 302s toredirect_uri?code=<auth_code>&state=<csrf>.POST https://portal.nousresearch.com/api/oauth/token(existing endpoint, extended) — acceptsgrant_type=authorization_code&code=<auth_code>&code_verifier=<pkce>&client_id=hermes-dashboard&redirect_uri=<dashboard_callback>and returns{access_token, refresh_token, token_type: "Bearer", expires_in, scope}. The access token is a JWT with the claims listed below.
The client_id hermes-dashboard must be added to the Portal's OAUTH_CLIENT_PRODUCT_CONTEXT_MAP (src/server/oauth/access-token-issuer.ts:49). The Portal-side change is tracked separately; this plan flags every place Hermes assumes that client_id is registered.
Redirect URI handling (per Q5): the initial design only needs to support Fly.io-hosted dashboards. The Portal will whitelist https://*.fly.dev/auth/callback for the hermes-dashboard client_id. Other deployments (custom domains, on-prem) are out of scope for v1; operators with those needs would register their own Portal OAuth client and override via config (dashboard.auth.providers.nous.client_id, future work).
JWT claims expected on the access token (per Q6 — all claims in the access token, no userinfo round-trip required)
{
"iss": "https://portal.nousresearch.com",
"sub": "<userId>",
"aud": "hermes-cli:hermes-dashboard",
"exp": <unix>,
"iat": <unix>,
"client_id": "hermes-dashboard",
"scope": "openid profile email inference:invoke tool:invoke",
"org_id": "<orgId>",
"email": "<user email>",
"email_verified": true,
"name": "<display name>",
"session_id": "<server-side session id; opaque>"
}
org_id, email, name are net-new to the existing Portal access-token-issuer.ts payload (today it carries userId, orgId, client_id, aud, sub, exp, iat, session_id, scope, rate-limit entitlement). Cross-repo work item: extend issueOAuthAccessToken to include email, email_verified, name when the scope includes profile email. The Portal already has all three fields on the User row, so this is purely a claim-projection change.
JWT signing
Portal currently signs with AUTH_SECRET (HS256) and only opens RS256 via OAUTH_PRIVATE_KEY for specific paths. Hermes will verify with the public JWKS at https://portal.nousresearch.com/.well-known/jwks.json — this requires the Portal to migrate dashboard-issued tokens to RS256/JWKS. Tracked as a cross-repo prerequisite. If JWKS isn't ready by Phase 4, we fall back to validating the JWT via GET /api/oauth/account (a network round-trip per request, cacheable for 60s), which is correct but slower. The plan's verification module supports both modes from day one and picks based on nous.signing_mode: jwks | userinfo.
Key Design Decisions
| # | Decision | Why |
|---|---|---|
| 1 | Auth gate ONLY when host != loopback AND --insecure not set. |
Matches Q1+Q2. Loopback stays zero-friction; --insecure stays as escape hatch with current "no-auth" semantics; new behavior fires for any other non-loopback bind. |
| 2 | Stateless server, refresh-in-cookie. | Q9 + Q-C2. No server-side session store, but the refresh cookie lets us silently refresh expired access tokens so the dashboard survives all-day tabs. |
| 3 | DashboardAuthProvider is a plugin hook (ctx.register_dashboard_auth_provider). |
Q-A. Mirrors existing plugin shapes; the Nous provider ships as plugins/dashboard-auth-nous/ so third parties have a verbatim template. |
| 4 | Multiple stacked providers; login page lists all. | Q-D. With only Nous installed it's a one-button page. No dashboard.auth.provider selector — the operator chooses at login time. |
| 5 | Server-rendered /login / /auth/callback / /auth/logout. |
Q-E (e1) + Q15. Pre-login pages must NOT load the React bundle (which would also load window.__HERMES_SESSION_TOKEN__). Jinja-style HTML rendered straight from FastAPI. |
| 6 | Narrow /api/auth/providers for the login-page bootstrap; remove /api/status from _PUBLIC_API_PATHS only when gate is active. |
Q15. When gate is off (loopback), nothing changes — /api/status stays public for sidebar polling. When gate is on, the login HTML hits only the narrow endpoint; post-login SPA continues to read /api/status (now auth-gated). |
| 7 | WS auth = short-lived ticket from /api/auth/ws-ticket. |
Browsers cannot set Authorization on WS upgrade. Tickets are random 32-byte tokens, single-use, 30-second TTL, minted only after cookie verification. Replaces ?token=<_SESSION_TOKEN> in auth-required mode. |
| 8 | --tui (embedded PTY) works in gated mode. |
Q11. Same ticket flow. |
| 9 | Audit log to ~/.hermes/logs/dashboard-auth.log (JSON-lines). |
Q14. One line per `login_start |
| 10 | Fail-closed if zero providers are registered AND gate is active. | Q13. Loopback mode never hits this. Non-loopback mode with no provider plugins installed = start_server raises SystemExit("dashboard auth gate is enabled but no auth providers are registered"). |
| 11 | PKCE (S256) is mandatory for the authorization-code flow. | Defense-in-depth even though Portal will also enforce. |
| 12 | Cookies: Secure when bound on TLS, omitted otherwise. SameSite=Lax always. HttpOnly always. Path scoped to /. |
TLS termination for Fly.io is upstream; we detect X-Forwarded-Proto: https to decide. |
Decision Log (Open Questions resolved during planning)
| Q | Resolution |
|---|---|
Q1 — --insecure semantics |
Keep current "no auth, no warning" behavior. Auth gate engages only for non-loopback binds where --insecure was NOT passed. |
| Q2 — Loopback auth opt-in | Out of scope for v1. Punt to a future dashboard.auth.required: true config knob. |
| Q3 — VPS/Fly path | Primary use case for v1. Cross-repo Portal redirect-URI whitelist must accept https://*.fly.dev/auth/callback. |
| Q4 — OAuth flow | Authorization Code + PKCE (S256). |
| Q5 — Redirect URI | https://*.fly.dev/auth/callback wildcard for v1. Operators with other deployments register their own Portal client (future, not in v1). |
| Q6 — JWT claims | All claims in access token. Portal must extend access-token-issuer.ts to add email, email_verified, name for profile email scope. |
| Q7 — Other providers | Google, GitHub, OIDC, etc. Not implemented; abstraction must support them. |
| Q8 — Plugin vs in-tree | Plugin. plugins/dashboard-auth-nous/ is the default; third parties drop in ~/.hermes/plugins/dashboard-auth-*/. |
| Q9 — Session model | Stateless. JWT-in-cookie. |
| Q10 — Multi-user | Single user only. No per-user UI state. |
Q11 — --tui interaction |
Same auth applies; WS ticketing flow. |
| Q12 — Operator setup | Zero config. Baked-in client_id=hermes-dashboard + Portal URL. |
| Q13 — Portal down | Fail-closed. Provider plugin's verify_session raises → middleware returns 503 with a "Portal unreachable, try again" page. |
| Q14 — Audit log | Yes. ~/.hermes/logs/dashboard-auth.log (JSON-lines). |
Q15 — /api/status |
Stays public when gate is off. When gate is on, becomes auth-gated; login HTML uses /api/auth/providers for bootstrap. |
| QC — Refresh strategy | Refresh-in-cookie (c2). Access token in hermes_session_at, refresh token in hermes_session_rt. Server-side /api/auth/refresh silently rotates both when the access token is within 60s of expiry. |
| QD — Provider selection | Multi-provider stack; login page lists all registered providers. |
| QE — Login route shape | /auth/login, /auth/callback, /auth/logout, server-rendered HTML. |
Phases Overview
| # | Phase | Shippable on its own? | Exit gate |
|---|---|---|---|
| 0 | Regression harness + auth-gate detection skeleton | Yes (no behavior change) | All existing tests pass; new tests assert "gate is OFF in loopback mode" and "gate is ON for non-loopback w/o --insecure" |
| 1 | DashboardAuthProvider protocol + plugin hook + audit logger |
Yes (no provider registers yet → gate fails-closed only if anyone tried to enable it) | register_dashboard_auth_provider works in unit test; nothing changes for the loopback dashboard |
| 2 | plugins/dashboard-auth-nous/ provider (no Portal integration yet — uses a stub) |
No (skipped in CI; bench tested w/ Portal stub) | Stub provider passes the provider protocol contract test; /auth/login returns a redirect to the stub |
| 3 | Auth gate middleware + cookie machinery + /auth/* routes + audit log |
Yes for stub provider | End-to-end test: bind to 0.0.0.0 with stub provider; GET / redirects to /auth/login; complete stub OAuth round-trip; receive cookie; GET /api/status now 200s |
| 4 | Real Nous provider implementation (JWKS verify OR userinfo fallback) | Once Portal RS256/JWKS lands or with userinfo mode | Real OAuth round-trip against staging Portal succeeds end-to-end |
| 5 | WS ticket auth (/api/auth/ws-ticket) + SPA cookie identity refit + remove window.__HERMES_SESSION_TOKEN__ injection in gated mode |
Yes | --tui works in gated mode; /api/pty?ticket=… accepted; no token leaks to unauthenticated index.html |
| 6 | Refresh-in-cookie machinery (/api/auth/refresh, silent-refresh middleware) |
Yes | Test: access token mocked to expire in 30s; subsequent request silently refreshes; cookie is rotated |
| 7 | Documentation, dashboard sidebar "Logged in as …" widget, CLI status integration | Yes | hermes status shows dashboard auth state; docs updated |
Cross-Repo Coordination Checklist
Tracked separately from the task list; Hermes-side work is independent except where called out.
| Item | Repo | Owner | Required by Phase |
|---|---|---|---|
Add hermes-dashboard to OAUTH_CLIENT_PRODUCT_CONTEXT_MAP |
nous-account-service |
Portal team | Phase 4 |
Implement GET /oauth/authorize (browser approval UI) |
nous-account-service |
Portal team | Phase 4 |
Extend POST /api/oauth/token to accept grant_type=authorization_code with PKCE |
nous-account-service |
Portal team | Phase 4 |
Extend issueOAuthAccessToken to include email, email_verified, name claims when scope includes profile email |
nous-account-service |
Portal team | Phase 4 |
Whitelist https://*.fly.dev/auth/callback as redirect URI for hermes-dashboard client |
nous-account-service |
Portal team | Phase 4 |
(Optional, future) Publish JWKS at /.well-known/jwks.json and migrate dashboard tokens to RS256 |
nous-account-service |
Portal team | Phase 4 enhancement (Phase 4 ships with signing_mode=userinfo fallback first) |
If Portal isn't ready by Phase 4, the plugin ships with signing_mode=userinfo as default and switches to jwks once the JWKS endpoint is live. No Hermes code change needed for the swap.
Open Questions (still TBD — flag at Phase 4 kickoff)
-
Cookie domain in multi-tenant Fly setup. Each Fly app gets its own subdomain (
hermes-agent-prod-<id>.fly.dev). Cookies will be set scoped to that exact host with noDomainattribute — works for single-tenant. If we ever serve multiple Hermes dashboards from sibling subdomains and want SSO across them, revisit. Not in v1. -
Org selection UX. A user with multiple orgs at Portal would today see the org_id baked into the JWT by Portal's resolution logic (probably their default org). If Portal exposes an org-selection step in
/oauth/authorize, we get it for free. If not, the dashboard will be bound to whichever org Portal picked, and switching requires/auth/logout+ re-login. Defer; revisit when first multi-org operator complains. -
Session length policy. Refresh tokens are valid for 30 days on the Portal side today. We honor that. We do NOT add a separate Hermes-side maximum-session-age cap. If sec-eng wants one later, it goes in the audit log + a cookie expiry override.
Phase 0 — Regression Harness + Gate-Detection Skeleton
Goal: Lock current dashboard behavior with tests, then introduce a single should_require_auth(host, allow_public) helper that returns True for non-loopback + non---insecure binds. Nothing actually gates yet; this phase just installs the predicate that later phases branch on.
Why TDD-first: auth middleware is load-bearing infrastructure. Per writing-plans, infra changes need a behavioral harness against the current code before we touch anything.
Task 0.1: Write the dashboard-auth regression harness
Objective: A pytest module that exercises today's _SESSION_TOKEN flow end-to-end against the live FastAPI app, so we can prove later phases don't regress loopback behavior.
Files:
- Create:
tests/hermes_cli/test_dashboard_auth_gate.py
Step 1: Write the harness.
"""Regression harness for the dashboard auth gate.
Phase 0 — establish a baseline pin on the current (pre-OAuth) behavior so
later phases can prove they didn't break loopback mode.
"""
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
@pytest.fixture
def client_loopback(monkeypatch):
# Reset bound_host between tests
web_server.app.state.bound_host = "127.0.0.1"
web_server.app.state.bound_port = 9119
return TestClient(web_server.app)
def test_loopback_status_is_public(client_loopback):
"""`/api/status` must remain reachable without a token in loopback mode."""
r = client_loopback.get("/api/status")
assert r.status_code == 200
body = r.json()
assert "version" in body
def test_loopback_protected_route_requires_token(client_loopback):
"""Any non-public /api/ route must require the session token."""
r = client_loopback.get("/api/sessions")
assert r.status_code == 401
def test_loopback_protected_route_accepts_session_token(client_loopback):
"""The injected SPA token unlocks protected /api/ routes."""
r = client_loopback.get(
"/api/sessions",
headers={"X-Hermes-Session-Token": web_server._SESSION_TOKEN},
)
# 200 or 404 (no sessions yet) both prove the auth layer let it through.
assert r.status_code in (200, 404)
def test_loopback_index_injects_session_token(client_loopback):
"""Loopback mode keeps injecting the SPA token into index.html.
This is the property that the new auth gate MUST disable once a gated
bind is detected. Phase 3 will add an inverse test for the gated path.
"""
r = client_loopback.get("/")
if r.status_code == 404:
pytest.skip("WEB_DIST not built in this env")
assert "__HERMES_SESSION_TOKEN__" in r.text
def test_loopback_host_header_validation_still_enforced(client_loopback):
"""DNS-rebinding protection: a foreign Host header is rejected."""
r = client_loopback.get("/api/status", headers={"Host": "evil.test"})
assert r.status_code == 400
Step 2: Run the harness against main.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py -v
Expected: All five tests pass (the index test may skip in CI if WEB_DIST isn't built). If any fails, STOP — current behavior was already broken and the rest of the plan rests on a wrong baseline.
Step 3: Commit.
git add tests/hermes_cli/test_dashboard_auth_gate.py
git commit -m "test(dashboard): pin current loopback auth behavior as regression harness"
Task 0.2: Add the should_require_auth predicate
Objective: A single source of truth for "is the auth gate active?" — used by start_server, the middleware, and the SPA token injection.
Files:
- Modify:
hermes_cli/web_server.py— add helper near the existing_LOCALHOSTconstant instart_server, but as a module-level function so middleware can call it. - Test:
tests/hermes_cli/test_dashboard_auth_gate.py
Step 1: Write failing test.
Append to tests/hermes_cli/test_dashboard_auth_gate.py:
from hermes_cli.web_server import should_require_auth
@pytest.mark.parametrize("host,allow_public,expected", [
("127.0.0.1", False, False),
("127.0.0.1", True, False),
("localhost", False, False),
("::1", False, False),
("0.0.0.0", True, False), # --insecure escape hatch
("0.0.0.0", False, True),
("192.168.1.5", False, True),
("10.0.0.1", True, False),
("100.64.0.1", False, True), # Tailscale CGNAT — treated as public
("hermes-agent-prod-abc.fly.dev", False, True),
])
def test_should_require_auth_truth_table(host, allow_public, expected):
assert should_require_auth(host, allow_public) is expected
Step 2: Run, observe failure.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py::test_should_require_auth_truth_table -v
Expected: ImportError: cannot import name 'should_require_auth'.
Step 3: Add the predicate.
Insert in hermes_cli/web_server.py immediately after the _LOOPBACK_HOST_VALUES definition (~line 159):
_LOCALHOST_HOSTS: frozenset = frozenset({"127.0.0.1", "localhost", "::1"})
def should_require_auth(host: str, allow_public: bool) -> bool:
"""Return True iff the dashboard auth gate must be active.
Truth table:
host == loopback → False (no auth)
host != loopback AND allow_public (--insecure)→ False (legacy escape hatch)
host != loopback AND NOT allow_public → True (gate engages)
"Loopback" matches the same set used by ``--insecure`` enforcement in
``start_server``: 127.0.0.1, localhost, ::1. RFC1918 / CGNAT / link-local
are deliberately treated as PUBLIC — a hostile device on the same LAN is
exactly the threat model the gate is designed for.
"""
return (host not in _LOCALHOST_HOSTS) and (not allow_public)
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py -v
Expected: all tests pass (including the new parametrized one with 10 cases).
Step 5: Commit.
git add hermes_cli/web_server.py tests/hermes_cli/test_dashboard_auth_gate.py
git commit -m "feat(dashboard): add should_require_auth predicate for OAuth gate"
Task 0.3: Surface the gate flag on app.state so middleware can read it
Objective: Wire should_require_auth(host, allow_public) into start_server so the rest of the system has one place to ask "are we gated?"
Files:
- Modify:
hermes_cli/web_server.py:4514(start_server) - Test:
tests/hermes_cli/test_dashboard_auth_gate.py
Step 1: Write failing test.
Append:
def test_start_server_sets_auth_required_flag_on_app_state(monkeypatch):
"""``start_server`` must record auth_required so middleware can read it."""
# Don't actually start uvicorn — patch it out and only inspect side effects.
called = {}
def fake_run(*args, **kwargs):
called["host"] = kwargs.get("host")
monkeypatch.setattr(web_server, "uvicorn", type("U", (), {"run": staticmethod(fake_run)}))
# The "0.0.0.0 without --insecure" case currently raises SystemExit. We're
# going to change that in Phase 3 (replace SystemExit with "gate engages"),
# but for now the helper itself is what we're testing.
web_server.app.state.bound_host = None
web_server.app.state.bound_port = None
web_server.app.state.auth_required = None
with pytest.raises(SystemExit):
# SystemExit is fine — we just want the state flag set before the exit.
web_server.start_server(host="0.0.0.0", port=9119,
open_browser=False, allow_public=False)
# Even though it exited, the flag must have been computed and stashed.
assert web_server.app.state.auth_required is True
def test_start_server_loopback_does_not_set_auth_required(monkeypatch):
monkeypatch.setattr(web_server, "uvicorn", type("U", (), {"run": staticmethod(lambda *a, **k: None)}))
web_server.app.state.auth_required = None
web_server.start_server(host="127.0.0.1", port=9119,
open_browser=False, allow_public=False)
assert web_server.app.state.auth_required is False
Step 2: Run, observe failure.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py -v -k auth_required_flag
Step 3: Edit start_server to set the flag.
In hermes_cli/web_server.py start_server, before the _LOCALHOST = (...) check (~line 4528), insert:
app.state.auth_required = should_require_auth(host, allow_public)
The existing SystemExit branch stays for now — Phase 3 will replace it with "the gate engages" once the gate exists.
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py -v
Step 5: Commit.
git add hermes_cli/web_server.py tests/hermes_cli/test_dashboard_auth_gate.py
git commit -m "feat(dashboard): stash auth_required flag on app.state"
Phase 0 Exit Gate
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py -v
All tests pass. should_require_auth is the single predicate. app.state.auth_required is the runtime flag. No behavior has changed — the SystemExit branch in start_server still fires for 0.0.0.0 + no --insecure.
Phase 1 — Provider Protocol + Plugin Hook + Audit Logger
Goal: Define the DashboardAuthProvider ABC + registry, the ctx.register_dashboard_auth_provider plugin hook, and the audit logger. No HTTP routes yet — pure plumbing.
Why this comes before the gate: the gate's middleware delegates to providers. We can't write the middleware without a provider contract to mock.
Task 1.1: Define DashboardAuthProvider ABC + Session dataclass
Objective: A minimal protocol every auth provider implements, plus the dataclass representing a verified session.
Files:
- Create:
hermes_cli/dashboard_auth/__init__.py - Create:
hermes_cli/dashboard_auth/base.py - Create:
tests/hermes_cli/test_dashboard_auth_provider_base.py
Step 1: Write the failing protocol test.
# tests/hermes_cli/test_dashboard_auth_provider_base.py
"""Contract test for DashboardAuthProvider implementations.
Every provider plugin should import and call ``assert_protocol_compliance``
on its provider instance in its own unit test. This module also tests the
abstract base raises on missing methods so the contract is enforced.
"""
import pytest
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
Session,
LoginStart,
assert_protocol_compliance,
)
def test_session_has_required_fields():
s = Session(
user_id="u1",
email="a@b.com",
display_name="A",
org_id="org_1",
provider="test",
expires_at=1234567890,
access_token="at",
refresh_token="rt",
)
assert s.user_id == "u1"
assert s.provider == "test"
def test_login_start_has_redirect_and_state():
ls = LoginStart(
redirect_url="https://portal/authorize?...",
cookie_payload={"hermes_session_pkce": "verifier=abc;state=xyz"},
)
assert ls.redirect_url.startswith("https://")
assert "hermes_session_pkce" in ls.cookie_payload
def test_abstract_provider_cannot_be_instantiated():
with pytest.raises(TypeError):
DashboardAuthProvider()
class _BrokenProvider(DashboardAuthProvider):
name = "broken"
display_name = "Broken"
# Deliberately missing all the methods.
def test_assert_protocol_compliance_rejects_partial_impl():
with pytest.raises(TypeError):
assert_protocol_compliance(_BrokenProvider)
class _CompliantProvider(DashboardAuthProvider):
name = "ok"
display_name = "OK"
def start_login(self, *, redirect_uri: str) -> LoginStart:
return LoginStart(redirect_url="x", cookie_payload={})
def complete_login(self, *, code, state, code_verifier, redirect_uri) -> Session:
return Session(
user_id="u", email="x", display_name="x", org_id="o",
provider=self.name, expires_at=0,
access_token="a", refresh_token="r",
)
def verify_session(self, *, access_token: str) -> Session | None:
return None
def refresh_session(self, *, refresh_token: str) -> Session:
return Session(
user_id="u", email="x", display_name="x", org_id="o",
provider=self.name, expires_at=0,
access_token="a", refresh_token="r",
)
def revoke_session(self, *, refresh_token: str) -> None:
return None
def test_assert_protocol_compliance_accepts_full_impl():
assert_protocol_compliance(_CompliantProvider) is None
Step 2: Run, observe failure.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_provider_base.py -v
Expected: ImportError: No module named 'hermes_cli.dashboard_auth'.
Step 3: Write the base module.
# hermes_cli/dashboard_auth/__init__.py
"""Dashboard authentication provider framework.
The dashboard auth gate engages only when the dashboard binds to a non-loopback
host without ``--insecure``. In that mode, every request must carry a verified
session from one of the registered ``DashboardAuthProvider`` plugins.
The Nous provider lives in ``plugins/dashboard-auth-nous/`` and is the default.
Third parties register their own providers via the plugin hook
``ctx.register_dashboard_auth_provider``.
"""
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
Session,
LoginStart,
assert_protocol_compliance,
)
from hermes_cli.dashboard_auth.registry import (
register_provider,
get_provider,
list_providers,
clear_providers,
)
__all__ = [
"DashboardAuthProvider",
"Session",
"LoginStart",
"assert_protocol_compliance",
"register_provider",
"get_provider",
"list_providers",
"clear_providers",
]
# hermes_cli/dashboard_auth/base.py
"""Abstract base + dataclasses for dashboard auth providers."""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class Session:
"""A verified identity. Returned by ``complete_login`` and ``verify_session``.
All fields are mandatory. Providers that don't have a concept of orgs
should set ``org_id`` to an empty string. ``access_token`` and
``refresh_token`` are opaque to Hermes — provider-specific.
"""
user_id: str
email: str
display_name: str
org_id: str
provider: str
expires_at: int # unix seconds; the access_token's exp claim
access_token: str
refresh_token: str
@dataclass(frozen=True)
class LoginStart:
"""First leg of the OAuth round trip.
``redirect_url`` is the URL the browser must navigate to (e.g. the
Portal's ``/oauth/authorize``). ``cookie_payload`` is a dict of cookie
name → serialised value that the auth route will Set-Cookie on the
response. Used for PKCE state, CSRF nonces, etc. Cookies set here MUST
be HttpOnly Secure SameSite=Lax and TTL ≤ 10 minutes (login lifetime).
"""
redirect_url: str
cookie_payload: dict[str, str]
class DashboardAuthProvider(ABC):
"""Protocol every dashboard-auth provider plugin implements.
Lifecycle:
1. ``start_login`` — user clicks "Log in with X" on the login page.
Provider returns a redirect URL and any PKCE/CSRF state to stash
in short-lived cookies.
2. Browser bounces through the OAuth IDP and lands at /auth/callback.
3. ``complete_login`` — exchange the code + verifier for a Session.
4. ``verify_session`` — called on every request to validate the access
token in the cookie. Returns ``None`` if the token is expired or
invalid (middleware then triggers refresh or logout).
5. ``refresh_session`` — called when the access token is near expiry.
Returns a new Session with rotated tokens.
6. ``revoke_session`` — called on /auth/logout. Best-effort.
Failure semantics:
* ``start_login`` may raise ``ProviderError`` if the IDP is unreachable.
* ``complete_login`` raises ``InvalidCodeError`` on bad code/state.
* ``verify_session`` returns ``None`` on expiry; raises ``ProviderError``
on IDP-unreachable. The middleware treats expiry and unreachable
differently (expiry → refresh; unreachable → 503).
* ``refresh_session`` raises ``RefreshExpiredError`` when the refresh
token is also invalid; middleware then forces re-login.
"""
name: str = ""
display_name: str = ""
@abstractmethod
def start_login(self, *, redirect_uri: str) -> LoginStart: ...
@abstractmethod
def complete_login(
self,
*,
code: str,
state: str,
code_verifier: str,
redirect_uri: str,
) -> Session: ...
@abstractmethod
def verify_session(self, *, access_token: str) -> Optional[Session]: ...
@abstractmethod
def refresh_session(self, *, refresh_token: str) -> Session: ...
@abstractmethod
def revoke_session(self, *, refresh_token: str) -> None: ...
class ProviderError(Exception):
"""IDP unreachable, network error, etc. Middleware → 503."""
class InvalidCodeError(Exception):
"""The OAuth callback code/state didn't validate. Middleware → 400."""
class RefreshExpiredError(Exception):
"""Refresh token is dead. Middleware forces re-login (clears cookies, 302 → /auth/login)."""
def assert_protocol_compliance(cls) -> None:
"""Raise TypeError if ``cls`` doesn't implement the full provider protocol.
Call this in every provider plugin's unit tests:
def test_protocol_compliance():
assert_protocol_compliance(MyProvider)
"""
required_methods = (
"start_login",
"complete_login",
"verify_session",
"refresh_session",
"revoke_session",
)
required_attrs = ("name", "display_name")
for attr in required_attrs:
val = getattr(cls, attr, "")
if not val:
raise TypeError(
f"{cls.__name__} missing or empty attribute: {attr!r}"
)
for method in required_methods:
if not callable(getattr(cls, method, None)):
raise TypeError(
f"{cls.__name__} missing method: {method}"
)
# Also catch the ABC-not-overridden case
if getattr(cls, "__abstractmethods__", None):
raise TypeError(
f"{cls.__name__} has unimplemented abstract methods: "
f"{sorted(cls.__abstractmethods__)}"
)
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_provider_base.py -v
All 6 tests should pass.
Step 5: Commit.
git add hermes_cli/dashboard_auth/ tests/hermes_cli/test_dashboard_auth_provider_base.py
git commit -m "feat(dashboard-auth): define DashboardAuthProvider ABC + Session dataclass"
Task 1.2: Provider registry
Objective: A module-level dict of name → provider with register/get/list. Mirrors agent/image_gen_registry.py (see register_provider at the existing image-gen one for the prior art).
Files:
- Create:
hermes_cli/dashboard_auth/registry.py - Modify:
tests/hermes_cli/test_dashboard_auth_provider_base.py
Step 1: Write failing test.
Append to tests/hermes_cli/test_dashboard_auth_provider_base.py:
from hermes_cli.dashboard_auth import (
register_provider,
get_provider,
list_providers,
clear_providers,
)
@pytest.fixture(autouse=True)
def _isolated_registry():
clear_providers()
yield
clear_providers()
def test_registry_register_and_get(_isolated_registry=None):
p = _CompliantProvider()
register_provider(p)
assert get_provider("ok") is p
def test_registry_get_missing_returns_none(_isolated_registry=None):
assert get_provider("nope") is None
def test_registry_lists_in_registration_order(_isolated_registry=None):
class A(_CompliantProvider): name = "a"
class B(_CompliantProvider): name = "b"
register_provider(A())
register_provider(B())
names = [p.name for p in list_providers()]
assert names == ["a", "b"]
def test_registry_rejects_non_compliant_provider(_isolated_registry=None):
with pytest.raises(TypeError):
register_provider(_BrokenProvider())
def test_registry_rejects_duplicate_name(_isolated_registry=None):
register_provider(_CompliantProvider())
with pytest.raises(ValueError, match="already registered"):
register_provider(_CompliantProvider())
Step 2: Run, observe failure.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_provider_base.py -v -k registry
Step 3: Write the registry.
# hermes_cli/dashboard_auth/registry.py
"""Module-level registry for DashboardAuthProvider instances.
Plugins call ``register_provider`` via the plugin context hook at startup.
The auth gate middleware iterates ``list_providers()`` and uses ``get_provider``
to dispatch on the session's ``provider`` field.
"""
from __future__ import annotations
import logging
import threading
from typing import List, Optional
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
assert_protocol_compliance,
)
_log = logging.getLogger(__name__)
_lock = threading.Lock()
_providers: dict[str, DashboardAuthProvider] = {}
def register_provider(provider: DashboardAuthProvider) -> None:
"""Register a provider. Raises TypeError on protocol violation, ValueError on duplicate name."""
assert_protocol_compliance(type(provider))
with _lock:
if provider.name in _providers:
raise ValueError(
f"dashboard-auth provider already registered: {provider.name!r}"
)
_providers[provider.name] = provider
_log.info("dashboard-auth: registered provider %r (%s)",
provider.name, provider.display_name)
def get_provider(name: str) -> Optional[DashboardAuthProvider]:
with _lock:
return _providers.get(name)
def list_providers() -> List[DashboardAuthProvider]:
"""All registered providers, in registration order."""
with _lock:
return list(_providers.values())
def clear_providers() -> None:
"""Test-only: drop all registrations."""
with _lock:
_providers.clear()
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_provider_base.py -v
Step 5: Commit.
git add hermes_cli/dashboard_auth/registry.py tests/hermes_cli/test_dashboard_auth_provider_base.py
git commit -m "feat(dashboard-auth): registry for DashboardAuthProvider plugins"
Task 1.3: Plugin hook — ctx.register_dashboard_auth_provider
Objective: Add the method to PluginContext so plugins can register providers from their register(ctx) entry point.
Files:
- Modify:
hermes_cli/plugins.py— add a new method onPluginContext(~line 556 region, afterregister_image_gen_provider). - Test:
tests/hermes_cli/test_dashboard_auth_plugin_hook.py
Step 1: Write failing test.
# tests/hermes_cli/test_dashboard_auth_plugin_hook.py
"""The plugin context exposes register_dashboard_auth_provider.
Mirrors the image-gen / memory-provider hooks. See plugins.py:531 for prior art.
"""
import pytest
from hermes_cli.dashboard_auth import clear_providers, get_provider
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider, Session, LoginStart,
)
@pytest.fixture(autouse=True)
def _isolated():
clear_providers()
yield
clear_providers()
class _Stub(DashboardAuthProvider):
name = "stub"
display_name = "Stub IdP"
def start_login(self, *, redirect_uri): return LoginStart(redirect_url="x", cookie_payload={})
def complete_login(self, **kw): return Session("u", "e", "n", "o", "stub", 0, "a", "r")
def verify_session(self, **kw): return None
def refresh_session(self, **kw): return Session("u", "e", "n", "o", "stub", 0, "a", "r")
def revoke_session(self, **kw): pass
def test_plugin_ctx_can_register_dashboard_auth_provider(tmp_path):
from hermes_cli.plugins import PluginContext, PluginManifest
manifest = PluginManifest(name="dashboard-auth-stub", version="0.0.1",
description="stub", path=tmp_path)
# PluginManager is the parent of PluginContext; minimal shim for the test
class _Manager:
_cli_ref = None
_context_engine = None
_tools = {}
ctx = PluginContext(manifest=manifest, manager=_Manager())
assert hasattr(ctx, "register_dashboard_auth_provider")
ctx.register_dashboard_auth_provider(_Stub())
assert get_provider("stub").display_name == "Stub IdP"
def test_plugin_ctx_rejects_non_provider(tmp_path):
from hermes_cli.plugins import PluginContext, PluginManifest
manifest = PluginManifest(name="bad", version="0.0.1", description="", path=tmp_path)
class _Manager:
_cli_ref = None
_context_engine = None
_tools = {}
ctx = PluginContext(manifest=manifest, manager=_Manager())
# Pass something that's not a DashboardAuthProvider; expect a warning log
# and the registry stays empty (mirrors the image_gen behaviour).
import logging
with pytest.raises(Exception):
ctx.register_dashboard_auth_provider("not a provider")
assert get_provider("stub") is None
Step 2: Run, observe failure.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_plugin_hook.py -v
Step 3: Add the method to PluginContext.
In hermes_cli/plugins.py, after register_image_gen_provider (~line 554, before register_video_gen_provider):
# -- dashboard auth provider registration --------------------------------
def register_dashboard_auth_provider(self, provider) -> None:
"""Register a dashboard authentication provider.
``provider`` must be an instance of
:class:`hermes_cli.dashboard_auth.DashboardAuthProvider`. Used by
the dashboard auth gate (engaged when the dashboard binds to a
non-loopback host without ``--insecure``).
"""
from hermes_cli.dashboard_auth import (
DashboardAuthProvider, register_provider,
)
if not isinstance(provider, DashboardAuthProvider):
logger.warning(
"Plugin %r tried to register a dashboard-auth provider that "
"does not inherit from DashboardAuthProvider. Ignoring.",
self.manifest.name,
)
raise TypeError(
"register_dashboard_auth_provider expects a "
"DashboardAuthProvider instance"
)
register_provider(provider)
logger.info(
"Plugin %r registered dashboard-auth provider: %s (%s)",
self.manifest.name, provider.name, provider.display_name,
)
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_plugin_hook.py -v
Step 5: Commit.
git add hermes_cli/plugins.py tests/hermes_cli/test_dashboard_auth_plugin_hook.py
git commit -m "feat(plugins): add register_dashboard_auth_provider hook on PluginContext"
Task 1.4: Audit logger
Objective: Profile-aware JSON-lines log at ~/.hermes/logs/dashboard-auth.log, one line per auth event. Used by the middleware (Phase 3) and /auth/* routes (Phase 3).
Files:
- Create:
hermes_cli/dashboard_auth/audit.py - Create:
tests/hermes_cli/test_dashboard_auth_audit.py
Step 1: Write failing test.
# tests/hermes_cli/test_dashboard_auth_audit.py
import json
import pytest
from hermes_cli.dashboard_auth.audit import audit_log, AuditEvent
@pytest.fixture
def profile_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(home))
yield home
def test_audit_writes_jsonlines(profile_home):
audit_log(AuditEvent.LOGIN_START, provider="nous", ip="1.2.3.4")
audit_log(AuditEvent.LOGIN_SUCCESS, provider="nous", user_id="u1",
email="a@b.com", ip="1.2.3.4")
path = profile_home / "logs" / "dashboard-auth.log"
assert path.exists(), f"audit log not created at {path}"
lines = path.read_text().strip().splitlines()
assert len(lines) == 2
entry = json.loads(lines[1])
assert entry["event"] == "login_success"
assert entry["provider"] == "nous"
assert entry["user_id"] == "u1"
assert entry["email"] == "a@b.com"
assert "ts" in entry # ISO-8601 timestamp
def test_audit_redacts_token_like_values(profile_home):
audit_log(AuditEvent.LOGIN_SUCCESS, provider="nous", access_token="should-not-appear")
entry = json.loads((profile_home / "logs" / "dashboard-auth.log").read_text())
# Tokens must NEVER end up in the audit log raw. The function should
# either drop them or replace with "<redacted>".
assert "should-not-appear" not in json.dumps(entry)
def test_audit_all_event_types_have_string_values(profile_home):
for ev in AuditEvent:
assert isinstance(ev.value, str)
assert ev.value
Step 2: Run, observe failure.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_audit.py -v
Step 3: Implement.
# hermes_cli/dashboard_auth/audit.py
"""Audit log for dashboard auth events.
Profile-aware location: ``$HERMES_HOME/logs/dashboard-auth.log``.
Format: one JSON object per line. Token-like fields are stripped before
serialisation to avoid leaking refresh tokens or JWTs to disk.
"""
from __future__ import annotations
import datetime as _dt
import enum
import json
import logging
import os
import threading
from pathlib import Path
from typing import Any
_log = logging.getLogger(__name__)
_write_lock = threading.Lock()
# Field names that must never appear in the log raw. Any kwarg matching
# these is silently dropped.
_REDACTED_FIELDS = frozenset({
"access_token", "refresh_token", "code", "code_verifier",
"state", "ticket", "cookie", "Authorization", "authorization",
})
class AuditEvent(enum.Enum):
LOGIN_START = "login_start"
LOGIN_SUCCESS = "login_success"
LOGIN_FAILURE = "login_failure"
LOGOUT = "logout"
REFRESH_SUCCESS = "refresh_success"
REFRESH_FAILURE = "refresh_failure"
REVOKE = "revoke"
SESSION_VERIFY_FAILURE = "session_verify_failure"
WS_TICKET_MINTED = "ws_ticket_minted"
def _resolve_log_path() -> Path:
"""Resolve $HERMES_HOME/logs/dashboard-auth.log without importing hermes_constants.
Mirrors ``hermes_constants.get_hermes_home`` semantics: env var wins, else
``~/.hermes``. Keeping a local copy avoids an import cycle from the
middleware which lives below hermes_cli.
"""
home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
return Path(home) / "logs" / "dashboard-auth.log"
def audit_log(event: AuditEvent, **fields: Any) -> None:
"""Append one event to the audit log.
Token-like fields are dropped. Missing log directory is created.
Write failures are logged at WARNING but never raise — auth must not
fail because the audit logger broke.
"""
safe_fields = {
k: v for k, v in fields.items()
if k not in _REDACTED_FIELDS
}
entry = {
"ts": _dt.datetime.now(_dt.timezone.utc).isoformat(),
"event": event.value,
**safe_fields,
}
line = json.dumps(entry, separators=(",", ":")) + "\n"
path = _resolve_log_path()
try:
path.parent.mkdir(parents=True, exist_ok=True)
with _write_lock:
with open(path, "a", encoding="utf-8") as f:
f.write(line)
except Exception as e:
_log.warning("dashboard-auth audit log write failed: %s", e)
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_audit.py -v
Step 5: Commit.
git add hermes_cli/dashboard_auth/audit.py tests/hermes_cli/test_dashboard_auth_audit.py
git commit -m "feat(dashboard-auth): json-lines audit log at \$HERMES_HOME/logs/dashboard-auth.log"
Phase 1 Exit Gate
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_provider_base.py tests/hermes_cli/test_dashboard_auth_plugin_hook.py tests/hermes_cli/test_dashboard_auth_audit.py -v
All tests pass. DashboardAuthProvider, registry, plugin hook, and audit logger exist. Loopback dashboard behavior is unchanged.
Phase 2 — Stub Provider for End-to-End Local Testing
Goal: A self-contained "no-IDP-needed" provider that completes a fake OAuth round trip locally. Lets Phase 3 (the gate + routes) be tested end-to-end without depending on either Portal staging or the real Nous plugin. Lives in the test tree only; never registered in production code paths.
Why a stub before the real Nous provider: decouples middleware development from cross-repo Portal coordination. Phase 4 swaps the stub for the real provider; nothing in the middleware changes.
Task 2.1: Implement the stub provider
Objective: A StubAuthProvider that returns a fixed redirect URL, accepts any code, and issues a deterministic Session. Used in tests/hermes_cli/test_dashboard_auth_gate_e2e.py (Phase 3).
Files:
- Create:
tests/hermes_cli/conftest_dashboard_auth.py— shared fixtures + the stub class. - Create:
tests/hermes_cli/test_dashboard_auth_stub_provider.py— protocol-compliance test for the stub.
Step 1: Write the contract test.
# tests/hermes_cli/test_dashboard_auth_stub_provider.py
"""Stub provider exists for E2E gate testing. Validate it against the protocol."""
import pytest
from hermes_cli.dashboard_auth.base import assert_protocol_compliance
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
def test_stub_complies_with_protocol():
assert_protocol_compliance(StubAuthProvider) is None
def test_stub_start_login_returns_callback_redirect():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
# Stub bounces straight back to the callback with a fake code.
assert "code=stub_code" in ls.redirect_url
assert "state=" in ls.redirect_url
assert "hermes_session_pkce" in ls.cookie_payload
def test_stub_complete_login_with_matching_state_succeeds():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
# Pull the state and verifier out of cookie_payload
payload = dict(item.split("=", 1) for item in
ls.cookie_payload["hermes_session_pkce"].split(";"))
sess = p.complete_login(
code="stub_code", state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x.fly.dev/auth/callback",
)
assert sess.user_id == "stub-user-1"
assert sess.email == "stub@example.test"
assert sess.provider == "stub"
def test_stub_complete_login_rejects_mismatched_state():
from hermes_cli.dashboard_auth.base import InvalidCodeError
p = StubAuthProvider()
with pytest.raises(InvalidCodeError):
p.complete_login(code="stub_code", state="WRONG",
code_verifier="v", redirect_uri="https://x.fly.dev/auth/callback")
def test_stub_verify_session_round_trips():
p = StubAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
payload = dict(item.split("=", 1) for item in
ls.cookie_payload["hermes_session_pkce"].split(";"))
sess = p.complete_login(code="stub_code", state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x.fly.dev/auth/callback")
verified = p.verify_session(access_token=sess.access_token)
assert verified is not None
assert verified.user_id == "stub-user-1"
def test_stub_verify_expired_session_returns_none():
p = StubAuthProvider(default_ttl=0)
ls = p.start_login(redirect_uri="https://x/auth/callback")
payload = dict(item.split("=", 1) for item in
ls.cookie_payload["hermes_session_pkce"].split(";"))
sess = p.complete_login(code="stub_code", state=payload["state"],
code_verifier=payload["verifier"],
redirect_uri="https://x/auth/callback")
assert p.verify_session(access_token=sess.access_token) is None
Step 2: Run, observe failure.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_stub_provider.py -v
Step 3: Implement the stub.
# tests/hermes_cli/conftest_dashboard_auth.py
"""Stub auth provider + shared fixtures for dashboard-auth tests.
This file is import-only — does NOT register the stub globally. Each test
that needs the stub imports ``StubAuthProvider`` and registers it through
a fixture so the registry stays isolated.
"""
from __future__ import annotations
import json
import secrets
import time
import base64
import hmac
import hashlib
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider, Session, LoginStart,
InvalidCodeError, ProviderError, RefreshExpiredError,
)
_STUB_SECRET = b"stub-test-secret-not-for-prod"
def _sign(payload: dict) -> str:
"""Produce a tamper-evident opaque token. Not a real JWT — just enough to round-trip."""
raw = json.dumps(payload, separators=(",", ":")).encode()
sig = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
return base64.urlsafe_b64encode(raw + b"." + sig).decode()
def _unsign(token: str) -> dict | None:
try:
blob = base64.urlsafe_b64decode(token.encode())
raw, sig = blob.rsplit(b".", 1)
expected = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return None
return json.loads(raw)
except Exception:
return None
class StubAuthProvider(DashboardAuthProvider):
"""Local fake IDP for E2E tests.
``start_login`` returns a redirect to ``{redirect_uri}?code=stub_code&state={s}``
so the test harness can do the whole round trip in-process without
talking to anything external.
``access_token`` is an HMAC-signed JSON blob; ``verify_session`` decodes
and checks ``expires_at``.
"""
name = "stub"
display_name = "Stub IdP (test only)"
def __init__(self, default_ttl: int = 3600):
self._default_ttl = default_ttl
self._state_to_verifier: dict[str, str] = {}
def start_login(self, *, redirect_uri: str) -> LoginStart:
state = secrets.token_urlsafe(16)
verifier = secrets.token_urlsafe(32)
self._state_to_verifier[state] = verifier
return LoginStart(
redirect_url=f"{redirect_uri}?code=stub_code&state={state}",
cookie_payload={
"hermes_session_pkce": f"state={state};verifier={verifier}",
},
)
def complete_login(self, *, code, state, code_verifier, redirect_uri) -> Session:
if code != "stub_code":
raise InvalidCodeError(f"stub expects code='stub_code', got {code!r}")
expected_verifier = self._state_to_verifier.get(state)
if expected_verifier is None or expected_verifier != code_verifier:
raise InvalidCodeError(f"stub state/verifier mismatch")
del self._state_to_verifier[state]
now = int(time.time())
return Session(
user_id="stub-user-1",
email="stub@example.test",
display_name="Stub User",
org_id="stub-org-1",
provider=self.name,
expires_at=now + self._default_ttl,
access_token=_sign({
"sub": "stub-user-1", "email": "stub@example.test",
"name": "Stub User", "org_id": "stub-org-1",
"exp": now + self._default_ttl,
}),
refresh_token=_sign({
"sub": "stub-user-1", "kind": "refresh",
"exp": now + 30 * 86400,
}),
)
def verify_session(self, *, access_token: str):
payload = _unsign(access_token)
if payload is None or payload.get("exp", 0) < int(time.time()):
return None
return Session(
user_id=payload["sub"],
email=payload["email"],
display_name=payload["name"],
org_id=payload["org_id"],
provider=self.name,
expires_at=payload["exp"],
access_token=access_token,
refresh_token="", # not surfaced on verify
)
def refresh_session(self, *, refresh_token: str) -> Session:
payload = _unsign(refresh_token)
if payload is None or payload.get("exp", 0) < int(time.time()):
raise RefreshExpiredError("stub refresh token expired/invalid")
now = int(time.time())
return Session(
user_id=payload["sub"],
email="stub@example.test",
display_name="Stub User",
org_id="stub-org-1",
provider=self.name,
expires_at=now + self._default_ttl,
access_token=_sign({
"sub": payload["sub"], "email": "stub@example.test",
"name": "Stub User", "org_id": "stub-org-1",
"exp": now + self._default_ttl,
}),
refresh_token=_sign({
"sub": payload["sub"], "kind": "refresh",
"exp": now + 30 * 86400,
}),
)
def revoke_session(self, *, refresh_token: str) -> None:
# Stub is in-memory; nothing to revoke server-side.
return None
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_stub_provider.py -v
All 6 tests should pass.
Step 5: Commit.
git add tests/hermes_cli/conftest_dashboard_auth.py tests/hermes_cli/test_dashboard_auth_stub_provider.py
git commit -m "test(dashboard-auth): stub auth provider for E2E gate testing"
Phase 2 Exit Gate
The stub provider is a fully-conformant DashboardAuthProvider. It can be registered in any test that needs to exercise the auth gate end-to-end without external dependencies. Phase 3 will use it as the test driver.
Phase 3 — Auth Gate Middleware, Cookies, /auth/* Routes, Login HTML
Goal: The actual gate. When app.state.auth_required is True:
- Every request to
/api/*(except a narrow allowlist) and every HTML page (except/auth/*and/login) requires a valid session cookie. GET /loginserves a server-rendered HTML page listing every registered provider.GET /auth/login?provider=<name>callsprovider.start_login(...), sets the PKCE state cookie, and 302s to the IDP.GET /auth/callback?code&statecallsprovider.complete_login(...), sets the session cookies, and 302s to/.POST /auth/logoutclears cookies, callsprovider.revoke_session(...), redirects to/login.GET /api/auth/providers(public when gate is on) — list providers for the login page bootstrap.GET /api/auth/me(auth-required) — return the verified Session as JSON for the SPA.index.htmltoken injection is suppressed whenapp.state.auth_required is True._PUBLIC_API_PATHSis narrowed when the gate is on:/api/auth/providersis added,/api/statusis removed.
Why this is one phase: these pieces are mutually-dependent (middleware can't be tested without routes; routes can't be tested without cookies; cookies can't be tested without the middleware). They land in one phase, behind tests, with the stub provider as the driver.
Task 3.1: Cookie machinery
Objective: Helper functions that set/clear/read the three cookies (hermes_session_at, hermes_session_rt, hermes_session_pkce) consistently. Centralised so every code path agrees on flags.
Files:
- Create:
hermes_cli/dashboard_auth/cookies.py - Create:
tests/hermes_cli/test_dashboard_auth_cookies.py
Step 1: Write failing test.
# tests/hermes_cli/test_dashboard_auth_cookies.py
import pytest
from fastapi import FastAPI
from fastapi.responses import Response
from fastapi.testclient import TestClient
from hermes_cli.dashboard_auth.cookies import (
set_session_cookies, clear_session_cookies,
set_pkce_cookie, clear_pkce_cookie,
read_session_cookies, read_pkce_cookie,
SESSION_AT_COOKIE, SESSION_RT_COOKIE, PKCE_COOKIE,
)
def _build_app():
app = FastAPI()
@app.get("/set")
def set_endpoint(request):
r = Response("ok")
set_session_cookies(r, access_token="AT", refresh_token="RT",
access_token_expires_in=3600, use_https=True)
return r
@app.get("/set-pkce")
def set_pkce(request):
r = Response("ok")
set_pkce_cookie(r, payload="state=s;verifier=v", use_https=True)
return r
@app.get("/clear")
def clear(request):
r = Response("ok")
clear_session_cookies(r)
clear_pkce_cookie(r)
return r
return app
def test_session_cookies_are_httponly_samesite_lax_secure_in_https():
app = _build_app()
client = TestClient(app)
r = client.get("/set")
cookies = r.headers.get_list("set-cookie")
at = next(c for c in cookies if c.startswith(f"{SESSION_AT_COOKIE}="))
rt = next(c for c in cookies if c.startswith(f"{SESSION_RT_COOKIE}="))
for c in (at, rt):
assert "HttpOnly" in c
assert "SameSite=lax" in c.lower() or "SameSite=Lax" in c
assert "Secure" in c
assert "Path=/" in c
def test_session_cookies_omit_secure_when_http(monkeypatch):
app = FastAPI()
@app.get("/x")
def x():
r = Response("ok")
set_session_cookies(r, access_token="AT", refresh_token="RT",
access_token_expires_in=3600, use_https=False)
return r
r = TestClient(app).get("/x")
for c in r.headers.get_list("set-cookie"):
if c.startswith(f"{SESSION_AT_COOKIE}=") or c.startswith(f"{SESSION_RT_COOKIE}="):
assert "Secure" not in c, f"Cookie unexpectedly Secure: {c}"
def test_clear_session_cookies_emits_expired_at_and_rt():
app = _build_app()
r = TestClient(app).get("/clear")
cookies = r.headers.get_list("set-cookie")
assert any(c.startswith(f"{SESSION_AT_COOKIE}=") and "Max-Age=0" in c for c in cookies)
assert any(c.startswith(f"{SESSION_RT_COOKIE}=") and "Max-Age=0" in c for c in cookies)
def test_pkce_cookie_short_ttl_and_path_root():
app = _build_app()
r = TestClient(app).get("/set-pkce")
c = next(x for x in r.headers.get_list("set-cookie") if x.startswith(f"{PKCE_COOKIE}="))
assert "HttpOnly" in c
assert "Max-Age=600" in c # 10 minutes
assert "Path=/" in c
def test_read_session_cookies_from_request(monkeypatch):
from starlette.requests import Request
scope = {
"type": "http",
"headers": [(b"cookie", f"{SESSION_AT_COOKIE}=at_value; {SESSION_RT_COOKIE}=rt_value".encode())],
}
req = Request(scope)
at, rt = read_session_cookies(req)
assert at == "at_value"
assert rt == "rt_value"
def test_read_session_cookies_missing_returns_none():
from starlette.requests import Request
req = Request({"type": "http", "headers": []})
assert read_session_cookies(req) == (None, None)
Step 2: Run, observe failure.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_cookies.py -v
Step 3: Implement.
# hermes_cli/dashboard_auth/cookies.py
"""Cookie helpers for dashboard auth.
Three cookies in play:
- hermes_session_at: the OAuth access token (HttpOnly, lifetime = token TTL)
- hermes_session_rt: the OAuth refresh token (HttpOnly, lifetime = 30 days)
- hermes_session_pkce: short-lived PKCE state + CSRF nonce
(HttpOnly, lifetime = 10 minutes)
All three are SameSite=Lax (browser will send on cross-site GET top-level
navigation, which we need for the IDP redirect back to /auth/callback)
and Path=/. Secure is set ONLY when the dashboard was reached over HTTPS
(detected via ``X-Forwarded-Proto`` upstream of Fly's TLS terminator, or a
direct https:// scheme). Loopback dev traffic is always HTTP so Secure
would lock the cookies out of the browser.
"""
from __future__ import annotations
from typing import Optional, Tuple
from fastapi import Request
from fastapi.responses import Response
SESSION_AT_COOKIE = "hermes_session_at"
SESSION_RT_COOKIE = "hermes_session_rt"
PKCE_COOKIE = "hermes_session_pkce"
# 30 days — matches Portal's REFRESH_TOKEN_TTL_SECONDS
_RT_MAX_AGE = 30 * 24 * 60 * 60
_PKCE_MAX_AGE = 10 * 60
def _common_attrs(use_https: bool) -> dict:
attrs = {
"httponly": True,
"samesite": "lax",
"path": "/",
}
if use_https:
attrs["secure"] = True
return attrs
def set_session_cookies(
response: Response,
*,
access_token: str,
refresh_token: str,
access_token_expires_in: int,
use_https: bool,
) -> None:
response.set_cookie(
SESSION_AT_COOKIE, access_token,
max_age=access_token_expires_in,
**_common_attrs(use_https),
)
response.set_cookie(
SESSION_RT_COOKIE, refresh_token,
max_age=_RT_MAX_AGE,
**_common_attrs(use_https),
)
def clear_session_cookies(response: Response) -> None:
# Use max_age=0 to make the browser drop both cookies immediately.
# Path must match the set-path for the delete to apply.
response.set_cookie(SESSION_AT_COOKIE, "", max_age=0, path="/", httponly=True, samesite="lax")
response.set_cookie(SESSION_RT_COOKIE, "", max_age=0, path="/", httponly=True, samesite="lax")
def set_pkce_cookie(response: Response, *, payload: str, use_https: bool) -> None:
response.set_cookie(
PKCE_COOKIE, payload,
max_age=_PKCE_MAX_AGE,
**_common_attrs(use_https),
)
def clear_pkce_cookie(response: Response) -> None:
response.set_cookie(PKCE_COOKIE, "", max_age=0, path="/", httponly=True, samesite="lax")
def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]]:
at = request.cookies.get(SESSION_AT_COOKIE)
rt = request.cookies.get(SESSION_RT_COOKIE)
return at, rt
def read_pkce_cookie(request: Request) -> Optional[str]:
return request.cookies.get(PKCE_COOKIE)
def detect_https(request: Request) -> bool:
"""Decide whether to set Secure flag.
Trusts ``X-Forwarded-Proto`` only when ``proxy_headers=True`` is enabled
on uvicorn — currently OFF in start_server. Falls back to the request
URL's scheme.
"""
# request.url.scheme reflects the actual incoming scheme when uvicorn
# is configured with proxy_headers=True, or the raw scheme otherwise.
# Fly.io terminates TLS upstream and sets X-Forwarded-Proto=https on
# forwarded requests. We re-enable proxy_headers in start_server only
# for the auth-required path; see Phase 3.5.
return request.url.scheme == "https"
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_cookies.py -v
Step 5: Commit.
git add hermes_cli/dashboard_auth/cookies.py tests/hermes_cli/test_dashboard_auth_cookies.py
git commit -m "feat(dashboard-auth): cookie helpers for session_at/session_rt/pkce"
Task 3.2: Auth-gate middleware
Objective: A new FastAPI middleware that runs after host_header_middleware and before the existing auth_middleware. When app.state.auth_required is True:
- Allowlist routes (
/auth/*,/login,/api/auth/providers, static assets) pass through. - Everything else requires a valid
hermes_session_atcookie. The middleware verifies it via the provider, attaches the verified Session torequest.state.session, and continues. - Failed verification → 401 (for
/api/*) or 302 to/login(for HTML routes). - The existing
_SESSION_TOKEN-basedauth_middlewareis skipped when the gate is active (cookie auth supersedes it).
Files:
- Create:
hermes_cli/dashboard_auth/middleware.py - Modify:
hermes_cli/web_server.py— register the middleware nearhost_header_middleware, and short-circuitauth_middlewarewhen gate is on. - Create:
tests/hermes_cli/test_dashboard_auth_middleware.py
Step 1: Write failing test.
# tests/hermes_cli/test_dashboard_auth_middleware.py
"""Behavioural tests for the auth-gate middleware.
Uses the StubAuthProvider so we don't talk to a real IDP.
"""
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def gated_app(monkeypatch):
"""Configure web_server.app to gated mode with the stub provider."""
clear_providers()
provider = StubAuthProvider()
register_provider(provider)
web_server.app.state.bound_host = "0.0.0.0"
web_server.app.state.bound_port = 9119
web_server.app.state.auth_required = True
yield TestClient(web_server.app, base_url="https://gated.fly.dev")
clear_providers()
web_server.app.state.auth_required = False
web_server.app.state.bound_host = "127.0.0.1"
def test_gated_status_now_requires_auth(gated_app):
"""When gate is on, /api/status is NOT public — login bootstrap uses /api/auth/providers instead."""
r = gated_app.get("/api/status")
assert r.status_code == 401
def test_gated_html_redirects_to_login(gated_app):
r = gated_app.get("/", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login"
def test_gated_auth_providers_is_public(gated_app):
r = gated_app.get("/api/auth/providers")
assert r.status_code == 200
body = r.json()
assert any(p["name"] == "stub" for p in body["providers"])
assert body["providers"][0]["display_name"] == "Stub IdP (test only)"
def test_gated_login_html_is_public(gated_app):
r = gated_app.get("/login")
assert r.status_code == 200
assert "Stub IdP" in r.text
def test_gated_static_assets_are_public(gated_app):
# Built assets under /assets/* are mounted as StaticFiles; without
# them the SPA can't even render the login page in the user's
# branded skin. Allow these through.
r = gated_app.get("/assets/_nonexistent.css")
# 404 not 401 — proves middleware let it through to the route handler
assert r.status_code == 404
def test_gated_valid_cookie_unlocks_api_status(gated_app):
# First do the login round trip to obtain a valid access token.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
# /auth/login should 302 with PKCE cookie set
pkce = next(c for c in r1.headers.get_list("set-cookie")
if c.startswith("hermes_session_pkce="))
assert "HttpOnly" in pkce
redirect = r1.headers["location"]
# Stub redirects to {redirect_uri}?code=stub_code&state=...
assert "code=stub_code" in redirect
# Issue a fake browser hop to /auth/callback carrying the same cookie.
state = redirect.split("state=")[1]
cookies = gated_app.cookies # picked up the pkce cookie from r1
r2 = gated_app.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r2.status_code == 302
assert r2.headers["location"] == "/"
# Session cookies must be set now
set_cookies = r2.headers.get_list("set-cookie")
assert any(c.startswith("hermes_session_at=") for c in set_cookies)
assert any(c.startswith("hermes_session_rt=") for c in set_cookies)
# And /api/status should now succeed.
r3 = gated_app.get("/api/status")
assert r3.status_code == 200
def test_gated_invalid_cookie_returns_401_on_api(gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "totally-not-a-valid-token")
r = gated_app.get("/api/sessions")
assert r.status_code == 401
def test_gated_invalid_cookie_redirects_on_html(gated_app):
gated_app.cookies.set(SESSION_AT_COOKIE, "garbage")
r = gated_app.get("/", follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/login"
def test_gated_zero_providers_fails_closed(monkeypatch):
"""If gate is on but no providers are registered, login bootstrap fails closed."""
clear_providers()
web_server.app.state.bound_host = "0.0.0.0"
web_server.app.state.auth_required = True
try:
client = TestClient(web_server.app)
r = client.get("/api/auth/providers")
assert r.status_code == 503
assert "no auth providers" in r.text.lower()
finally:
web_server.app.state.auth_required = False
Step 2: Run, observe failure. (Will fail because the middleware doesn't exist yet.)
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_middleware.py -v
Step 3: Implement the middleware module.
# hermes_cli/dashboard_auth/middleware.py
"""Auth-gate middleware for the dashboard.
Engaged when ``app.state.auth_required is True``. The gate's job:
1. Allow a small set of routes through unauthenticated (login page,
/auth/* OAuth round trip, /api/auth/providers, static assets).
2. For everything else, demand a valid session cookie and attach the
verified ``Session`` to ``request.state.session``.
3. On HTML routes, redirect missing/invalid cookies to /login.
On /api/* routes, return 401 JSON.
"""
from __future__ import annotations
import logging
from typing import Iterable
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse
from hermes_cli.dashboard_auth import get_provider
from hermes_cli.dashboard_auth.audit import audit_log, AuditEvent
from hermes_cli.dashboard_auth.base import ProviderError
from hermes_cli.dashboard_auth.cookies import read_session_cookies
_log = logging.getLogger(__name__)
# Paths that bypass the auth gate. Order matters: prefix match.
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
"/auth/login",
"/auth/callback",
"/auth/logout",
"/login",
"/api/auth/providers",
"/assets/",
"/favicon.ico",
"/ds-assets/",
"/fonts/",
"/fonts-terminal/",
)
def _path_is_public(path: str) -> bool:
return any(path == p or path.startswith(p) for p in _GATE_PUBLIC_PREFIXES)
async def gated_auth_middleware(request: Request, call_next):
"""Engaged only when app.state.auth_required is True."""
if not getattr(request.app.state, "auth_required", False):
return await call_next(request)
path = request.url.path
if _path_is_public(path):
return await call_next(request)
at, _rt = read_session_cookies(request)
if not at:
return _unauth_response(path, reason="no_cookie")
# Look up the provider that issued the access_token. For the stateless
# design we *try every registered provider's verify_session* until one
# returns a Session. Providers MUST return None for tokens they don't
# recognise (rather than raise). Cheap because verification is local
# JWT decode (with optional userinfo network call for the Nous
# `signing_mode=userinfo` mode — that path adds a 60-second cache).
from hermes_cli.dashboard_auth import list_providers
session = None
for provider in list_providers():
try:
session = provider.verify_session(access_token=at)
except ProviderError as e:
_log.warning("dashboard-auth: provider %r unreachable: %s",
provider.name, e)
audit_log(AuditEvent.SESSION_VERIFY_FAILURE,
provider=provider.name, reason="provider_unreachable",
ip=_client_ip(request))
return JSONResponse(
{"detail": f"Auth provider {provider.name!r} unreachable"},
status_code=503,
)
if session is not None:
break
if session is None:
audit_log(AuditEvent.SESSION_VERIFY_FAILURE, reason="no_provider_recognises",
ip=_client_ip(request))
return _unauth_response(path, reason="invalid_or_expired_session")
request.state.session = session
return await call_next(request)
def _unauth_response(path: str, *, reason: str):
"""API routes → 401 JSON; HTML routes → 302 → /login."""
if path.startswith("/api/"):
return JSONResponse(
{"detail": "Unauthorized", "reason": reason},
status_code=401,
)
return RedirectResponse(url="/login", status_code=302)
def _client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else ""
Step 4: Wire the middleware into web_server.py.
In hermes_cli/web_server.py, immediately after host_header_middleware (around line 233), add:
from hermes_cli.dashboard_auth.middleware import gated_auth_middleware
@app.middleware("http")
async def _gated_auth_proxy(request: Request, call_next):
return await gated_auth_middleware(request, call_next)
And update the existing auth_middleware (line 237) to short-circuit when the gate is on:
@app.middleware("http")
async def auth_middleware(request: Request, call_next):
"""Require the session token on all /api/ routes except the public list."""
# When the OAuth gate is active, cookie auth supersedes the legacy
# _SESSION_TOKEN. The gated_auth_middleware has already verified the
# session; we skip the token check here.
if getattr(request.app.state, "auth_required", False):
return await call_next(request)
path = request.url.path
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS:
if not _has_valid_session_token(request):
return JSONResponse(
status_code=401,
content={"detail": "Unauthorized"},
)
return await call_next(request)
Step 5: Run, verify the tests pass. (Will still fail on tests that exercise routes the next sub-task adds.)
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_middleware.py -v -k "not auth_login and not callback"
Expected: tests that don't depend on the /auth/* routes pass. The route-dependent tests still fail.
Step 6: Commit.
git add hermes_cli/dashboard_auth/middleware.py hermes_cli/web_server.py
git commit -m "feat(dashboard-auth): auth-gate middleware (cookie-based, gated on app.state.auth_required)"
Task 3.3: /auth/login, /auth/callback, /auth/logout routes
Objective: The three OAuth round-trip endpoints. They are FastAPI routes (not middleware) because they need response bodies / cookies / redirects.
Files:
- Create:
hermes_cli/dashboard_auth/routes.py - Modify:
hermes_cli/web_server.py— import + mount the router.
Step 1: Implement the routes.
# hermes_cli/dashboard_auth/routes.py
"""HTTP routes for the dashboard-auth OAuth round trip.
Mounted at root (no prefix). The router is registered in web_server.py
only — it doesn't auto-gate; gating is done by gated_auth_middleware,
which allowlists /auth/*.
"""
from __future__ import annotations
import logging
import urllib.parse
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse, JSONResponse
from hermes_cli.dashboard_auth import get_provider, list_providers
from hermes_cli.dashboard_auth.audit import audit_log, AuditEvent
from hermes_cli.dashboard_auth.base import (
InvalidCodeError, ProviderError, RefreshExpiredError,
)
from hermes_cli.dashboard_auth.cookies import (
set_session_cookies, clear_session_cookies,
set_pkce_cookie, clear_pkce_cookie,
read_session_cookies, read_pkce_cookie,
detect_https,
)
_log = logging.getLogger(__name__)
router = APIRouter()
def _redirect_uri(request: Request) -> str:
"""Reconstruct the absolute callback URL the IDP will redirect back to.
Reads from the request URL — under Fly's proxy with proxy_headers=True,
this picks up the public https URL from X-Forwarded-Host + Proto.
"""
return str(request.url_for("auth_callback"))
def _client_ip(request: Request) -> str:
fwd = request.headers.get("x-forwarded-for", "")
if fwd:
return fwd.split(",")[0].strip()
return request.client.host if request.client else ""
@router.get("/auth/login", name="auth_login")
async def auth_login(request: Request, provider: str):
p = get_provider(provider)
if p is None:
raise HTTPException(status_code=404, detail=f"Unknown provider: {provider!r}")
try:
ls = p.start_login(redirect_uri=_redirect_uri(request))
except ProviderError as e:
audit_log(AuditEvent.LOGIN_FAILURE, provider=provider,
reason="provider_unreachable", ip=_client_ip(request))
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
audit_log(AuditEvent.LOGIN_START, provider=provider, ip=_client_ip(request))
resp = RedirectResponse(url=ls.redirect_url, status_code=302)
# The login start may stash one or more cookies (PKCE state, etc.)
# We expect the canonical key `hermes_session_pkce` plus possibly a
# `provider_name` so the callback knows which provider to dispatch to.
pkce = ls.cookie_payload.get("hermes_session_pkce", "")
# Pack provider name into the PKCE blob so callback can find it.
if "provider=" not in pkce:
pkce = f"provider={provider};{pkce}"
set_pkce_cookie(resp, payload=pkce, use_https=detect_https(request))
return resp
@router.get("/auth/callback", name="auth_callback")
async def auth_callback(request: Request, code: str = "", state: str = "",
error: str = "", error_description: str = ""):
pkce_raw = read_pkce_cookie(request)
if not pkce_raw:
audit_log(AuditEvent.LOGIN_FAILURE, reason="missing_pkce_cookie",
ip=_client_ip(request))
raise HTTPException(status_code=400, detail="Missing PKCE state cookie")
# Parse the semicolon-delimited blob: provider=...;state=...;verifier=...
parts = dict(p.split("=", 1) for p in pkce_raw.split(";") if "=" in p)
provider_name = parts.get("provider", "")
expected_state = parts.get("state", "")
verifier = parts.get("verifier", "")
p = get_provider(provider_name)
if p is None:
raise HTTPException(status_code=400, detail=f"Unknown provider in cookie: {provider_name!r}")
if error:
audit_log(AuditEvent.LOGIN_FAILURE, provider=provider_name,
reason="idp_error", error=error, ip=_client_ip(request))
raise HTTPException(
status_code=400,
detail=f"OAuth error from provider: {error} ({error_description})",
)
if state != expected_state:
audit_log(AuditEvent.LOGIN_FAILURE, provider=provider_name,
reason="state_mismatch", ip=_client_ip(request))
raise HTTPException(status_code=400, detail="OAuth state mismatch (CSRF check failed)")
try:
session = p.complete_login(
code=code, state=state, code_verifier=verifier,
redirect_uri=_redirect_uri(request),
)
except InvalidCodeError as e:
audit_log(AuditEvent.LOGIN_FAILURE, provider=provider_name,
reason="invalid_code", ip=_client_ip(request))
raise HTTPException(status_code=400, detail=f"Invalid code: {e}")
except ProviderError as e:
audit_log(AuditEvent.LOGIN_FAILURE, provider=provider_name,
reason="provider_unreachable", ip=_client_ip(request))
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
audit_log(AuditEvent.LOGIN_SUCCESS, provider=provider_name,
user_id=session.user_id, email=session.email,
org_id=session.org_id, ip=_client_ip(request))
import time
expires_in = max(60, session.expires_at - int(time.time()))
resp = RedirectResponse(url="/", status_code=302)
use_https = detect_https(request)
set_session_cookies(
resp,
access_token=session.access_token,
refresh_token=session.refresh_token,
access_token_expires_in=expires_in,
use_https=use_https,
)
clear_pkce_cookie(resp)
return resp
@router.post("/auth/logout", name="auth_logout")
async def auth_logout(request: Request):
at, rt = read_session_cookies(request)
if rt:
# Best-effort revoke at the provider — failure is logged but doesn't
# affect the local logout outcome.
from hermes_cli.dashboard_auth import list_providers
for provider in list_providers():
try:
provider.revoke_session(refresh_token=rt)
except Exception as e:
_log.warning("dashboard-auth: revoke on %r failed: %s",
provider.name, e)
sess = getattr(request.state, "session", None)
audit_log(
AuditEvent.LOGOUT,
provider=(sess.provider if sess else "unknown"),
user_id=(sess.user_id if sess else ""),
ip=_client_ip(request),
)
resp = RedirectResponse(url="/login", status_code=302)
clear_session_cookies(resp)
clear_pkce_cookie(resp)
return resp
@router.get("/api/auth/providers")
async def api_auth_providers():
providers = list_providers()
if not providers:
# Q13: fail-closed when zero providers are registered.
return JSONResponse(
{"detail": "no auth providers registered"},
status_code=503,
)
return {
"providers": [
{"name": p.name, "display_name": p.display_name}
for p in providers
],
}
@router.get("/api/auth/me")
async def api_auth_me(request: Request):
"""Return the verified session JSON. Auth-required (middleware gates this)."""
sess = getattr(request.state, "session", None)
if sess is None:
# Should be unreachable; middleware enforces. Defence in depth.
raise HTTPException(status_code=401, detail="Unauthorized")
return {
"user_id": sess.user_id,
"email": sess.email,
"display_name": sess.display_name,
"org_id": sess.org_id,
"provider": sess.provider,
"expires_at": sess.expires_at,
}
Step 2: Mount the router.
In hermes_cli/web_server.py, near the existing route definitions (after the host_header_middleware block, before the SPA mount at the bottom), add:
from hermes_cli.dashboard_auth.routes import router as _dashboard_auth_router
app.include_router(_dashboard_auth_router)
Step 3: Commit.
git add hermes_cli/dashboard_auth/routes.py hermes_cli/web_server.py
git commit -m "feat(dashboard-auth): /auth/{login,callback,logout} + /api/auth/{providers,me} routes"
Task 3.4: /login server-rendered HTML page
Objective: A minimal styled HTML page (no React bundle) that lists every registered provider. Inline CSS — matches the existing Hermes branding colors but doesn't pull from the build.
Files:
- Create:
hermes_cli/dashboard_auth/login_page.py - Modify:
hermes_cli/dashboard_auth/routes.py— addGET /login.
Step 1: Write the page renderer.
# hermes_cli/dashboard_auth/login_page.py
"""Server-rendered /login page. No React, no JavaScript dependency.
Listed providers come from the registry. Clicking a provider sends a GET
to /auth/login?provider=<name>.
"""
from __future__ import annotations
import html
from hermes_cli.dashboard_auth import list_providers
# Inline minimal CSS. The dashboard's full skin lives in the React bundle
# which we deliberately do NOT load here — the login page must not depend
# on the SPA build being present or on the injected session token.
_LOGIN_HTML_TEMPLATE = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Sign in — Hermes Agent</title>
<style>
:root {{
--bg: #0a0a0b;
--fg: #e5e5e7;
--accent: #f97316;
--border: #27272a;
}}
html, body {{
margin: 0; padding: 0; height: 100%;
background: var(--bg); color: var(--fg);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}}
main {{
max-width: 28rem; margin: 10vh auto; padding: 2rem;
border: 1px solid var(--border); border-radius: 0.75rem;
background: rgba(255,255,255,0.02);
}}
h1 {{ margin: 0 0 0.5rem; font-size: 1.5rem; }}
p {{ margin: 0 0 1.5rem; opacity: 0.7; }}
.provider-list {{ display: grid; gap: 0.75rem; }}
.provider-btn {{
display: block; width: 100%; box-sizing: border-box;
padding: 0.875rem 1rem; text-align: center;
background: var(--accent); color: #0a0a0b;
font-weight: 600; font-size: 1rem;
border-radius: 0.5rem; text-decoration: none;
border: 0; cursor: pointer;
}}
.provider-btn:hover {{ filter: brightness(1.1); }}
.empty {{
padding: 1rem; border-radius: 0.5rem;
background: rgba(248, 113, 113, 0.1); color: #fca5a5;
border: 1px solid rgba(248, 113, 113, 0.3);
}}
footer {{ margin-top: 2rem; font-size: 0.875rem; opacity: 0.5; text-align: center; }}
</style>
</head>
<body>
<main>
<h1>Sign in to Hermes Agent</h1>
<p>Choose a sign-in method to continue.</p>
<div class="provider-list">
{provider_buttons}
</div>
<footer>This dashboard is bound to a non-loopback host.<br>
Sign-in is required for security.</footer>
</main>
</body>
</html>
"""
_EMPTY_HTML = """\
<!doctype html>
<html><body><main style="font-family: system-ui; max-width: 36rem; margin: 10vh auto; padding: 2rem;">
<h1>Sign-in unavailable</h1>
<p>This dashboard is bound to a non-loopback host but no authentication providers are installed.</p>
<p>Install <code>plugins/dashboard-auth-nous</code> (default) or another auth provider, or restart with
<code>--insecure</code> to bypass the auth gate (not recommended on untrusted networks).</p>
</main></body></html>
"""
def render_login_html() -> str:
providers = list_providers()
if not providers:
return _EMPTY_HTML
buttons = []
for p in providers:
buttons.append(
f' <a class="provider-btn" href="/auth/login?provider='
f'{html.escape(p.name, quote=True)}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
return _LOGIN_HTML_TEMPLATE.format(provider_buttons="\n".join(buttons))
Step 2: Add the route.
In hermes_cli/dashboard_auth/routes.py, add at the top of the route list:
from fastapi.responses import HTMLResponse
from hermes_cli.dashboard_auth.login_page import render_login_html
@router.get("/login", name="login")
async def login_page(request: Request):
return HTMLResponse(
render_login_html(),
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
Step 3: Commit.
git add hermes_cli/dashboard_auth/login_page.py hermes_cli/dashboard_auth/routes.py
git commit -m "feat(dashboard-auth): server-rendered /login page listing providers"
Task 3.5: Suppress _SESSION_TOKEN injection and harden start_server when gate is on
Objective:
- When
auth_required is True,_serve_indexmust NOT injectwindow.__HERMES_SESSION_TOKEN__. The post-login SPA reads identity from/api/auth/meinstead. start_server's SystemExit onhost != loopback && not allow_publicis replaced with "the auth gate engages" branching. If zero providers are registered AND gate would be active, fail closed (SystemExit with a clear message).- Re-enable
proxy_headers=Trueon uvicorn when gate is active so cookies see the real client scheme (X-Forwarded-Proto) behind Fly's TLS terminator.
Files:
- Modify:
hermes_cli/web_server.py—_serve_index(~line 3676),start_server(~line 4514).
Step 1: Write failing tests.
Append to tests/hermes_cli/test_dashboard_auth_gate.py:
def test_gated_index_does_not_inject_session_token(monkeypatch):
web_server.app.state.auth_required = True
web_server.app.state.bound_host = "0.0.0.0"
try:
client = TestClient(web_server.app)
r = client.get("/login") # _serve_index isn't hit on /login
# And confirm the SPA index itself (post-login) doesn't leak the token.
# But / redirects to /login when no cookie, so we can't easily get to
# _serve_index without an authenticated cookie. Verify the helper
# directly:
from hermes_cli.web_server import WEB_DIST
if (WEB_DIST / "index.html").exists():
# Construct a Request and call _serve_index directly via a
# known-good path is fiddly; instead read the function source's
# branch handling. For an integration test, use a logged-in
# cookie via Stub provider and check the body of /.
pass
finally:
web_server.app.state.auth_required = False
def test_start_server_with_gate_and_no_providers_fails_closed(monkeypatch):
from hermes_cli.dashboard_auth import clear_providers
clear_providers()
monkeypatch.setattr(web_server, "uvicorn", type("U", (), {"run": staticmethod(lambda *a, **k: None)}))
with pytest.raises(SystemExit, match="no auth providers"):
web_server.start_server(host="0.0.0.0", port=9119,
open_browser=False, allow_public=False)
def test_start_server_with_gate_and_provider_proceeds(monkeypatch):
from hermes_cli.dashboard_auth import clear_providers, register_provider
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
clear_providers()
register_provider(StubAuthProvider())
called = {}
def fake_run(*a, **kw):
called.update(kw)
monkeypatch.setattr(web_server, "uvicorn", type("U", (), {"run": staticmethod(fake_run)}))
web_server.start_server(host="0.0.0.0", port=9119,
open_browser=False, allow_public=False)
assert called["host"] == "0.0.0.0"
# proxy_headers must be True in gated mode so X-Forwarded-Proto from Fly
# is honoured for cookie Secure-flag decisions.
assert called["proxy_headers"] is True
clear_providers()
Step 2: Modify _serve_index.
In hermes_cli/web_server.py:3676:
def _serve_index(prefix: str = ""):
html = _index_path.read_text()
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
# When the OAuth gate is active, do NOT inject _SESSION_TOKEN — the
# SPA reads identity from /api/auth/me using cookie auth instead.
if getattr(app.state, "auth_required", False):
bootstrap_script = (
f"<script>"
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
f'window.__HERMES_BASE_PATH__="{prefix}";'
f"window.__HERMES_AUTH_REQUIRED__=true;"
f"</script>"
)
else:
bootstrap_script = (
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
f'window.__HERMES_BASE_PATH__="{prefix}";'
f"window.__HERMES_AUTH_REQUIRED__=false;"
f"</script>"
)
# ... rest of the function (asset-prefix rewrites) unchanged, but
# replace ``token_script`` with ``bootstrap_script`` on the final
# ``html.replace("</head>", ...)`` call.
Step 3: Modify start_server.
Replace the _LOCALHOST block (lines ~4528–4539) with:
app.state.auth_required = should_require_auth(host, allow_public)
if app.state.auth_required:
from hermes_cli.dashboard_auth import list_providers
if not list_providers():
raise SystemExit(
f"Refusing to bind dashboard to {host} — the auth gate is "
f"required but no auth providers are registered. Install the "
f"default Nous provider (plugins/dashboard-auth-nous) or another "
f"DashboardAuthProvider plugin. Pass --insecure to skip the "
f"auth gate (NOT recommended on untrusted networks)."
)
_log.info(
"Dashboard binding to %s with OAuth auth gate enabled. "
"Providers: %s", host,
", ".join(p.name for p in list_providers()),
)
elif host not in _LOCALHOST and allow_public:
_log.warning(
"Binding to %s with --insecure — the dashboard has no robust "
"authentication. Only use on trusted networks.", host,
)
The original _LOCALHOST = (...) constant tuple and the SystemExit at line 4530 are removed (the predicate has subsumed them).
Update the uvicorn.run call (line 4583):
uvicorn.run(
app, host=host, port=port, log_level="warning",
# Trust X-Forwarded-Proto / X-Forwarded-For ONLY when the auth gate
# is engaged AND we're behind a known terminator (Fly.io). The
# gateway never runs publicly without a terminator; in loopback
# mode there's nothing to proxy.
proxy_headers=bool(app.state.auth_required),
)
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py tests/hermes_cli/test_dashboard_auth_middleware.py -v
Step 5: Commit.
git add hermes_cli/web_server.py tests/hermes_cli/test_dashboard_auth_gate.py
git commit -m "feat(dashboard-auth): suppress _SESSION_TOKEN injection in gated mode; fail-closed without providers"
Phase 3 Exit Gate
End-to-end test passes:
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_gate.py tests/hermes_cli/test_dashboard_auth_middleware.py tests/hermes_cli/test_dashboard_auth_cookies.py tests/hermes_cli/test_dashboard_auth_stub_provider.py -v
Manual smoke test:
hermes dashboard --host 0.0.0.0 --port 9119with the stub registered via a local~/.hermes/plugins/dashboard-auth-stub/plugin. Expect: server starts, listens on0.0.0.0:9119.- Browser to
http://localhost:9119/→ 302 →/login→ "Sign in with Stub IdP" button. - Click → bounces through
/auth/login?provider=stub→ stub redirects to/auth/callback?code=stub_code&state=…→ 302 →/. - The SPA loads with cookies, hits
/api/auth/me, displays "Logged in as Stub User". ~/.hermes/logs/dashboard-auth.logcontainslogin_startandlogin_successentries.
Loopback regression test passes unchanged:
hermes dashboard
# → http://127.0.0.1:9119 still works without any login, token still injected
Phase 4 — Real Nous Provider Plugin (v2 — contract-compliant)
Plan v2 rewrite. This section was rewritten on 2026-05-21 after fetching the Portal's published contract (PR #180). The original draft (which assumed a static
hermes-dashboardclient_id, userinfo-fallback verification, and broad OIDC scopes) is preserved further below under "Phase 4 v1 (rejected — preserved for archeology)" for reviewer context.
Goal: Ship plugins/dashboard-auth-nous/ as a real DashboardAuthProvider that implements the Nous Portal authorization-code + PKCE flow per nous-account-service/docs/agent-dashboard-oauth-contract.md. Bundled with Hermes (lives under plugins/, auto-loaded). The plugin is a no-op unless the Portal-injected env vars are present, so it has zero impact on loopback-only / --insecure operators.
Dependencies: Portal-side endpoints already exist in PR #180:
GET /oauth/authorize(authorization endpoint)POST /api/oauth/token(token endpoint, acceptsgrant_type=authorization_code)GET /.well-known/jwks.json(RS256 signing keys, RFC-7517 JWK Set, cachepublic, max-age=300, stale-while-revalidate=300)
There is no userinfo endpoint and no refresh-token endpoint for the dashboard flow. Token lifetime is 900 seconds.
Task 4.1: Plugin skeleton + env-driven provider class
Objective: A plugins/dashboard-auth-nous/ directory that registers a NousDashboardAuthProvider ONLY when HERMES_DASHBOARD_OAUTH_CLIENT_ID is set. If unset (the common case for loopback / --insecure operators), the plugin loads but registers nothing — the gate then fails closed if it's actually engaged, which is correct.
Files:
- Create:
plugins/dashboard-auth-nous/plugin.yaml - Create:
plugins/dashboard-auth-nous/__init__.py - Create:
plugins/dashboard-auth-nous/provider.py - Create:
plugins/dashboard-auth-nous/test_provider.py
Step 1: Plugin manifest.
# plugins/dashboard-auth-nous/plugin.yaml
name: dashboard-auth-nous
version: 1.0.0
description: "Default dashboard auth provider for Hermes — OAuth via Nous Portal (portal.nousresearch.com)."
author: NousResearch
kind: dashboard_auth
pip_dependencies:
- httpx
- pyjwt[crypto]
Step 2: Plugin entry point — conditional registration.
# plugins/dashboard-auth-nous/__init__.py
"""Default dashboard-auth provider — Nous Portal OAuth.
Auto-loaded by the plugin system at startup. Registers the provider only
when the Portal-injected env vars are present; otherwise loads silently
so loopback / --insecure operators don't see a spurious "auth misconfigured"
warning every time they start the dashboard.
"""
import logging
import os
from plugins.dashboard_auth_nous.provider import NousDashboardAuthProvider
_log = logging.getLogger(__name__)
def register(ctx):
"""Plugin entry — called by the plugin loader.
Required env vars (Portal injects these at Fly.io provisioning time):
HERMES_DASHBOARD_OAUTH_CLIENT_ID — must be shape ``agent:{instance_id}``
HERMES_DASHBOARD_PORTAL_URL — e.g. https://portal.nousresearch.com
With both present, registers the provider. With either missing, logs a
DEBUG note and returns silently — operator-owned dashboards binding to
loopback (or running with --insecure) are not expected to set these.
The gate-engagement layer fails closed if a public bind is attempted
with zero providers registered, so the failure mode is already covered.
"""
client_id = os.environ.get("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "").strip()
portal_url = os.environ.get("HERMES_DASHBOARD_PORTAL_URL", "").strip()
if not client_id or not portal_url:
_log.debug(
"dashboard-auth-nous: env vars missing "
"(HERMES_DASHBOARD_OAUTH_CLIENT_ID=%r, HERMES_DASHBOARD_PORTAL_URL=%r); "
"not registering provider.",
bool(client_id), bool(portal_url),
)
return
if not client_id.startswith("agent:"):
_log.warning(
"dashboard-auth-nous: HERMES_DASHBOARD_OAUTH_CLIENT_ID=%r does not "
"match contract shape 'agent:{instance_id}'; not registering provider. "
"Set this env var to the value provisioned by Nous Portal.",
client_id,
)
return
ctx.register_dashboard_auth_provider(
NousDashboardAuthProvider(client_id=client_id, portal_url=portal_url)
)
Step 3: Provider implementation — contract-compliant.
# plugins/dashboard-auth-nous/provider.py
"""NousDashboardAuthProvider — authorization-code + PKCE against Nous Portal.
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``
(PR #180). Key contract points encoded here:
- client_id is per-instance (``agent:{instance_id}``), injected at
provisioning. Stored on ``self._client_id``; ``self._agent_instance_id``
is the suffix used for defense-in-depth claim verification.
- scope is ``agent_dashboard:access`` only.
- redirect_uri is computed from request.url_for("auth_callback") under
proxy_headers=True so Fly's TLS terminator's X-Forwarded-Proto / Host
are honoured.
- tokens are RS256-signed JWTs verified against ``/.well-known/jwks.json``;
JWKS is cached for 5 minutes with stale-while-revalidate.
- V1 has no refresh tokens — refresh_session always raises
RefreshExpiredError so the middleware redirects to /auth/login.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import secrets
import time
import urllib.parse
from typing import Optional
import httpx
import jwt
from jwt import PyJWKClient
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
Session,
LoginStart,
InvalidCodeError,
ProviderError,
RefreshExpiredError,
)
_log = logging.getLogger(__name__)
# Contract: ``agent_dashboard:access`` is the scope name for this flow.
_SCOPE = "agent_dashboard:access"
# Contract: tolerant treatment — if the claim is missing, warn and proceed;
# if present and != 1, refuse. See OQ-C2.
_EXPECTED_CONTRACT_VERSION = 1
# Contract: JWKS cache lifetime (matches Portal's Cache-Control header).
_JWKS_CACHE_SECONDS = 300
def _b64url_no_pad(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
class NousDashboardAuthProvider(DashboardAuthProvider):
"""Nous Portal OAuth via authorization-code + PKCE (S256)."""
name = "nous"
display_name = "Nous Research"
def __init__(self, *, client_id: str, portal_url: str) -> None:
if not client_id.startswith("agent:"):
# Defense-in-depth — the plugin entry already filters, but the
# provider should never be constructed with a malformed id.
raise ValueError(
f"client_id must match contract shape 'agent:{{instance_id}}', "
f"got {client_id!r}"
)
self._client_id = client_id
self._agent_instance_id = client_id[len("agent:"):]
self._portal_url = portal_url.rstrip("/")
self._jwks_url = f"{self._portal_url}/.well-known/jwks.json"
self._authorize_url = f"{self._portal_url}/oauth/authorize"
self._token_url = f"{self._portal_url}/api/oauth/token"
# PyJWKClient handles cache + stale-while-revalidate semantics.
self._jwks = PyJWKClient(
self._jwks_url,
cache_keys=True,
lifespan=_JWKS_CACHE_SECONDS,
)
# ---------------- start_login -------------------------------------
def start_login(self, *, redirect_uri: str) -> LoginStart:
# Validate redirect_uri shape early to surface misconfiguration before
# the user is bounced to Portal and gets an opaque error.
parsed = urllib.parse.urlparse(redirect_uri)
if parsed.scheme not in ("https", "http"):
raise ProviderError(f"redirect_uri must be http(s), got {redirect_uri!r}")
if parsed.scheme == "http" and parsed.hostname not in ("localhost", "127.0.0.1"):
raise ProviderError(
f"redirect_uri may only use http:// for localhost/127.0.0.1, "
f"got {redirect_uri!r}"
)
code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
code_challenge = _b64url_no_pad(hashlib.sha256(code_verifier.encode()).digest())
state = _b64url_no_pad(secrets.token_bytes(32))
params = {
"response_type": "code",
"client_id": self._client_id,
"redirect_uri": redirect_uri,
"scope": _SCOPE,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
authorize_url = f"{self._authorize_url}?{urllib.parse.urlencode(params)}"
return LoginStart(
authorize_url=authorize_url,
state=state,
code_verifier=code_verifier,
)
# ---------------- complete_login ----------------------------------
def complete_login(
self,
*,
code: str,
code_verifier: str,
redirect_uri: str,
) -> Session:
try:
response = httpx.post(
self._token_url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._client_id,
"code_verifier": code_verifier,
},
headers={"Accept": "application/json"},
timeout=10.0,
)
except httpx.RequestError as exc:
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
if response.status_code == 400:
# Contract: invalid_code, invalid_grant, redirect_uri_mismatch all
# surface here.
body = response.json() if response.headers.get("content-type", "").startswith("application/json") else {}
error_code = body.get("error", "invalid_request")
raise InvalidCodeError(f"Portal rejected code: {error_code}")
if response.status_code != 200:
raise ProviderError(
f"Portal token endpoint returned {response.status_code}: "
f"{response.text[:200]}"
)
payload = response.json()
access_token = payload.get("access_token")
if not access_token:
raise ProviderError("Portal token response missing access_token")
# Contract V1: no refresh token. If one is present, we deliberately
# ignore it (forward-compat: a future Portal can issue one without
# forcing us to ship a new Hermes version).
token_type = payload.get("token_type", "").lower()
if token_type and token_type != "bearer":
raise ProviderError(f"unexpected token_type={token_type!r}")
claims = self._verify_jwt(access_token)
return self._session_from_claims(access_token, claims)
# ---------------- refresh_session ---------------------------------
def refresh_session(self, *, refresh_token: str) -> Session:
# Contract V1 has no refresh tokens. The cookie machinery may still
# call this if a future Portal change starts issuing them; for now
# we always force re-auth.
raise RefreshExpiredError(
"Nous Portal does not issue refresh tokens in OAuth contract v1; "
"user must re-authenticate."
)
# ---------------- verify_session (called per request) -------------
def verify_session(self, *, access_token: str) -> Session:
claims = self._verify_jwt(access_token)
return self._session_from_claims(access_token, claims)
# ---------------- internals --------------------------------------
def _verify_jwt(self, access_token: str) -> dict:
try:
signing_key = self._jwks.get_signing_key_from_jwt(access_token)
except jwt.PyJWKClientError as exc:
raise ProviderError(f"JWKS lookup failed: {exc}") from exc
try:
claims = jwt.decode(
access_token,
signing_key.key,
algorithms=["RS256"],
# Contract: audience is the bare client_id for agent:* clients.
audience=self._client_id,
# Issuer is the Portal base URL. Pin it.
issuer=self._portal_url,
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
)
except jwt.ExpiredSignatureError as exc:
raise InvalidCodeError(f"access token expired: {exc}") from exc
except jwt.InvalidTokenError as exc:
raise ProviderError(f"access token verification failed: {exc}") from exc
# Defense-in-depth: contract recommends verifying agent_instance_id
# matches our configured client_id suffix. (Doc says all client_id-shaped
# claims should be cross-checked.)
token_instance_id = claims.get("agent_instance_id")
if token_instance_id and token_instance_id != self._agent_instance_id:
raise ProviderError(
f"agent_instance_id mismatch: token={token_instance_id!r} "
f"vs configured={self._agent_instance_id!r}"
)
# Tolerant contract-version check (see OQ-C2).
contract_version = claims.get("oauth_contract_version")
if contract_version is None:
_log.warning(
"Nous Portal token missing oauth_contract_version claim "
"(contract says it should be %d); proceeding anyway.",
_EXPECTED_CONTRACT_VERSION,
)
elif contract_version != _EXPECTED_CONTRACT_VERSION:
raise ProviderError(
f"unsupported oauth_contract_version={contract_version!r}, "
f"expected {_EXPECTED_CONTRACT_VERSION}"
)
return claims
def _session_from_claims(self, access_token: str, claims: dict) -> Session:
# Contract V1 emits no email / display_name. We surface the user_id
# (truncated) in the AuthWidget; Session keeps the fields for forward
# compatibility but populates them with empty strings.
user_id = str(claims.get("sub", ""))
if not user_id:
raise ProviderError("token missing 'sub' (user_id) claim")
return Session(
provider_name=self.name,
user_id=user_id,
email="",
display_name="",
access_token=access_token,
refresh_token="", # contract V1: no refresh
expires_at=int(claims["exp"]),
extra={
"org_id": claims.get("org_id"),
"agent_instance_id": claims.get("agent_instance_id"),
"scope": claims.get("scope"),
},
)
Step 4: Tests.
The test suite covers four shapes:
- Plugin registration gating — env unset / malformed
client_id→ no registration. start_loginshape — generates correctcode_verifier(43-128 chars),code_challenge(S256 of verifier), and authorize URL with all required params.complete_loginhappy path + error mapping — httpx mocked. 200 with valid JWT →Session; 400 →InvalidCodeError; 500 →ProviderError; 200 withoutaccess_token→ProviderError.verify_sessiontoken verification — uses an RSA keypair generated inconftest; signs a JWT with the expected claims and verifies it round-trips. Negative cases: wrongaud, wrongiss, missingsub,agent_instance_idmismatch,oauth_contract_version=2rejection, missingoauth_contract_versionwarning.
Skip refresh_session happy path — it has none; one test asserts RefreshExpiredError is always raised.
Task 4.2: Smoke test against staging Portal (portal.rewbs.uk)
Objective: Manual end-to-end run against the staging Portal before considering Phase 4 done. Not a CI gate; the OAuth flow needs a real browser. Document the checklist:
- Provision a fake Fly app pointing to localhost (e.g. via
fly apps create+ DNS override) OR — easier — patch the Portal'sflyAppName → canonicalRedirectUrito allowhttp://localhost:8080/auth/callback. - Set
HERMES_DASHBOARD_OAUTH_CLIENT_ID=agent:{instance_id},HERMES_DASHBOARD_PORTAL_URL=https://portal.rewbs.uk. hermes dashboard --host 0.0.0.0 --port 8080.- Open
http://localhost:8080/. Expect bounce to/login. Click "Continue with Nous Research". - Expect Portal
/oauth/authorizepage; sign in; consent. - Expect redirect to
/auth/callback?code=…&state=…; cookie set; redirect to/. - Open dev tools;
/api/auth/mereturns user_id;/api/ptyticket-auth path works (Phase 5). - Wait 900 s; expect 401 on next mutation; expect SPA to redirect to
/login(Phase 6 v2).
Phase 4 v1 (rejected — preserved for archeology)
The v1 draft below assumed (a) a static hermes-dashboard OAuth client, (b) signing_mode=userinfo with JWKS as a future upgrade, and (c) refresh tokens. All three were reversed by the contract; see Contract Anchor above.
Task 4.1: Plugin skeleton + provider class
Objective: A plugins/dashboard-auth-nous/ directory with plugin.yaml, __init__.py that imports and registers the provider, and provider.py that implements the OAuth dance.
Files:
- Create:
plugins/dashboard-auth-nous/plugin.yaml - Create:
plugins/dashboard-auth-nous/__init__.py - Create:
plugins/dashboard-auth-nous/provider.py - Create:
plugins/dashboard-auth-nous/test_provider.py
Step 1: Plugin manifest.
# plugins/dashboard-auth-nous/plugin.yaml
name: dashboard-auth-nous
version: 1.0.0
description: "Default dashboard auth provider for Hermes — OAuth via Nous Portal (portal.nousresearch.com)."
author: NousResearch
kind: dashboard_auth
pip_dependencies:
- httpx
- pyjwt[crypto]
Step 2: Plugin entry point.
# plugins/dashboard-auth-nous/__init__.py
"""Default dashboard-auth provider — Nous Portal OAuth.
Auto-loaded by the plugin system at startup. Registers the provider into
the dashboard-auth registry via the plugin context hook.
"""
from plugins.dashboard_auth_nous.provider import NousDashboardAuthProvider
def register(ctx):
"""Plugin entry — called by the plugin loader.
Honours these env vars (typically left unset; the defaults are correct
for production Nous Portal):
HERMES_DASHBOARD_AUTH_NOUS_PORTAL_URL — override portal base URL
(default: https://portal.nousresearch.com)
HERMES_DASHBOARD_AUTH_NOUS_CLIENT_ID — override OAuth client_id
(default: hermes-dashboard)
HERMES_DASHBOARD_AUTH_NOUS_SIGNING_MODE — "userinfo" (default) or "jwks"
"""
ctx.register_dashboard_auth_provider(NousDashboardAuthProvider())
Step 3: Provider implementation.
# plugins/dashboard-auth-nous/provider.py
"""NousDashboardAuthProvider — authorization-code + PKCE against Nous Portal."""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import secrets
import time
import urllib.parse
from typing import Optional
import httpx
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
Session,
LoginStart,
InvalidCodeError,
ProviderError,
RefreshExpiredError,
)
_log = logging.getLogger(__name__)
_DEFAULT_PORTAL_URL = "https://portal.nousresearch.com"
_DEFAULT_CLIENT_ID = "hermes-dashboard"
_DEFAULT_SIGNING_MODE = "userinfo" # or "jwks"
# Scopes:
# openid profile email — identity claims
# inference:invoke tool:invoke — Hermes inherits these from Portal's default
# scope set, so the agent can use them later.
_SCOPE = "openid profile email inference:invoke tool:invoke"
def _b64url_no_pad(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _make_pkce_pair() -> tuple[str, str]:
verifier = _b64url_no_pad(secrets.token_bytes(64))
challenge = _b64url_no_pad(hashlib.sha256(verifier.encode()).digest())
return verifier, challenge
class NousDashboardAuthProvider(DashboardAuthProvider):
name = "nous"
display_name = "Nous Portal"
def __init__(self):
self._portal_url = (
os.getenv("HERMES_DASHBOARD_AUTH_NOUS_PORTAL_URL")
or _DEFAULT_PORTAL_URL
).rstrip("/")
self._client_id = (
os.getenv("HERMES_DASHBOARD_AUTH_NOUS_CLIENT_ID")
or _DEFAULT_CLIENT_ID
)
self._signing_mode = (
os.getenv("HERMES_DASHBOARD_AUTH_NOUS_SIGNING_MODE")
or _DEFAULT_SIGNING_MODE
)
# Simple 60s memoisation for verify_session in userinfo mode so we
# don't hammer Portal on every browser request.
self._verify_cache: dict[str, tuple[int, Session]] = {}
# ---- OAuth ---------------------------------------------------------
def start_login(self, *, redirect_uri: str) -> LoginStart:
verifier, challenge = _make_pkce_pair()
state = _b64url_no_pad(secrets.token_bytes(24))
params = {
"response_type": "code",
"client_id": self._client_id,
"redirect_uri": redirect_uri,
"scope": _SCOPE,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
auth_url = f"{self._portal_url}/oauth/authorize?" + urllib.parse.urlencode(params)
return LoginStart(
redirect_url=auth_url,
cookie_payload={
# Caller (routes.py) prepends `provider=nous;`
"hermes_session_pkce": f"state={state};verifier={verifier}",
},
)
def complete_login(
self, *, code, state, code_verifier, redirect_uri,
) -> Session:
token_url = f"{self._portal_url}/api/oauth/token"
try:
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
r = client.post(
token_url,
data={
"grant_type": "authorization_code",
"code": code,
"code_verifier": code_verifier,
"client_id": self._client_id,
"redirect_uri": redirect_uri,
},
headers={"Accept": "application/json"},
)
except httpx.HTTPError as e:
raise ProviderError(f"Portal token endpoint unreachable: {e}")
if r.status_code == 400:
raise InvalidCodeError(f"Portal rejected code: {r.text}")
if r.status_code >= 500:
raise ProviderError(f"Portal token endpoint returned {r.status_code}")
if r.status_code != 200:
raise InvalidCodeError(f"Portal token exchange failed: HTTP {r.status_code} {r.text}")
body = r.json()
return self._session_from_token_response(body)
def verify_session(self, *, access_token: str) -> Optional[Session]:
# Cache hit?
now = int(time.time())
cached = self._verify_cache.get(access_token)
if cached and cached[0] > now:
return cached[1]
if self._signing_mode == "jwks":
return self._verify_via_jwks(access_token)
return self._verify_via_userinfo(access_token)
def refresh_session(self, *, refresh_token: str) -> Session:
token_url = f"{self._portal_url}/api/oauth/token"
try:
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
r = client.post(
token_url,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": self._client_id,
},
headers={"Accept": "application/json"},
)
except httpx.HTTPError as e:
raise ProviderError(f"Portal refresh endpoint unreachable: {e}")
if r.status_code in (400, 401):
# Portal indicates the refresh token is dead.
raise RefreshExpiredError(f"Portal rejected refresh: {r.text}")
if r.status_code != 200:
raise ProviderError(f"Portal refresh failed: HTTP {r.status_code} {r.text}")
return self._session_from_token_response(r.json())
def revoke_session(self, *, refresh_token: str) -> None:
# Portal's existing API exposes revocation through Account Service's
# /api/account/oauth/sessions delete-by-id route. The refresh token
# itself isn't accepted as a revoke key. Best-effort: we POST it to
# the token endpoint with grant_type=refresh_token, then discard the
# response — this consumes the refresh and rotates it server-side,
# which is sufficient for "this old refresh is now dead". A future
# iteration can add a dedicated /api/oauth/revoke endpoint.
try:
with httpx.Client(timeout=httpx.Timeout(5.0)) as client:
client.post(
f"{self._portal_url}/api/oauth/token",
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": self._client_id,
},
)
except httpx.HTTPError:
# Best-effort; log but don't raise.
_log.warning("dashboard-auth-nous: revoke best-effort call failed", exc_info=True)
# ---- internals -----------------------------------------------------
def _session_from_token_response(self, body: dict) -> Session:
access_token = body.get("access_token", "")
refresh_token = body.get("refresh_token", "")
if not access_token or not refresh_token:
raise ProviderError("Portal token response missing tokens")
# Decode the JWT payload (no signature verification here — only used
# to extract claims for the Session dataclass; signature verification
# happens in verify_session).
claims = self._decode_jwt_payload_unsafe(access_token)
now = int(time.time())
expires_at = int(claims.get("exp", now + 3600))
return Session(
user_id=str(claims.get("sub", "")),
email=str(claims.get("email", "")),
display_name=str(claims.get("name", "") or claims.get("email", "")),
org_id=str(claims.get("org_id", "")),
provider=self.name,
expires_at=expires_at,
access_token=access_token,
refresh_token=refresh_token,
)
def _decode_jwt_payload_unsafe(self, token: str) -> dict:
"""Decode the JWT payload without verification. Used only to extract
claims for the Session dataclass; signature verification is the job
of ``verify_session``."""
try:
header_b64, payload_b64, _sig = token.split(".")
padded = payload_b64 + "=" * (-len(payload_b64) % 4)
import json
return json.loads(base64.urlsafe_b64decode(padded))
except Exception as e:
raise ProviderError(f"Cannot decode JWT payload: {e}")
def _verify_via_userinfo(self, access_token: str) -> Optional[Session]:
"""Use Portal's /api/oauth/account as a userinfo endpoint.
Cached for 60 seconds keyed on the access_token. Returns None if
Portal returns 401 (expired/invalid token).
"""
try:
with httpx.Client(timeout=httpx.Timeout(10.0)) as client:
r = client.get(
f"{self._portal_url}/api/oauth/account",
headers={
"Authorization": f"Bearer {access_token}",
"Accept": "application/json",
},
)
except httpx.HTTPError as e:
raise ProviderError(f"Portal /api/oauth/account unreachable: {e}")
if r.status_code == 401:
return None
if r.status_code >= 500:
raise ProviderError(f"Portal /api/oauth/account returned {r.status_code}")
if r.status_code != 200:
return None
body = r.json()
# Cache the verified Session for 60 seconds.
claims = self._decode_jwt_payload_unsafe(access_token)
now = int(time.time())
sess = Session(
user_id=str(body.get("userId", claims.get("sub", ""))),
email=str(body.get("email", claims.get("email", ""))),
display_name=str(body.get("name", claims.get("name", "") or body.get("email", ""))),
org_id=str(body.get("orgId", claims.get("org_id", ""))),
provider=self.name,
expires_at=int(claims.get("exp", now + 3600)),
access_token=access_token,
refresh_token="", # not returned on verify
)
self._verify_cache[access_token] = (now + 60, sess)
return sess
def _verify_via_jwks(self, access_token: str) -> Optional[Session]:
"""Verify the JWT signature against Portal's JWKS."""
try:
import jwt as _jwt
from jwt import PyJWKClient
except ImportError:
raise ProviderError("pyjwt[crypto] not installed — falling back to userinfo")
jwks_url = f"{self._portal_url}/.well-known/jwks.json"
try:
client = PyJWKClient(jwks_url)
signing_key = client.get_signing_key_from_jwt(access_token)
claims = _jwt.decode(
access_token,
signing_key.key,
algorithms=["RS256"],
audience=f"hermes-cli:{self._client_id}",
issuer=self._portal_url,
)
except _jwt.ExpiredSignatureError:
return None
except _jwt.InvalidTokenError as e:
_log.warning("dashboard-auth-nous: JWT validation failed: %s", e)
return None
except Exception as e:
raise ProviderError(f"JWKS verify failed: {e}")
now = int(time.time())
sess = Session(
user_id=str(claims.get("sub", "")),
email=str(claims.get("email", "")),
display_name=str(claims.get("name", "") or claims.get("email", "")),
org_id=str(claims.get("org_id", "")),
provider=self.name,
expires_at=int(claims.get("exp", now + 3600)),
access_token=access_token,
refresh_token="",
)
self._verify_cache[access_token] = (now + 60, sess)
return sess
Step 4: Unit tests for the provider.
# plugins/dashboard-auth-nous/test_provider.py
"""Unit tests for the Nous dashboard-auth provider. Mocks Portal endpoints."""
import json
import time
import pytest
import respx
import httpx
from hermes_cli.dashboard_auth.base import (
assert_protocol_compliance, InvalidCodeError, ProviderError, RefreshExpiredError,
)
from plugins.dashboard_auth_nous.provider import NousDashboardAuthProvider
def test_protocol_compliance():
assert_protocol_compliance(NousDashboardAuthProvider) is None
def test_start_login_returns_authorize_url():
p = NousDashboardAuthProvider()
ls = p.start_login(redirect_uri="https://x.fly.dev/auth/callback")
assert ls.redirect_url.startswith("https://portal.nousresearch.com/oauth/authorize?")
assert "response_type=code" in ls.redirect_url
assert "client_id=hermes-dashboard" in ls.redirect_url
assert "code_challenge_method=S256" in ls.redirect_url
assert "state=" in ls.redirect_url
assert "scope=" in ls.redirect_url
# State+verifier go into the cookie payload
pkce = ls.cookie_payload["hermes_session_pkce"]
assert "state=" in pkce
assert "verifier=" in pkce
@respx.mock
def test_complete_login_happy_path():
p = NousDashboardAuthProvider()
# Forge a JWT-shaped access_token: header.payload.sig with a real payload
import base64
payload = {
"sub": "u_123", "email": "u@nous.com", "name": "U Nous",
"org_id": "org_x", "exp": int(time.time()) + 3600,
"aud": "hermes-cli:hermes-dashboard",
}
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode()
access_token = f"hdr.{payload_b64}.sig"
respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
return_value=httpx.Response(200, json={
"access_token": access_token, "refresh_token": "rt_xyz",
"token_type": "Bearer", "expires_in": 3600,
"scope": "openid profile email inference:invoke tool:invoke",
})
)
sess = p.complete_login(code="auth_code", state="s", code_verifier="v",
redirect_uri="https://x.fly.dev/auth/callback")
assert sess.user_id == "u_123"
assert sess.email == "u@nous.com"
assert sess.display_name == "U Nous"
assert sess.org_id == "org_x"
assert sess.access_token == access_token
assert sess.refresh_token == "rt_xyz"
assert sess.provider == "nous"
@respx.mock
def test_complete_login_invalid_code_raises():
p = NousDashboardAuthProvider()
respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
return_value=httpx.Response(400, json={"error": "invalid_grant"})
)
with pytest.raises(InvalidCodeError):
p.complete_login(code="bad", state="s", code_verifier="v",
redirect_uri="https://x.fly.dev/auth/callback")
@respx.mock
def test_complete_login_portal_5xx_raises_provider_error():
p = NousDashboardAuthProvider()
respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
return_value=httpx.Response(503, json={"error": "service_unavailable"})
)
with pytest.raises(ProviderError):
p.complete_login(code="c", state="s", code_verifier="v",
redirect_uri="https://x.fly.dev/auth/callback")
@respx.mock
def test_verify_session_userinfo_mode_happy():
p = NousDashboardAuthProvider()
import base64
payload = {"sub": "u1", "email": "u@x.com", "name": "U", "org_id": "o",
"exp": int(time.time()) + 3600}
token = f"hdr.{base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()}.sig"
respx.get("https://portal.nousresearch.com/api/oauth/account").mock(
return_value=httpx.Response(200, json={
"userId": "u1", "email": "u@x.com", "name": "U", "orgId": "o",
})
)
sess = p.verify_session(access_token=token)
assert sess is not None
assert sess.user_id == "u1"
@respx.mock
def test_verify_session_userinfo_401_returns_none():
p = NousDashboardAuthProvider()
import base64
payload = {"sub": "u1", "exp": int(time.time()) + 3600}
token = f"hdr.{base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()}.sig"
respx.get("https://portal.nousresearch.com/api/oauth/account").mock(
return_value=httpx.Response(401)
)
assert p.verify_session(access_token=token) is None
@respx.mock
def test_refresh_session_happy():
p = NousDashboardAuthProvider()
import base64
payload = {"sub": "u1", "email": "u@x.com", "name": "U", "org_id": "o",
"exp": int(time.time()) + 3600}
new_token = f"hdr.{base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()}.sig"
respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
return_value=httpx.Response(200, json={
"access_token": new_token, "refresh_token": "rt_new",
"token_type": "Bearer", "expires_in": 3600,
})
)
sess = p.refresh_session(refresh_token="rt_old")
assert sess.access_token == new_token
assert sess.refresh_token == "rt_new"
@respx.mock
def test_refresh_session_expired_raises():
p = NousDashboardAuthProvider()
respx.post("https://portal.nousresearch.com/api/oauth/token").mock(
return_value=httpx.Response(400, json={"error": "invalid_grant"})
)
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token="rt_dead")
Step 5: Run, verify pass.
scripts/run_tests.sh plugins/dashboard-auth-nous/test_provider.py -v
Adds respx to the test deps if not already present.
Step 6: Commit.
git add plugins/dashboard-auth-nous/
git commit -m "feat(dashboard-auth-nous): default OAuth provider for Nous Portal (authcode + PKCE)"
Task 4.2: Integrate plugin discovery for dashboard-auth
Objective: Confirm the existing plugin loader picks up plugins/dashboard-auth-nous/ on hermes dashboard startup. The plugin manager should already auto-discover any plugin with a plugin.yaml; this task verifies and locks that behavior with a test.
Files:
- Modify (only if needed):
hermes_cli/plugins.py— confirm the loader scansplugins/for built-in plugins. (It already does —plugins/memory/honcho/andplugins/image_gen/openai/are auto-loaded.) - Test:
tests/hermes_cli/test_dashboard_auth_plugin_discovery.py
Step 1: Test the discovery integration.
# tests/hermes_cli/test_dashboard_auth_plugin_discovery.py
"""When the dashboard starts, the bundled Nous auth provider must auto-register."""
from hermes_cli.dashboard_auth import clear_providers, get_provider
from hermes_cli.plugins import PluginManager
def test_bundled_nous_auth_plugin_is_discovered_and_registered(tmp_path, monkeypatch):
clear_providers()
# Use a real PluginManager pointed at the repo's plugins/ dir.
mgr = PluginManager()
mgr.discover_and_load()
# Either the Nous provider OR no provider is acceptable in CI where
# plugins might be opt-in; assert that if the plugin is in the registry,
# it speaks the correct portal URL.
nous = get_provider("nous")
if nous is not None:
assert nous.display_name == "Nous Portal"
clear_providers()
Step 2: Run.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_plugin_discovery.py -v
Step 3: Commit.
git add tests/hermes_cli/test_dashboard_auth_plugin_discovery.py
git commit -m "test(dashboard-auth): verify Nous provider auto-loads through plugin discovery"
Phase 4 Exit Gate
scripts/run_tests.sh plugins/dashboard-auth-nous/ tests/hermes_cli/test_dashboard_auth_plugin_discovery.py -v
Integration smoke (requires staging Portal access — operator-run, not CI):
- Set
HERMES_DASHBOARD_AUTH_NOUS_PORTAL_URL=https://staging.portal.nousresearch.com. hermes dashboard --host 0.0.0.0 --port 9119- Visit
http://localhost:9119/login→ click "Sign in with Nous Portal" → redirected to staging Portal → approve → redirected back to/. /api/auth/mereturns{user_id, email, display_name, org_id, provider: "nous", expires_at}.
Hard dependency on cross-repo work: Phase 4 cannot pass the integration smoke until the Portal-side items in the Cross-Repo Coordination Checklist land. The unit tests (mocked Portal endpoints) pass independently.
Phase 5 — WebSocket Ticket Auth (--tui Support in Gated Mode)
Goal: The PTY/WS endpoints (/api/pty, /api/ws, /api/pub, /api/events) currently authenticate via ?token=<_SESSION_TOKEN>. In gated mode the SPA has cookies, not the token. This phase adds a /api/auth/ws-ticket endpoint that mints a short-lived single-use ticket from a valid cookie, and updates each WS endpoint to accept either the legacy token (loopback mode) OR a ticket (gated mode).
Task 5.1: Ticket store + mint endpoint
Objective: A small in-memory ticket store with TTL + single-use semantics, and an authenticated endpoint that mints a ticket for the cookie's session.
Files:
- Create:
hermes_cli/dashboard_auth/ws_tickets.py - Modify:
hermes_cli/dashboard_auth/routes.py— add/api/auth/ws-ticket. - Create:
tests/hermes_cli/test_dashboard_auth_ws_tickets.py
Step 1: Write failing test.
# tests/hermes_cli/test_dashboard_auth_ws_tickets.py
import time
import pytest
from hermes_cli.dashboard_auth.ws_tickets import (
mint_ticket, consume_ticket, TicketInvalid,
)
def test_mint_and_consume_round_trip():
ticket = mint_ticket(user_id="u1", provider="nous")
# Must be opaque token (urlsafe base64ish), reasonable length
assert len(ticket) >= 32
info = consume_ticket(ticket)
assert info["user_id"] == "u1"
assert info["provider"] == "nous"
def test_ticket_is_single_use():
ticket = mint_ticket(user_id="u1", provider="stub")
consume_ticket(ticket)
with pytest.raises(TicketInvalid, match="already consumed|unknown"):
consume_ticket(ticket)
def test_expired_ticket_rejected(monkeypatch):
real_time = time.time
t0 = real_time()
monkeypatch.setattr("hermes_cli.dashboard_auth.ws_tickets.time.time",
lambda: t0)
ticket = mint_ticket(user_id="u1", provider="stub")
# Jump forward 31 seconds (TTL = 30s)
monkeypatch.setattr("hermes_cli.dashboard_auth.ws_tickets.time.time",
lambda: t0 + 31)
with pytest.raises(TicketInvalid, match="expired"):
consume_ticket(ticket)
def test_unknown_ticket_rejected():
with pytest.raises(TicketInvalid, match="unknown"):
consume_ticket("nope-never-minted")
Step 2: Implement.
# hermes_cli/dashboard_auth/ws_tickets.py
"""Short-lived single-use tickets for WS-upgrade auth in gated mode.
Browsers cannot set Authorization on a WebSocket upgrade. In loopback
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because
the token comes from the injected SPA script. In gated mode there is no
injected token — the SPA gets a fresh ticket via the authenticated REST
endpoint ``/api/auth/ws-ticket`` and passes that as ``?ticket=`` on the
WS upgrade.
Tickets are single-use, TTL = 30 seconds. In-memory; the dashboard is a
single process so no distributed coordination is needed.
"""
from __future__ import annotations
import secrets
import threading
import time
from typing import Optional
_TTL_SECONDS = 30
_lock = threading.Lock()
_tickets: dict[str, tuple[int, dict]] = {} # ticket -> (expires_at, info)
class TicketInvalid(Exception):
"""Ticket missing, expired, or already consumed."""
def mint_ticket(*, user_id: str, provider: str) -> str:
"""Generate a one-shot ticket bound to this user identity."""
ticket = secrets.token_urlsafe(32)
info = {"user_id": user_id, "provider": provider, "minted_at": int(time.time())}
with _lock:
_tickets[ticket] = (int(time.time()) + _TTL_SECONDS, info)
_gc_expired_locked()
return ticket
def consume_ticket(ticket: str) -> dict:
"""Validate and consume. Raises TicketInvalid on missing/expired/used."""
now = int(time.time())
with _lock:
entry = _tickets.pop(ticket, None)
if entry is None:
raise TicketInvalid(f"unknown ticket: {ticket[:8]}…")
expires_at, info = entry
if expires_at < now:
raise TicketInvalid("expired")
return info
def _gc_expired_locked() -> None:
now = int(time.time())
expired = [t for t, (exp, _) in _tickets.items() if exp < now]
for t in expired:
_tickets.pop(t, None)
Step 3: Add the mint endpoint.
In hermes_cli/dashboard_auth/routes.py:
from hermes_cli.dashboard_auth.ws_tickets import mint_ticket
@router.post("/api/auth/ws-ticket", name="auth_ws_ticket")
async def api_auth_ws_ticket(request: Request):
"""Mint a short-lived WS ticket for the authenticated session."""
sess = getattr(request.state, "session", None)
if sess is None:
# Middleware should already have rejected, but check defensively.
raise HTTPException(status_code=401, detail="Unauthorized")
ticket = mint_ticket(user_id=sess.user_id, provider=sess.provider)
audit_log(AuditEvent.WS_TICKET_MINTED, provider=sess.provider,
user_id=sess.user_id, ip=_client_ip(request))
return {"ticket": ticket, "ttl_seconds": 30}
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_ws_tickets.py -v
Step 5: Commit.
git add hermes_cli/dashboard_auth/ws_tickets.py hermes_cli/dashboard_auth/routes.py tests/hermes_cli/test_dashboard_auth_ws_tickets.py
git commit -m "feat(dashboard-auth): single-use WS tickets for cookie→ws bridge"
Task 5.2: Update WS endpoints to accept tickets
Objective: /api/pty, /api/ws, /api/pub, /api/events accept either ?token=<_SESSION_TOKEN> (loopback) or ?ticket=<ticket> (gated). In gated mode the legacy token path is rejected.
Files:
- Modify:
hermes_cli/web_server.py:3520(/api/ws),:3562,:3591, plus/api/pty(~line 3264).
Step 1: Write failing test.
Append to tests/hermes_cli/test_dashboard_auth_middleware.py:
def test_ws_accepts_ticket_in_gated_mode(gated_app):
# Authenticate via the stub round trip, then mint a ticket.
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
r2 = gated_app.get(f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False)
assert r2.status_code == 302
rt = gated_app.post("/api/auth/ws-ticket")
assert rt.status_code == 200
ticket = rt.json()["ticket"]
# The PTY endpoint should accept ?ticket=... in gated mode.
# Use the WS test client. Don't actually do the PTY handshake — the
# auth gate is what we're testing.
with gated_app.websocket_connect(f"/api/pty?ticket={ticket}") as ws:
# Either we read a banner or the server closes cleanly because no
# tty-write follows. Both prove the auth gate accepted the ticket.
pass
def test_ws_rejects_legacy_token_in_gated_mode(gated_app):
# Even if you somehow knew the legacy _SESSION_TOKEN, gated mode
# must NOT accept it.
from hermes_cli import web_server
with pytest.raises(Exception): # WSException / ConnectionClosed
with gated_app.websocket_connect(
f"/api/pty?token={web_server._SESSION_TOKEN}"
):
pass
def test_ws_rejects_consumed_ticket(gated_app):
r1 = gated_app.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
gated_app.get(f"/auth/callback?code=stub_code&state={state}", follow_redirects=False)
rt = gated_app.post("/api/auth/ws-ticket")
ticket = rt.json()["ticket"]
# First use — fine
with gated_app.websocket_connect(f"/api/pty?ticket={ticket}"):
pass
# Second use — rejected (single-use)
with pytest.raises(Exception):
with gated_app.websocket_connect(f"/api/pty?ticket={ticket}"):
pass
Step 2: Update each WS endpoint.
Pattern for each (refactor the duplicated check into a helper):
# In hermes_cli/web_server.py — add near the other helpers
def _ws_auth_ok(ws: WebSocket, app_state) -> bool:
"""Validate WS auth in either loopback or gated mode.
Returns True if the ws should be accepted. Caller is responsible for
closing with the right code if False.
"""
if getattr(app_state, "auth_required", False):
ticket = ws.query_params.get("ticket", "")
if not ticket:
return False
from hermes_cli.dashboard_auth.ws_tickets import consume_ticket, TicketInvalid
try:
consume_ticket(ticket)
return True
except TicketInvalid:
return False
token = ws.query_params.get("token", "")
return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode())
Then update each WS handler to use it:
@app.websocket("/api/ws")
async def gateway_ws(ws: WebSocket) -> None:
if not _DASHBOARD_EMBEDDED_CHAT_ENABLED:
await ws.close(code=4403)
return
if not _ws_auth_ok(ws, app.state):
await ws.close(code=4401)
return
if not _ws_client_is_allowed(ws):
await ws.close(code=4403)
return
from tui_gateway.ws import handle_ws
await handle_ws(ws)
Same surgery for /api/pty, /api/pub, /api/events.
Step 3: SPA-side change.
The React SPA's WS client must, in auth_required mode, fetch a fresh ticket from /api/auth/ws-ticket before each connect rather than using window.__HERMES_SESSION_TOKEN__.
Files:
- Modify:
web/src/pages/ChatPage.tsx— the xterm.js WebSocket connect. - Modify:
web/src/lib/api.ts— addgetWsTicket()typed wrapper.
// web/src/lib/api.ts — add:
export async function getWsTicket(): Promise<{ ticket: string; ttl_seconds: number }> {
const r = await fetch('/api/auth/ws-ticket', { method: 'POST', credentials: 'include' });
if (!r.ok) throw new Error(`ws-ticket: HTTP ${r.status}`);
return r.json();
}
// web/src/pages/ChatPage.tsx — replace token usage with:
const ws_url = window.__HERMES_AUTH_REQUIRED__
? `/api/pty?ticket=${encodeURIComponent((await getWsTicket()).ticket)}`
: `/api/pty?token=${encodeURIComponent(window.__HERMES_SESSION_TOKEN__)}`;
Step 4: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_middleware.py -v -k ws
Step 5: Commit.
git add hermes_cli/web_server.py web/src/pages/ChatPage.tsx web/src/lib/api.ts tests/hermes_cli/test_dashboard_auth_middleware.py
git commit -m "feat(dashboard-auth): WS ticket auth for /api/pty + /api/ws + /api/pub + /api/events"
Phase 5 Exit Gate
hermes dashboard --host 0.0.0.0 --port 9119 --tui with the stub provider:
- Login as before.
- Visit
/chat→ xterm.js opens. - The browser's network tab shows
POST /api/auth/ws-ticketreturning 200, immediately followed byGET /api/pty?ticket=…upgrading to WS. - The TUI is interactive.
Loopback hermes dashboard --tui keeps working without any of the above (legacy token path).
Phase 6 — 401-Triggered Re-Authentication (v2 — contract-compliant)
Plan v2 rewrite. The Portal contract V1 does not issue refresh tokens (see Contract Anchor C5). Silent-refresh machinery is replaced by a "401 → redirect to
/login" UX. The v1 draft is preserved below as "Phase 6 v1 (rejected — preserved for archeology)" so reviewers can see the alternative we explored.
Goal: When the access token in hermes_session_at expires (15-minute TTL per contract C6), the dashboard cleanly bounces the user back through /oauth/authorize. No silent refresh; no UX surprises beyond the single redirect. The behaviour must be identical whether the expiry is detected (a) at the gateway middleware (HTML navigation request) or (b) by an XHR / fetch from the SPA.
Design overview
Two interception points handle expiry:
gated_auth_middleware(HTML navigation requests). Already in place from Phase 3. Whenverify_sessionraisesInvalidCodeError("access token expired: …"), the middleware:- clears the
hermes_session_atcookie, - audit-logs
auth.session_expired, - returns
RedirectResponse("/login?next={original_path}", status_code=303).
- clears the
/api/*JSON endpoints. A new sibling middleware (gated_api_auth_middleware) handles XHR fetches: instead of redirecting (which afetch()call cannot follow into the OAuth dance), it returns401 {"error": "session_expired", "login_url": "/login?next=…"}. The SPA's global fetch wrapper notices the 401 and triggers a full-page navigation tologin_url.
This split is canonical OAuth UX — modern SPAs interpret 401 as "your session is gone" and any subsequent decision (re-auth flow choice, where to send the user) is conveyed in the body. The middleware does not return 302 to /login for API requests because (a) most browsers' fetch APIs swallow the redirect into the cross-origin OAuth flow opaquely, and (b) returning HTML in response to Accept: application/json confuses front-end frameworks.
Task 6.1: API auth middleware + session_expired envelope
Files:
- Modify:
hermes_cli/dashboard_auth/middleware.py— addgated_api_auth_middleware. - Modify:
hermes_cli/web_server.py— wire it in alongsidegated_auth_middleware. - Add:
tests/hermes_cli/test_dashboard_auth_api_401.py.
Behavior:
- Path matches
/api/*and not the auth allowlist (/api/auth/providers,/api/auth/login,/api/auth/callback). - Reads
hermes_session_atcookie; if absent →401 {"error": "unauthenticated", "login_url": "/login"}. - If present, calls the provider's
verify_session. On any exception (InvalidCodeError/ProviderError/RefreshExpiredError) → clear cookie, audit-log, return401 {"error": "session_expired", "login_url": "/login?next=/"}. (next=/notnext={path}for API calls — the user wasn't navigating to the API endpoint directly.) - On success, stash claims on
request.state.auth_sessionand call the route.
Audit-log events added:
auth.api_unauthenticated—/api/*request with no cookie.auth.api_session_expired—/api/*request with expired/invalid cookie.
WebSocket endpoints (/api/pty, /api/ws) are NOT covered by this middleware. They use the ticket-auth flow from Phase 5. A WS upgrade request with an expired access-token cookie should never reach /api/auth/ws-ticket (which is an HTTP POST covered by this middleware), so the SPA's ticket-fetch step is the natural failure point.
Task 6.2: SPA global 401 handler
Files:
- Modify:
dashboard/src/api/client.ts(or wherever the central fetch wrapper lives — find via grep). - Add:
dashboard/src/api/__tests__/sessionExpired.test.ts.
Behavior:
- Single shared
apiFetch(path, init)helper used by all SPA code. - When
response.status === 401 && response.headers.get("content-type")?.startsWith("application/json"):- Parse body.
- If
body.error in ("unauthenticated", "session_expired"), callwindow.location.assign(body.login_url). Return a never-resolving promise so the caller's.thendoesn't fire — the page is going away. - Otherwise, reject normally (the route returned a domain 401 like "monitor X is read-only for your role").
- One small UX nicety: before the redirect, the helper sets
sessionStorage.setItem("hermes.lastLocation", window.location.pathname)so the post-login redirect can land back where the user was. The/auth/callbackhandler reads this and, if it's a same-origin path, uses it as thenext=value.
Task 6.3: Remove the hermes_session_rt cookie + refresh path
Files:
- Modify:
hermes_cli/dashboard_auth/cookies.py— dropset_refresh_cookie,clear_refresh_cookie,get_refresh_token. - Modify:
hermes_cli/dashboard_auth/routes.py—/auth/callbackno longer writes a refresh cookie;/auth/logoutno longer needs to clear it (it never existed). - Modify:
tests/hermes_cli/test_dashboard_auth_cookies.py— drop the three RT tests; add a regression thathermes_session_rtis NOT a cookie name we emit.
Rationale: Contract V1 does not issue refresh tokens, so persisting one is dead state. The provider's refresh_session raises RefreshExpiredError unconditionally; if we wired the middleware to call it, the cookie machinery would observe an empty refresh_token and the result would be the same as having no cookie. Keeping the cookie around is just attack surface.
Forward compatibility: if Portal later starts issuing refresh tokens, the provider's complete_login already ignores them today (line # Contract V1: no refresh token. If one is present, we deliberately ignore it). To turn them on later, three things change: cookies.py grows the RT cookie back, provider sets Session.refresh_token, middleware adds a "near-expiry → refresh" branch in front of the expired branch. None of those changes break the V1 behavior; they're additive. Document this in the file header.
Task 6.4: Audit-log + observability
Files:
- Modify:
hermes_cli/dashboard_auth/audit.py— extendAuditEventenum withAPI_UNAUTHENTICATED,API_SESSION_EXPIRED(already covered above for completeness). - Modify:
docs/dashboard-auth-operations.md(new in Phase 7) — document the redirect/401 split + how to read the audit log to debug "users keep getting logged out".
Phase 6 v1 (rejected — preserved for archeology)
The v1 draft below implemented silent token refresh in the middleware: when the access token was within 60s of expiry, the provider's refresh_session would mint a new pair using the stored refresh token, and the user would never see a re-auth screen until the 30-day refresh token itself expired. This is the right design IF refresh tokens exist. They don't in V1 (contract C5), so the entire path is unimplementable. Reverted to 401-redirect UX above.
Task 6.1: Refresh helper + /api/auth/refresh endpoint
Objective: A function the middleware calls when verify says "expired", and a manual /api/auth/refresh endpoint for the SPA to invoke proactively.
Files:
- Create:
hermes_cli/dashboard_auth/refresh.py - Modify:
hermes_cli/dashboard_auth/middleware.py— call refresh when verify returns None. - Modify:
hermes_cli/dashboard_auth/routes.py— add/api/auth/refresh.
Step 1: Implement the refresh helper.
# hermes_cli/dashboard_auth/refresh.py
"""Cookie-rotating refresh helper.
The middleware calls ``maybe_refresh_session`` whenever ``verify_session``
returned None and a refresh token is available. On success the response
gets new ``hermes_session_at`` + ``hermes_session_rt`` cookies.
"""
from __future__ import annotations
import logging
import time
from typing import Optional, Tuple
from fastapi import Request
from fastapi.responses import Response
from hermes_cli.dashboard_auth import list_providers
from hermes_cli.dashboard_auth.audit import audit_log, AuditEvent
from hermes_cli.dashboard_auth.base import (
Session, RefreshExpiredError, ProviderError,
)
from hermes_cli.dashboard_auth.cookies import (
set_session_cookies, clear_session_cookies, detect_https,
)
_log = logging.getLogger(__name__)
def attempt_refresh(*, refresh_token: str) -> Optional[Session]:
"""Try every provider until one accepts the refresh token.
Returns the new Session on success. Returns None if every provider
rejected the token. Raises ProviderError if at least one provider
was unreachable AND none succeeded.
"""
last_provider_error: Optional[ProviderError] = None
for provider in list_providers():
try:
return provider.refresh_session(refresh_token=refresh_token)
except RefreshExpiredError:
# Token doesn't belong to this provider (or is truly dead); try next.
continue
except ProviderError as e:
last_provider_error = e
continue
if last_provider_error:
raise last_provider_error
return None
def apply_refresh_to_response(
request: Request,
response: Response,
session: Session,
) -> None:
"""Set the new session cookies on ``response``."""
expires_in = max(60, session.expires_at - int(time.time()))
set_session_cookies(
response,
access_token=session.access_token,
refresh_token=session.refresh_token,
access_token_expires_in=expires_in,
use_https=detect_https(request),
)
Step 2: Wire silent refresh into the middleware.
In hermes_cli/dashboard_auth/middleware.py, after the if session is None: block, insert a refresh attempt BEFORE the bail-out:
if session is None:
# Silent refresh attempt — if we still have a refresh token, try it.
if _rt:
try:
refreshed = attempt_refresh(refresh_token=_rt)
except ProviderError as e:
_log.warning("dashboard-auth refresh: provider unreachable: %s", e)
audit_log(AuditEvent.REFRESH_FAILURE, reason="provider_unreachable",
ip=_client_ip(request))
return _unauth_response(path, reason="refresh_unreachable")
if refreshed is not None:
# Carry on with the refreshed session; the response handler
# rotates the cookies on the way out via the wrapper below.
request.state.session = refreshed
request.state._session_just_refreshed = refreshed
response = await call_next(request)
apply_refresh_to_response(request, response, refreshed)
audit_log(AuditEvent.REFRESH_SUCCESS,
provider=refreshed.provider,
user_id=refreshed.user_id,
ip=_client_ip(request))
return response
audit_log(AuditEvent.REFRESH_FAILURE, reason="refresh_expired",
ip=_client_ip(request))
audit_log(AuditEvent.SESSION_VERIFY_FAILURE, reason="no_provider_recognises",
ip=_client_ip(request))
return _unauth_response(path, reason="invalid_or_expired_session")
Imports at the top of middleware.py:
from hermes_cli.dashboard_auth.refresh import attempt_refresh, apply_refresh_to_response
Step 3: Manual /api/auth/refresh endpoint.
In hermes_cli/dashboard_auth/routes.py:
from hermes_cli.dashboard_auth.refresh import attempt_refresh, apply_refresh_to_response
@router.post("/api/auth/refresh", name="auth_refresh")
async def api_auth_refresh(request: Request):
"""SPA-triggered explicit refresh.
Reads the refresh token from cookies, calls the provider, rotates the
session cookies on the response. SPA uses this to extend a session
proactively (e.g. when the user navigates back to a tab they left open
for hours).
"""
_at, rt = read_session_cookies(request)
if not rt:
raise HTTPException(status_code=401, detail="No refresh token in cookie")
try:
sess = attempt_refresh(refresh_token=rt)
except ProviderError as e:
audit_log(AuditEvent.REFRESH_FAILURE, reason="provider_unreachable",
ip=_client_ip(request))
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
if sess is None:
# Refresh truly dead → clear cookies and tell SPA to re-login.
resp = JSONResponse(
{"detail": "Refresh expired; re-login required"},
status_code=401,
)
clear_session_cookies(resp)
audit_log(AuditEvent.REFRESH_FAILURE, reason="refresh_expired",
ip=_client_ip(request))
return resp
audit_log(AuditEvent.REFRESH_SUCCESS,
provider=sess.provider, user_id=sess.user_id,
ip=_client_ip(request))
resp = JSONResponse({
"user_id": sess.user_id,
"email": sess.email,
"display_name": sess.display_name,
"provider": sess.provider,
"expires_at": sess.expires_at,
})
apply_refresh_to_response(request, resp, sess)
return resp
Step 4: Test.
# tests/hermes_cli/test_dashboard_auth_refresh.py
"""Silent refresh and explicit /api/auth/refresh."""
import time
import pytest
from fastapi.testclient import TestClient
from hermes_cli import web_server
from hermes_cli.dashboard_auth import clear_providers, register_provider
from hermes_cli.dashboard_auth.cookies import SESSION_AT_COOKIE, SESSION_RT_COOKIE
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
@pytest.fixture
def gated_app():
clear_providers()
register_provider(StubAuthProvider())
web_server.app.state.bound_host = "0.0.0.0"
web_server.app.state.auth_required = True
yield TestClient(web_server.app, base_url="https://gated.fly.dev")
clear_providers()
web_server.app.state.auth_required = False
def _login(client) -> dict:
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
state = r1.headers["location"].split("state=")[1]
r2 = client.get(f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False)
cookies = {}
for raw in r2.headers.get_list("set-cookie"):
name, _, rest = raw.partition("=")
val = rest.split(";", 1)[0]
cookies[name] = val
return cookies
def test_explicit_refresh_rotates_cookies(gated_app):
cookies = _login(gated_app)
old_at = cookies[SESSION_AT_COOKIE]
old_rt = cookies[SESSION_RT_COOKIE]
r = gated_app.post("/api/auth/refresh")
assert r.status_code == 200
new_at = next((c.split(";")[0].split("=", 1)[1]
for c in r.headers.get_list("set-cookie")
if c.startswith(f"{SESSION_AT_COOKIE}=")), None)
new_rt = next((c.split(";")[0].split("=", 1)[1]
for c in r.headers.get_list("set-cookie")
if c.startswith(f"{SESSION_RT_COOKIE}=")), None)
assert new_at and new_at != old_at
assert new_rt and new_rt != old_rt
def test_silent_refresh_on_expired_access_token():
# Configure the stub provider with a very short TTL so the first /api/me
# call sees an expired token, but the refresh succeeds.
clear_providers()
register_provider(StubAuthProvider(default_ttl=0))
web_server.app.state.auth_required = True
try:
client = TestClient(web_server.app, base_url="https://gated.fly.dev")
cookies = _login(client)
# /api/auth/me with an expired AT must succeed because middleware
# silently refreshes.
r = client.get("/api/auth/me")
assert r.status_code == 200
# And the response carries rotated cookies.
set_cookies = r.headers.get_list("set-cookie")
assert any(c.startswith(f"{SESSION_AT_COOKIE}=") for c in set_cookies)
finally:
clear_providers()
web_server.app.state.auth_required = False
def test_refresh_without_rt_cookie_returns_401(gated_app):
r = gated_app.post("/api/auth/refresh")
assert r.status_code == 401
def test_refresh_with_dead_token_clears_cookies(gated_app):
gated_app.cookies.set(SESSION_RT_COOKIE, "garbage-refresh-token")
r = gated_app.post("/api/auth/refresh")
assert r.status_code == 401
# Clearing cookies on a dead refresh
set_cookies = r.headers.get_list("set-cookie")
assert any(c.startswith(f"{SESSION_AT_COOKIE}=") and "Max-Age=0" in c for c in set_cookies)
Step 5: Run, verify pass.
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_refresh.py -v
Step 6: Commit.
git add hermes_cli/dashboard_auth/refresh.py hermes_cli/dashboard_auth/middleware.py hermes_cli/dashboard_auth/routes.py tests/hermes_cli/test_dashboard_auth_refresh.py
git commit -m "feat(dashboard-auth): silent refresh on expired AT + manual /api/auth/refresh"
Phase 6 Exit Gate
scripts/run_tests.sh tests/hermes_cli/test_dashboard_auth_refresh.py -v
Manual: leave a dashboard tab open with the stub provider configured to issue 60-second access tokens. After 60 seconds the next page interaction is invisibly upgraded — no login redirect. The audit log shows refresh_success entries.
Phase 7 — SPA "Logged in as …" Widget, CLI Status, Documentation
Plan v2 note. Contract C4 means the JWT does NOT emit
display_nameclaims, and contract C7 means there's no userinfo endpoint to fetch them from. The widget shown below was originally drafted as "Logged in as Foo foo@bar.com"; the implementation surfaces a truncateduser_idinstead (Logged in as usr_abc123…). The/api/auth/mepayload retains emptydisplay_namefields for forward-compat with a future Portal userinfo endpoint (OQ-C1).
Goal: User-visible polish — the gated dashboard surfaces the current identity, hermes status reports auth-gate state, and the docs site has a guide for VPS/Fly deployments.
Task 7.1: SPA sidebar identity widget (v2)
Objective: A small "Logged in as Foo foo@bar.com ⏻" widget at the top of the sidebar. Hits /api/auth/me on mount; the logout icon POSTs /auth/logout.
Files:
- Create:
web/src/components/AuthWidget.tsx - Modify:
web/src/App.tsx— mount the widget at the top of the sidebar. - Modify:
web/src/lib/api.ts— addgetMe()andlogout().
// web/src/lib/api.ts — add
export interface AuthMe {
user_id: string;
email: string;
display_name: string;
org_id: string;
provider: string;
expires_at: number;
}
export async function getAuthMe(): Promise<AuthMe | null> {
const r = await fetch('/api/auth/me', { credentials: 'include' });
if (r.status === 401) return null;
if (!r.ok) throw new Error(`auth/me: HTTP ${r.status}`);
return r.json();
}
export async function logout(): Promise<void> {
const r = await fetch('/auth/logout', {
method: 'POST',
credentials: 'include',
redirect: 'manual',
});
// Server returns 302 → /login. With redirect: 'manual', fetch resolves
// with status 0 in browsers — we manually navigate.
window.location.href = '/login';
}
// web/src/components/AuthWidget.tsx
import { useEffect, useState } from 'react';
import { getAuthMe, logout, AuthMe } from '../lib/api';
export function AuthWidget() {
const [me, setMe] = useState<AuthMe | null | undefined>(undefined);
useEffect(() => {
if (!window.__HERMES_AUTH_REQUIRED__) return;
getAuthMe().then(setMe).catch(() => setMe(null));
}, []);
if (!window.__HERMES_AUTH_REQUIRED__) return null;
if (me === undefined) return <div className="text-xs opacity-50 px-3 py-2">Loading…</div>;
if (me === null) return null;
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-zinc-800 text-xs">
<div className="flex-1 min-w-0">
<div className="truncate font-medium">{me.display_name}</div>
<div className="truncate opacity-60">{me.email}</div>
</div>
<button
onClick={() => logout()}
title="Log out"
className="opacity-60 hover:opacity-100"
>⏻</button>
</div>
);
}
In web/src/App.tsx, mount at the top of the sidebar:
import { AuthWidget } from './components/AuthWidget';
// ...
<aside>
<AuthWidget />
{/* existing sidebar items */}
</aside>
Add to web/src/vite-env.d.ts (or wherever globals are declared):
interface Window {
__HERMES_SESSION_TOKEN__?: string;
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
__HERMES_BASE_PATH__?: string;
__HERMES_AUTH_REQUIRED__?: boolean;
}
Commit:
git add web/
git commit -m "feat(dashboard-auth): SPA auth widget showing logged-in identity + logout button"
Task 7.2: hermes status integration
Objective: hermes status reports whether the gateway/dashboard has an active OAuth gate.
Files:
- Modify:
hermes_cli/status.py— extend the existing reporter.
# In hermes_cli/status.py, add to the report block:
# ... after the existing nous_logged_in line ...
def _dashboard_auth_status() -> str:
"""Reports number of registered dashboard-auth providers."""
try:
from hermes_cli.dashboard_auth import list_providers
providers = list_providers()
except Exception:
return "not available"
if not providers:
return "no auth providers registered (loopback only)"
names = ", ".join(p.name for p in providers)
return f"{len(providers)} provider(s): {names}"
# Print in the report:
print(f" dashboard auth providers: {_dashboard_auth_status()}")
Commit:
git add hermes_cli/status.py
git commit -m "feat(status): report dashboard-auth provider availability"
Task 7.3: Documentation
Objective: A doc page covering the auth gate, how to deploy publicly, and how to write a custom provider plugin.
Files:
- Create:
website/docs/user-guide/features/dashboard-auth.md - Modify:
website/sidebars.ts— link the new page.
Content sketch (full prose in the file):
# Dashboard Authentication
When `hermes dashboard` binds to a non-loopback host (any IP other than
`127.0.0.1`, `localhost`, or `::1`) **without** `--insecure`, an OAuth
authentication gate is automatically engaged. By default, sign-in uses
your Nous Portal account.
## When the gate is active
| Command | Gate? |
|---|---|
| `hermes dashboard` | Off (loopback) |
| `hermes dashboard --host 0.0.0.0` | On |
| `hermes dashboard --host 0.0.0.0 --insecure` | Off (legacy escape hatch) |
| `hermes dashboard --host 192.168.1.5` | On |
| `hermes dashboard --host fly-app.fly.dev` | On |
## Default sign-in: Nous Portal
The default provider is bundled (`plugins/dashboard-auth-nous`) and needs
zero configuration. Visiting the dashboard prompts a "Sign in with Nous
Portal" button → OAuth redirect → back to your dashboard.
## Logging out
Click ⏻ in the sidebar widget, or `POST /auth/logout`. The browser cookies
are cleared and the refresh token is best-effort revoked at Portal.
## Audit log
Every sign-in attempt, success, refresh, and logout is recorded at
`$HERMES_HOME/logs/dashboard-auth.log` (JSON-lines).
## Adding a custom auth provider
(Plugin authoring guide — `DashboardAuthProvider` ABC, `register(ctx)`
example, link to `plugins/dashboard-auth-nous/` as the canonical template.)
## Forcing the legacy no-auth behavior
For trusted-network or testing scenarios, pass `--insecure`:
hermes dashboard --host 0.0.0.0 --insecure
Be aware: this exposes API keys and config without authentication. Only
use on private LANs where you trust every device.
Commit:
git add website/docs/user-guide/features/dashboard-auth.md website/sidebars.ts
git commit -m "docs(dashboard): document the OAuth auth gate + custom provider authoring"
Phase 7 Exit Gate
Visual confirmation:
- Gated dashboard renders the AuthWidget in the sidebar showing "Stub User · stub@example.test · ⏻".
- Clicking ⏻ logs out, clears cookies, redirects to
/login. hermes statusprintsdashboard auth providers: 1 provider(s): nous.- Docs site renders the new page; sidebar links work.
Risk Register
| # | Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|---|
| R1 | A misconfigured operator believes the gate is on when it isn't, exposes dashboard publicly | Med | High | start_server prints OAuth auth gate enabled to stdout at bind time. hermes status shows the active state. Audit log writes dashboard binding to host: <X>, gate: <on/off> on every start. |
| R2 | Refresh token in cookie is exfiltrated via XSS | Low | High | HttpOnly cookie prevents JS access. CSP headers on / (added in Phase 7) restrict inline-script sources. The dashboard already escapes all user-supplied content; this plan doesn't add new XSS surface. |
| R3 | OAuth redirect URI mismatch breaks the round-trip in Fly setups | Med | High | The Portal whitelists *.fly.dev/auth/callback; verify with each new Fly hostname. audit_log records idp_error events so misconfigs are visible. Operators with custom domains follow the Open Question #1 path (out of v1 scope but flagged). |
| R4 | Portal JWKS rollout slips; userinfo mode hammers Portal with one network call per dashboard request | Med | Med | 60-second per-token cache in _verify_via_userinfo keeps the load to ~1 req/min/user. Add a metric/log if cache hit rate drops. |
| R5 | The hermes-dashboard client_id is not yet registered on Portal at code-merge time |
High | Med | Phase 4 ships with the userinfo fallback and unit tests use respx mocks — code merges and CI passes without Portal. Operator-run smoke test (Phase 4 exit gate) gates the actual release. |
| R6 | Browsers reject cookies because Fly TLS terminates HTTPS but uvicorn sees HTTP without proxy_headers |
High | High | start_server re-enables proxy_headers=True when gate is active. detect_https reads from request.url.scheme which honors X-Forwarded-Proto when proxy_headers is True. Tested in Phase 3.5. |
| R7 | Memory leak in _verify_cache for the userinfo mode |
Low | Low | LRU-bounded to access tokens still in valid cookies; tokens are 1-hour-TTL so the dict size is bounded by simultaneous-user count. If telemetry shows growth, swap to lru_cache with explicit max=10000. |
| R8 | Stub provider accidentally leaks into a production build | Low | High | Lives under tests/, not plugins/ or hermes_cli/. The plugin discovery scanner doesn't traverse tests/. CI assertion: grep -r StubAuthProvider plugins/ hermes_cli/ returns nothing. |
| R9 | Loopback regression: existing dashboards stop accepting the injected token | Med | High | Phase 0's harness pins current behavior. Every subsequent phase reruns it. Pre-merge: full scripts/run_tests.sh against the whole suite. |
| R10 | Single-user-only assumption is violated by a future feature change | Low | Med | The session model treats every cookie as authoritative for the dashboard process; there's no per-user UI state. If multi-user is ever needed, audit /api/sessions, /api/config, and the PTY bridge — each currently writes to single shared state. Flagged in Open Questions #2. |
Rollout
Phases 0–3 land first as one unit (the gate + stub-driven E2E). After merge, the gate is OFF by default for everyone (loopback unchanged) and OPT-IN for non-loopback (operator must pass --insecure to bypass, or install a provider plugin).
Phases 4–7 land in sequence as the Portal cross-repo work completes. Phase 4 is the first user-visible step; Phases 5–6–7 are quality-of-life improvements that don't change correctness.
Pre-merge checklist for each phase
- All new tests pass
- Loopback regression harness from Phase 0 still passes
- No new errors at
WARNINGor higher inagent.log/gateway.logwhen starting a loopback dashboard - Manual smoke test (Phase exit gate) walked by the implementer
hermes statusoutput unchanged (until Phase 7's intentional addition)
Pre-release checklist for Phase 4
- Portal team confirms
hermes-dashboardclient_id is registered inOAUTH_CLIENT_PRODUCT_CONTEXT_MAP - Portal team confirms
https://*.fly.dev/auth/callbackis in the redirect-URI whitelist forhermes-dashboard - Portal team confirms
GET /oauth/authorizeroute is live - Portal team confirms
POST /api/oauth/tokenacceptsgrant_type=authorization_codewith PKCE - Portal team confirms access token includes
email,email_verified,nameclaims - Operator walks the end-to-end flow against staging Portal once
- Operator confirms
~/.hermes/logs/dashboard-auth.logrecordslogin_successevent with correct user_id + email - Operator confirms refresh works by setting access-token TTL to 60s and leaving the tab open for 90s
Verification Strategy
| Layer | How |
|---|---|
| Provider protocol | Unit tests in tests/hermes_cli/test_dashboard_auth_provider_base.py — every provider plugin must call assert_protocol_compliance in its own tests. |
| Cookies | Unit tests in tests/hermes_cli/test_dashboard_auth_cookies.py cover HttpOnly/Secure/SameSite/Max-Age semantics. |
| Middleware | Behavioral tests in tests/hermes_cli/test_dashboard_auth_middleware.py exercise gated vs loopback modes with the stub provider. |
| Real provider | plugins/dashboard-auth-nous/test_provider.py uses respx to mock Portal endpoints. Real-Portal smoke is operator-run. |
| End-to-end | The Phase 3 exit gate is a full browser round trip with the stub. Phase 4 exit gate is the same against staging Portal. |
| Regression | Phase 0's harness is rerun by every subsequent phase as part of its exit gate. |
| Audit log | Tests in test_dashboard_auth_audit.py confirm event types, JSON format, and token redaction. |
| WS auth | Tests in test_dashboard_auth_middleware.py::test_ws_* cover ticket mint/consume/expire across loopback and gated. |
Timeline (rough)
Each phase is independently shippable. A focused engineer can land:
| Phase | Effort |
|---|---|
| 0 | ½ day |
| 1 | 1 day |
| 2 | ½ day |
| 3 | 2 days |
| 4 | 1 day Hermes side + cross-repo coordination (variable) |
| 5 | 1 day |
| 6 | 1 day |
| 7 | 1 day |
Total: ~7–8 working days on the Hermes side. Cross-repo Portal work is on top of that and gates Phase 4's actual usefulness.
Files Changed Summary
New:
hermes_cli/dashboard_auth/__init__.pyhermes_cli/dashboard_auth/base.pyhermes_cli/dashboard_auth/registry.pyhermes_cli/dashboard_auth/audit.pyhermes_cli/dashboard_auth/cookies.pyhermes_cli/dashboard_auth/middleware.pyhermes_cli/dashboard_auth/routes.pyhermes_cli/dashboard_auth/login_page.pyhermes_cli/dashboard_auth/ws_tickets.pyhermes_cli/dashboard_auth/refresh.pyplugins/dashboard-auth-nous/plugin.yamlplugins/dashboard-auth-nous/__init__.pyplugins/dashboard-auth-nous/provider.pyplugins/dashboard-auth-nous/test_provider.pyweb/src/components/AuthWidget.tsxwebsite/docs/user-guide/features/dashboard-auth.md- 8 test files under
tests/hermes_cli/
Modified:
hermes_cli/web_server.py— addshould_require_auth, register two middlewares, update_serve_index/start_server/WS endpointshermes_cli/plugins.py— addregister_dashboard_auth_providermethodhermes_cli/status.py— report dashboard-auth stateweb/src/App.tsx,web/src/lib/api.ts,web/src/pages/ChatPage.tsx,web/src/vite-env.d.tswebsite/sidebars.ts
Cross-repo (nous-account-service):
src/server/oauth/access-token-issuer.ts— registerhermes-dashboardclient_id- New:
src/app/oauth/authorize/page.tsx(or equivalent) src/app/api/oauth/token/route.ts— acceptgrant_type=authorization_codesrc/server/oauth/access-token-issuer.ts— addemail/nameclaims forprofile emailscope- Portal config — whitelist
https://*.fly.dev/auth/callback