Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1482,6 +1482,34 @@ def _ensure_hermes_home_managed(home: Path):
"client_id": "", # agent:{instance_id} — Portal provisions this
"portal_url": "", # blank → use plugin default (production Portal)
},
# Username/password gate configuration — read by the bundled
# ``dashboard_auth/basic`` plugin (a self-hosted "just put a
# password on my dashboard" provider that needs no OAuth IDP).
# The plugin registers a password provider when ``username`` plus
# either ``password_hash`` (preferred — no plaintext at rest) or
# ``password`` (plaintext, hashed in-memory at load) are set. Each
# key is overridable by an env var
# (``HERMES_DASHBOARD_BASIC_AUTH_USERNAME`` /
# ``_PASSWORD_HASH`` / ``_PASSWORD`` / ``_SECRET`` /
# ``_TTL_SECONDS``), env winning when non-empty. Leave ``username``
# empty (the default) to keep the plugin a no-op — loopback /
# ``--insecure`` operators and OAuth users are unaffected.
#
# ``secret`` is the HMAC key used to sign the stateless session
# tokens this provider mints. When empty, a random per-process key
# is generated — fine for a single process, but sessions then
# don't survive a restart or span multiple workers. Set an
# explicit ``secret`` (32+ random bytes, base64/hex/raw) for
# stable multi-worker / restart-surviving sessions. Compute a
# ``password_hash`` with
# ``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"``.
"basic_auth": {
"username": "", # blank → plugin no-op (no password provider)
"password_hash": "", # scrypt$... (preferred — no plaintext at rest)
"password": "", # plaintext fallback (hashed in-memory at load)
"secret": "", # token-signing key; blank → random per-process
"session_ttl_seconds": 0, # 0 → plugin default (12h)
},
# Public URL override (env: ``HERMES_DASHBOARD_PUBLIC_URL``).
# When set, this is the complete authority — scheme + host +
# optional path prefix (e.g. ``https://example.com/hermes``) —
Expand Down
2 changes: 2 additions & 0 deletions hermes_cli/dashboard_auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
Session,
LoginStart,
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
RefreshExpiredError,
assert_protocol_compliance,
Expand All @@ -30,6 +31,7 @@
"Session",
"LoginStart",
"InvalidCodeError",
"InvalidCredentialsError",
"ProviderError",
"RefreshExpiredError",
"assert_protocol_compliance",
Expand Down
62 changes: 62 additions & 0 deletions hermes_cli/dashboard_auth/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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: ...

Expand All @@ -121,6 +153,36 @@ def refresh_session(self, *, refresh_token: str) -> Session: ...
@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.
Expand Down
162 changes: 156 additions & 6 deletions hermes_cli/dashboard_auth/login_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,56 @@ class name MUST NOT change without updating
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;
Expand Down Expand Up @@ -264,6 +314,7 @@ class name MUST NOT change without updating
<span class="sep"></span>Public bind &middot; Auth required<span class="sep"></span>
</footer>
</main>
{password_script}
</body>
</html>
"""
Expand Down Expand Up @@ -350,6 +401,60 @@ class name MUST NOT change without updating
"""


# Inline script that wires every password provider form to POST JSON to
# ``/auth/password-login`` and navigate on success. Emitted ONLY when at
# least one ``supports_password`` provider is listed (OAuth-only login
# pages stay script-free, preserving the no-JS contract for that case).
#
# Plain string (NOT run through ``str.format``), so braces are literal —
# do not double them. A single delegated submit handler covers all forms;
# the provider name is read from the form's ``data-provider`` attribute.
_PASSWORD_FORM_SCRIPT = """\
<script>
(function () {
function handle(form) {
form.addEventListener('submit', function (ev) {
ev.preventDefault();
var err = form.querySelector('.form-error');
var btn = form.querySelector('button[type=submit]');
if (err) { err.hidden = true; err.textContent = ''; }
if (btn) { btn.disabled = true; }
var body = {
provider: form.getAttribute('data-provider') || '',
username: (form.querySelector('input[name=username]') || {}).value || '',
password: (form.querySelector('input[name=password]') || {}).value || '',
next: (form.querySelector('input[name=next]') || {}).value || ''
};
fetch('/auth/password-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
credentials: 'same-origin'
}).then(function (resp) {
if (resp.ok) {
return resp.json().then(function (data) {
window.location.assign((data && data.next) || '/');
});
}
var msg = resp.status === 429
? 'Too many attempts. Please wait and try again.'
: (resp.status === 401 ? 'Invalid username or password.'
: 'Sign-in failed. Please try again.');
if (err) { err.textContent = msg; err.hidden = false; }
if (btn) { btn.disabled = false; }
}).catch(function () {
if (err) { err.textContent = 'Network error. Please try again.'; err.hidden = false; }
if (btn) { btn.disabled = false; }
});
});
}
var forms = document.querySelectorAll('form.provider-form');
for (var i = 0; i < forms.length; i++) { handle(forms[i]); }
})();
</script>
"""


def render_login_html(*, next_path: str = "") -> str:
"""Return the full HTML for ``GET /login``.

Expand All @@ -375,10 +480,55 @@ def render_login_html(*, next_path: str = "") -> str:
next_qs = ""

buttons = []
needs_password_script = False
for p in providers:
buttons.append(
f' <a class="provider-btn" '
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
return _LOGIN_HTML_TEMPLATE.format(provider_buttons="\n".join(buttons))
if getattr(p, "supports_password", False):
needs_password_script = True
buttons.append(_render_password_form(p, next_path))
else:
buttons.append(
f' <a class="provider-btn" '
f'href="/auth/login?provider={html.escape(p.name, quote=True)}{next_qs}">'
f'Sign in with {html.escape(p.display_name)}</a>'
)
script = _PASSWORD_FORM_SCRIPT if needs_password_script else ""
return _LOGIN_HTML_TEMPLATE.format(
provider_buttons="\n".join(buttons),
password_script=script,
)


def _render_password_form(provider, next_path: str) -> str:
"""Render a username/password form for a ``supports_password`` provider.

The form is wired by :data:`_PASSWORD_FORM_SCRIPT` (a single delegated
submit handler) to POST JSON to ``/auth/password-login`` and navigate
on success. ``next_path`` is carried in a hidden field; it has already
been validated same-origin by the caller and is HTML-escaped here as
defence in depth. The provider ``name`` is emitted in a ``data-``
attribute (not a hidden input) so the script reads it without trusting
form-field ordering.
"""
pname = html.escape(provider.name, quote=True)
plabel = html.escape(provider.display_name)
safe_next = html.escape(next_path, quote=True) if next_path else ""
return (
f' <form class="provider-form" data-provider="{pname}" '
f'autocomplete="on">\n'
f' <div class="form-title">Sign in with {plabel}</div>\n'
f' <input type="hidden" name="next" value="{safe_next}">\n'
f' <label class="field">\n'
f' <span class="field-label">Username</span>\n'
f' <input class="field-input" type="text" name="username" '
f'autocomplete="username" autocapitalize="none" '
f'autocorrect="off" spellcheck="false" required>\n'
f' </label>\n'
f' <label class="field">\n'
f' <span class="field-label">Password</span>\n'
f' <input class="field-input" type="password" name="password" '
f'autocomplete="current-password" required>\n'
f' </label>\n'
f' <div class="form-error" role="alert" hidden></div>\n'
f' <button class="provider-btn" type="submit">Sign in</button>\n'
f' </form>'
)
1 change: 1 addition & 0 deletions hermes_cli/dashboard_auth/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
"/auth/login",
"/auth/callback",
"/auth/password-login",
"/auth/logout",
"/login",
"/api/auth/providers",
Expand Down
Loading
Loading