Skip to content

feat(dashboard-auth): pluggable username/password login (Option B)#38819

Merged
teknium1 merged 4 commits into
mainfrom
feat/dashboard-auth-password-provider
Jun 4, 2026
Merged

feat(dashboard-auth): pluggable username/password login (Option B)#38819
teknium1 merged 4 commits into
mainfrom
feat/dashboard-auth-password-provider

Conversation

@benbarclay

@benbarclay benbarclay commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a pluggable password (non-redirect) login path to the dashboard auth gate, plus a bundled BasicAuthProvider that implements it. This is the "Option B" design: extend the DashboardAuthProvider protocol so a provider can authenticate with a username + password instead of an OAuth redirect, converging on the same Session + cookie + transparent-refresh + ws-ticket + logout + audit machinery the OAuth path already uses.

Why this matters (impact case)

Today the gate is OAuth-only. A self-hoster who wants to expose the dashboard on a non-loopback address without --insecure has exactly one option: stand up (or be provisioned by) a Nous Portal OAuth client. There is no first-class "just put a password on it" path. This fixes that for the self-hosted / on-prem / homelab operator — a narrow but real audience that currently has to choose between --insecure (no auth) and running an OAuth IDP. It does not change anything for existing OAuth or loopback/--insecure users (the new provider is a no-op unless explicitly configured).

It's also an extension point, not just one provider: LDAP-bind, a credentials DB, or any non-redirect scheme can now register by setting supports_password = True and implementing one method.

What changed

Framework (hermes_cli/dashboard_auth/)

  • base.py: optional supports_password flag + complete_password_login(username, password) -> Session (default raises NotImplementedError); new InvalidCredentialsError.
  • routes.py: POST /auth/password-login — mirrors the cookie-minting tail of /auth/callback, skips PKCE/state/code. Generic 401 for unknown-user and wrong-password (no enumeration oracle), 404 hides provider existence/capability, per-IP sliding-window rate limit (10/min → 429). /api/auth/providers now reports supports_password.
  • middleware.py: allowlist the bootstrap route (1 line). verify/refresh/revoke/ws-tickets/logout unchanged.
  • login_page.py: render a credential form (+ small inline script) for supports_password providers; OAuth-only pages stay script-free.

Plugin (plugins/dashboard_auth/basic/)

  • BasicAuthProvider: zero-infrastructure (no IDP, no DB). Stateless HMAC-signed session tokens; passwords hashed with stdlib scrypt (deliberately no bcrypt — keeps the dependency surface unchanged). Constant-time verify with a dummy-hash path for unknown users; access/refresh tokens carry a kind claim that verify/refresh enforce; cross-secret tokens rejected.
  • config.py: documents the canonical dashboard.basic_auth.* surface.

Docs: web-dashboard guide section + HERMES_DASHBOARD_BASIC_AUTH_* env-var reference.

How it differs from the rewbs/basic-auth PR

That PR hardens the legacy _SESSION_TOKEN --insecure shell (stops the SPA bootstrap leaking the token, lets you log in with HTTP Basic using the session token as the password). This PR adds a real per-user password provider to the secure gate via the plugin protocol. They're complementary — different lane, different problem.

Test plan

  • test_dashboard_auth_password_login.py — drives /auth/password-login end-to-end through the real gated_auth_middleware: login → session cookie → authenticated /api/auth/me → transparent refresh via the RT cookie. Plus protocol extension, generic-401/404 oracle properties, rate limiter, and login-page rendering (form+script vs script-free vs mixed providers).
  • test_basic_provider.py — scrypt hash/verify, login mint, kind-claim enforcement, cross-secret rejection, register() config/env precedence + skip reasons.
  • Mutation-tested: dropping the kind check in verify_session makes the guarding test fail (not theater).
  • Full dashboard-auth + plugin + config suites green under scripts/run_tests.sh (per-test process isolation, 24 workers): 131 + 116 passing, 0 regressions.

Open questions for review

  1. Token strategy — I went stateless HMAC-signed (zero infra; revoke is a no-op / TTL expiry). A server-side session store would enable real revoke but needs storage. Stateless seemed right for a single-box self-hosted dashboard; flag if you'd prefer the store.
  2. Rate limiter — in-process sliding window, per client IP, defence-in-depth on top of the provider's constant-time verify. Process-local (resets on restart) and trusts X-Forwarded-For behind a proxy. Happy to make the limits configurable if desired.

Infographic

pluggable-password-login

