feat(dashboard-auth): pluggable username/password login (Option B)#38819
Merged
Conversation
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.
Contributor
🔎 Lint report:
|
| 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.
This was referenced Jun 4, 2026
1 task
9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a pluggable password (non-redirect) login path to the dashboard auth gate, plus a bundled
BasicAuthProviderthat implements it. This is the "Option B" design: extend theDashboardAuthProviderprotocol so a provider can authenticate with a username + password instead of an OAuth redirect, converging on the sameSession+ 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
--insecurehas 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/--insecureusers (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 = Trueand implementing one method.What changed
Framework (
hermes_cli/dashboard_auth/)base.py: optionalsupports_passwordflag +complete_password_login(username, password) -> Session(default raisesNotImplementedError); newInvalidCredentialsError.routes.py:POST /auth/password-login— mirrors the cookie-minting tail of/auth/callback, skips PKCE/state/code. Generic401for unknown-user and wrong-password (no enumeration oracle),404hides provider existence/capability, per-IP sliding-window rate limit (10/min →429)./api/auth/providersnow reportssupports_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) forsupports_passwordproviders; 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 akindclaim that verify/refresh enforce; cross-secret tokens rejected.config.py: documents the canonicaldashboard.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--insecureshell (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-loginend-to-end through the realgated_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.kindcheck inverify_sessionmakes the guarding test fail (not theater).scripts/run_tests.sh(per-test process isolation, 24 workers): 131 + 116 passing, 0 regressions.Open questions for review
X-Forwarded-Forbehind a proxy. Happy to make the limits configurable if desired.Infographic