diff --git a/hermes_cli/dashboard_auth/__init__.py b/hermes_cli/dashboard_auth/__init__.py
index 4a5c68b6e..faba37610 100644
--- a/hermes_cli/dashboard_auth/__init__.py
+++ b/hermes_cli/dashboard_auth/__init__.py
@@ -14,6 +14,7 @@ from hermes_cli.dashboard_auth.base import (
Session,
LoginStart,
InvalidCodeError,
+ InvalidCredentialsError,
ProviderError,
RefreshExpiredError,
assert_protocol_compliance,
@@ -30,6 +31,7 @@ __all__ = [
"Session",
"LoginStart",
"InvalidCodeError",
+ "InvalidCredentialsError",
"ProviderError",
"RefreshExpiredError",
"assert_protocol_compliance",
diff --git a/hermes_cli/dashboard_auth/base.py b/hermes_cli/dashboard_auth/base.py
index 207c7c602..06dab5dd5 100644
--- a/hermes_cli/dashboard_auth/base.py
+++ b/hermes_cli/dashboard_auth/base.py
@@ -55,6 +55,16 @@ class InvalidCodeError(Exception):
"""
+class InvalidCredentialsError(Exception):
+ """A username/password pair was rejected by a password provider.
+
+ Raised by :meth:`DashboardAuthProvider.complete_password_login`. The
+ ``/auth/password-login`` route translates this to HTTP 401 with a
+ deliberately generic detail (never distinguishing "unknown user" from
+ "wrong password") so the endpoint can't be used as a username oracle.
+ """
+
+
class RefreshExpiredError(Exception):
"""The refresh token is dead.
@@ -94,11 +104,33 @@ class DashboardAuthProvider(ABC):
Subclasses MUST set ``name`` (lowercase identifier, stable forever)
and ``display_name`` (user-facing label on the login page).
+
+ Password (non-redirect) providers:
+ A provider that authenticates with a username + password instead of
+ an OAuth redirect sets ``supports_password = True`` and implements
+ ``complete_password_login``. The login page then renders a
+ credential form (POSTing to ``/auth/password-login``) instead of a
+ "Log in with X" redirect button. Everything downstream of login —
+ ``verify_session`` / ``refresh_session`` / ``revoke_session``, the
+ session cookies, the WS-ticket mint — is identical to the OAuth
+ path, because a password session is just a :class:`Session` with
+ provider-minted opaque tokens. The OAuth methods (``start_login`` /
+ ``complete_login``) remain abstract; a pure-password provider that
+ will never be reached via the redirect flow may implement them as
+ stubs that raise ``NotImplementedError``.
"""
name: str = ""
display_name: str = ""
+ # When True, this provider authenticates via username + password
+ # (``complete_password_login``) rather than (or in addition to) the
+ # OAuth redirect flow. The login page renders a credential form for
+ # such providers; the ``/auth/password-login`` route dispatches to
+ # ``complete_password_login``. OAuth-only providers leave this False
+ # and are completely unaffected.
+ supports_password: bool = False
+
@abstractmethod
def start_login(self, *, redirect_uri: str) -> LoginStart: ...
@@ -121,6 +153,36 @@ class DashboardAuthProvider(ABC):
@abstractmethod
def revoke_session(self, *, refresh_token: str) -> None: ...
+ def complete_password_login(
+ self, *, username: str, password: str
+ ) -> "Session":
+ """Verify a username/password pair and mint a :class:`Session`.
+
+ Only called when ``supports_password`` is True (the
+ ``/auth/password-login`` route guards on the flag). The default
+ raises ``NotImplementedError`` so an OAuth-only provider that
+ forgets to set the flag fails loudly rather than silently
+ accepting credentials.
+
+ The returned ``Session`` carries provider-minted opaque
+ ``access_token`` / ``refresh_token`` exactly like the OAuth path,
+ so all downstream session handling (cookies, verify, refresh,
+ ws-tickets, logout) is identical.
+
+ Failure semantics:
+ * ``InvalidCredentialsError`` — username/password rejected. The
+ route surfaces a generic 401 (no user-vs-password
+ distinction). Implementations SHOULD spend constant time on
+ unknown users (dummy hash verify) to avoid a timing oracle.
+ * ``ProviderError`` — the backing credential store is
+ unreachable (LDAP/DB down); the route surfaces 503.
+ """
+ raise NotImplementedError(
+ f"{type(self).__name__} does not support password login "
+ "(set supports_password = True and override "
+ "complete_password_login)"
+ )
+
def assert_protocol_compliance(cls: type) -> None:
"""Raise ``TypeError`` if ``cls`` doesn't fully implement the provider protocol.
diff --git a/hermes_cli/dashboard_auth/login_page.py b/hermes_cli/dashboard_auth/login_page.py
index 74da4dbe2..645944548 100644
--- a/hermes_cli/dashboard_auth/login_page.py
+++ b/hermes_cli/dashboard_auth/login_page.py
@@ -225,6 +225,56 @@ _LOGIN_HTML_TEMPLATE = """\
outline-offset: 3px;
}}
+ /* Password provider form — same visual language as the OAuth buttons:
+ squared inputs, hairline borders, amber focus ring. */
+ .provider-form {{
+ display: grid;
+ gap: 0.75rem;
+ text-align: left;
+ }}
+ .form-title {{
+ font-family: 'Rules Compressed', 'Collapse', sans-serif;
+ font-weight: 600;
+ font-size: 0.72rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: color-mix(in srgb, var(--foreground) 70%, transparent);
+ }}
+ .field {{
+ display: grid;
+ gap: 0.3rem;
+ }}
+ .field-label {{
+ font-size: 0.72rem;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: color-mix(in srgb, var(--foreground) 55%, transparent);
+ }}
+ .field-input {{
+ width: 100%;
+ box-sizing: border-box;
+ padding: 0.7rem 0.8rem;
+ background: color-mix(in srgb, #000000 25%, var(--background-base));
+ color: var(--foreground);
+ border: 1px solid var(--hairline-strong);
+ border-radius: 0;
+ font-family: 'Collapse', sans-serif;
+ font-size: 0.95rem;
+ }}
+ .field-input:focus-visible {{
+ outline: none;
+ border-color: var(--midground);
+ box-shadow: 0 0 0 1px var(--midground);
+ }}
+ .form-error {{
+ color: #ff6b6b;
+ font-size: 0.82rem;
+ letter-spacing: 0.02em;
+ }}
+ .provider-form .provider-btn {{
+ margin-top: 0.25rem;
+ }}
+
footer {{
margin-top: 1.75rem;
text-align: center;
@@ -264,6 +314,7 @@ _LOGIN_HTML_TEMPLATE = """\
Public bind · Auth required
+{password_script}