The dashboard auth gate was OAuth-only: a DashboardAuthProvider could
authenticate only via a redirect to an IDP (start_login -> /auth/callback
-> complete_login). There was no first-class path for username/password
auth, so self-hosters who just want a password on their dashboard had no
clean option short of an external OAuth IDP.

Extend the provider framework with a parallel, non-redirect front door
that converges on the same Session + cookie + refresh machinery:

  - base.py: add the optional supports_password flag and
    complete_password_login(username, password) -> Session (default
    raises NotImplementedError so an OAuth-only provider that forgets the
    flag fails loudly). Add InvalidCredentialsError. OAuth providers are
    unaffected (flag defaults False; the method is never called).
  - routes.py: add POST /auth/password-login, mirroring the cookie-minting
    tail of /auth/callback but skipping PKCE/state/code. Returns JSON
    {ok, next} (the form POSTs via fetch). Generic 401 for both unknown
    user and wrong password (no enumeration oracle); 404 hides whether a
    provider exists or supports passwords; per-IP sliding-window rate
    limit (10/min -> 429). /api/auth/providers now reports
    supports_password so the login page can branch.
  - middleware.py: allowlist /auth/password-login (a bootstrap route).
    verify/refresh/revoke/ws-tickets/logout need zero changes — a password
    session is just a Session with provider-minted opaque tokens.
  - login_page.py: render a credential form (instead of a redirect button)
    for supports_password providers, wired by a small inline script that
    POSTs to /auth/password-login and navigates on success. OAuth-only
    pages stay script-free.
A bundled, zero-infrastructure 'just put a password on my dashboard'
provider that uses the supports_password extension point. No external IDP,
no database: sessions are stateless HMAC-signed tokens the provider mints
and verifies itself, and passwords are hashed with stdlib scrypt (no
third-party dependency — deliberately avoids bcrypt to keep the dep
surface unchanged).

  - plugins/dashboard_auth/basic: BasicAuthProvider (scrypt verify with a
    constant-time dummy-hash path for unknown users so the endpoint is not
    a username-timing oracle; access/refresh tokens carry a 'kind' claim
    that verify/refresh enforce; cross-secret tokens are rejected). The
    register() entry point mirrors the Nous plugin's config/env precedence
    (env wins; empty treated as unset) and LAST_SKIP_REASON channel.
  - config.py: document the canonical dashboard.basic_auth.* surface
    (username / password_hash / password / secret / session_ttl_seconds).

Activates only when username + (password or password_hash) are set, so
OAuth users and loopback/--insecure operators are unaffected. Without an
explicit secret a random per-process key is generated (logged): fine for a
single process, but sessions then don't survive restart or span workers.
  - test_dashboard_auth_password_login.py: drives /auth/password-login
    end-to-end through the REAL gated_auth_middleware (login -> session
    cookie -> authenticated /api/auth/me -> transparent refresh via the RT
    cookie), plus protocol-extension checks, the generic-401/404 oracle
    properties, the rate limiter, and login-page rendering (form+script
    when supports_password, script-free otherwise, both for mixed
    providers). Reuses the existing StubAuthProvider harness convention.
  - test_basic_provider.py: scrypt hash/verify, login mint, kind-claim
    enforcement (access != refresh), cross-secret rejection, and the
    register() config/env precedence + skip reasons.

Mutation-tested: dropping the kind-claim check in verify_session makes
test_access_token_not_accepted_as_refresh fail, confirming the test isn't
theater.
Add a 'Username/password provider (no OAuth IDP)' section to the web
dashboard guide (config.yaml + env surfaces, the explicit-secret caveat,
the rate-limit/generic-401 properties, and a 'write your own password
provider' pointer to the supports_password extension point), and list the
HERMES_DASHBOARD_BASIC_AUTH_* env vars in the environment-variables
reference.
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: feat/dashboard-auth-password-provider vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9788 on HEAD, 9783 on base (🆕 +5)

🆕 New issues (5):

Rule Count
unresolved-import 4
unresolved-attribute 1
First entries
hermes_cli/dashboard_auth/routes.py:26: [unresolved-import] unresolved-import: Cannot resolve imported module `pydantic`
tests/hermes_cli/test_dashboard_auth_password_login.py:24: [unresolved-import] unresolved-import: Cannot resolve imported module `fastapi.testclient`
tests/plugins/dashboard_auth/test_basic_provider.py:14: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
tests/hermes_cli/test_dashboard_auth_password_login.py:393: [unresolved-attribute] unresolved-attribute: Attribute `status_code` is not defined on `None` in union `None | Unknown`
tests/hermes_cli/test_dashboard_auth_password_login.py:17: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`

✅ Fixed issues: none

Unchanged: 5076 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants