Hermes-agent
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
"""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,
|
||||
InvalidCodeError,
|
||||
InvalidCredentialsError,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
assert_protocol_compliance,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.registry import (
|
||||
register_provider,
|
||||
get_provider,
|
||||
list_providers,
|
||||
clear_providers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DashboardAuthProvider",
|
||||
"Session",
|
||||
"LoginStart",
|
||||
"InvalidCodeError",
|
||||
"InvalidCredentialsError",
|
||||
"ProviderError",
|
||||
"RefreshExpiredError",
|
||||
"assert_protocol_compliance",
|
||||
"register_provider",
|
||||
"get_provider",
|
||||
"list_providers",
|
||||
"clear_providers",
|
||||
]
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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.
|
||||
|
||||
This module deliberately keeps a minimal dependency surface — no imports
|
||||
from ``hermes_constants`` or other hermes_cli modules — so it can be
|
||||
imported safely from middleware code that loads early in the startup
|
||||
sequence.
|
||||
"""
|
||||
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 = frozenset({
|
||||
"access_token", "refresh_token", "code", "code_verifier",
|
||||
"state", "ticket", "cookie", "Authorization", "authorization",
|
||||
})
|
||||
|
||||
|
||||
class AuditEvent(enum.Enum):
|
||||
"""Event types written to dashboard-auth.log.
|
||||
|
||||
Values are the literal ``event`` field on the JSON line.
|
||||
"""
|
||||
|
||||
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"
|
||||
WS_TICKET_REJECTED = "ws_ticket_rejected"
|
||||
|
||||
|
||||
def _resolve_log_path() -> Path:
|
||||
"""``$HERMES_HOME/logs/dashboard-auth.log`` with the standard fallback.
|
||||
|
||||
Mirrors ``hermes_constants.get_hermes_home`` semantics: env var wins,
|
||||
else ``~/.hermes``. A local copy avoids an import cycle with 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)
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Abstract base + dataclasses + exceptions 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 (when over HTTPS) + SameSite=Lax with a TTL ≤ 10
|
||||
minutes (the login lifetime).
|
||||
"""
|
||||
|
||||
redirect_url: str
|
||||
cookie_payload: dict[str, str]
|
||||
|
||||
|
||||
class ProviderError(Exception):
|
||||
"""IDP unreachable, network error, or other transient failure.
|
||||
|
||||
Middleware translates this to HTTP 503.
|
||||
"""
|
||||
|
||||
|
||||
class InvalidCodeError(Exception):
|
||||
"""The OAuth callback ``code`` / ``state`` failed validation.
|
||||
|
||||
Middleware translates this to HTTP 400.
|
||||
"""
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Middleware clears cookies and forces re-login (302 → ``/login``).
|
||||
"""
|
||||
|
||||
|
||||
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;
|
||||
``ProviderError`` if the IDP is unreachable.
|
||||
* ``verify_session`` returns ``None`` on expiry / unknown token;
|
||||
raises ``ProviderError`` if the IDP is unreachable. 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.
|
||||
Raises ``ProviderError`` on network failure.
|
||||
* ``revoke_session`` is best-effort and must not raise.
|
||||
|
||||
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: ...
|
||||
|
||||
@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: ...
|
||||
|
||||
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.
|
||||
|
||||
Call this in every provider plugin's unit tests::
|
||||
|
||||
def test_protocol_compliance():
|
||||
assert_protocol_compliance(MyProvider)
|
||||
|
||||
Returns ``None`` on success so callers can assert it explicitly.
|
||||
"""
|
||||
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__)}"
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Cookie helpers for dashboard auth.
|
||||
|
||||
Three cookies in play:
|
||||
- hermes_session_at: the OAuth access token
|
||||
(HttpOnly, lifetime = token TTL, ~15 min)
|
||||
- hermes_session_rt: the OAuth refresh token
|
||||
(HttpOnly, lifetime = 24h, ROTATING + reuse-detected)
|
||||
Nous Portal issues a rotating refresh token for the
|
||||
dashboard auth-code grant (Portal NAS #293 / hermes
|
||||
#37247). ``set_session_cookies`` writes this cookie
|
||||
whenever the provider returns a non-empty
|
||||
``refresh_token``; the middleware uses it to rotate a
|
||||
fresh access token transparently on AT expiry. A
|
||||
provider that omits the refresh token (empty string)
|
||||
degrades gracefully to access-token-only sessions —
|
||||
the RT cookie is simply not written.
|
||||
- hermes_session_pkce: short-lived PKCE state + CSRF nonce + provider
|
||||
hint (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 live under the prefix's Path. ``Secure`` is set
|
||||
ONLY when the dashboard was reached over HTTPS — detected via the
|
||||
request URL scheme, which honours ``X-Forwarded-Proto`` upstream of
|
||||
Fly's TLS terminator when uvicorn is configured with
|
||||
``proxy_headers=True``. Loopback dev traffic is always HTTP so
|
||||
``Secure`` would lock the cookies out of the browser.
|
||||
|
||||
Cookie prefix selection (browser hardening per
|
||||
https://datatracker.ietf.org/doc/html/draft-west-cookie-prefixes):
|
||||
|
||||
* Loopback HTTP — bare name. ``__Host-`` / ``__Secure-`` require
|
||||
``Secure``, which is incompatible with HTTP.
|
||||
* Gated HTTPS, direct deploy (Path=/) — ``__Host-`` prefix. Binds the
|
||||
cookie to the exact origin (no Domain attribute) — strongest spec
|
||||
guarantee.
|
||||
* Gated HTTPS, behind a reverse-proxy prefix (Path=/hermes) —
|
||||
``__Secure-`` prefix. ``__Host-`` is disallowed when Path != "/";
|
||||
``__Secure-`` keeps the Secure-required hardening without the
|
||||
Path constraint, and the explicit ``Path=/hermes`` covers
|
||||
same-origin app isolation.
|
||||
|
||||
The setters and readers BOTH consult the active prefix because the
|
||||
cookie *name* changes — a reader that looked up the bare name when the
|
||||
setter wrote ``__Secure-hermes_session_at`` would never find the value.
|
||||
|
||||
Refresh-token handling:
|
||||
``set_session_cookies`` accepts ``refresh_token=""`` (provider omitted
|
||||
it) and silently skips writing the RT cookie in that case, so a
|
||||
refresh-token-less provider degrades to access-token-only sessions.
|
||||
``clear_session_cookies`` always emits a Max-Age=0 deletion for the RT
|
||||
cookie on logout / session expiry so a stale cookie from an earlier
|
||||
deployment gets cleared. The transparent rotation flow ("expired AT +
|
||||
live RT → rotate server-side, else 401 → /login") lives in
|
||||
``middleware._attempt_refresh``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
# Bare cookie names — the request-scoped ``_resolved_name`` helper
|
||||
# decides whether to prepend ``__Host-`` / ``__Secure-`` based on the
|
||||
# request's HTTPS + prefix combination.
|
||||
SESSION_AT_COOKIE = "hermes_session_at"
|
||||
SESSION_RT_COOKIE = "hermes_session_rt"
|
||||
PKCE_COOKIE = "hermes_session_pkce"
|
||||
|
||||
# Possible name variants we may have to read back. Sorted so most-strict
|
||||
# wins on iteration when both happen to be present (shouldn't happen in
|
||||
# practice — a single request emits exactly one variant).
|
||||
_NAME_VARIANTS = ("__Host-", "__Secure-", "")
|
||||
|
||||
# RT cookie Max-Age. Kept at 30 days as a generous upper bound on the cookie's
|
||||
# browser lifetime; Portal's actual refresh-token TTL (24h, rotating) is the
|
||||
# real authority — once the RT itself expires/rotates out, a refresh attempt
|
||||
# returns 400 → RefreshExpiredError → clean re-login, regardless of how long
|
||||
# the cookie lingers. (Not tightened to 24h here to avoid coupling the cookie
|
||||
# lifetime to a server-side TTL that can change independently; revisit if the
|
||||
# stale-cookie refresh churn ever matters.)
|
||||
_RT_MAX_AGE = 30 * 24 * 60 * 60
|
||||
_PKCE_MAX_AGE = 10 * 60
|
||||
|
||||
|
||||
def _resolved_name(bare: str, *, use_https: bool, prefix: str) -> str:
|
||||
"""Pick the cookie-prefix variant for the active request shape.
|
||||
|
||||
See module docstring for the prefix selection rules. Mismatch
|
||||
between setter and reader would silently break sessions, so this
|
||||
function is the single source of truth for naming.
|
||||
"""
|
||||
if not use_https:
|
||||
return bare
|
||||
if prefix:
|
||||
# Path != "/" forbids __Host-; fall back to __Secure-.
|
||||
return f"__Secure-{bare}"
|
||||
return f"__Host-{bare}"
|
||||
|
||||
|
||||
def _cookie_path(prefix: str) -> str:
|
||||
"""Cookie ``Path`` attribute for the active deploy shape.
|
||||
|
||||
Under ``X-Forwarded-Prefix: /hermes`` we want ``Path=/hermes`` so:
|
||||
a) the browser sends the cookie back on requests under the prefix
|
||||
(browsers omit the cookie if request path doesn't start with
|
||||
Path);
|
||||
b) the cookie doesn't leak to other apps on the same origin
|
||||
(``mission-control.tilos.com/billing/...``).
|
||||
|
||||
Direct-deploy (no proxy prefix) gets ``Path=/``.
|
||||
"""
|
||||
return prefix if prefix else "/"
|
||||
|
||||
|
||||
def _common_attrs(*, use_https: bool, prefix: str) -> dict:
|
||||
attrs: dict = {
|
||||
"httponly": True,
|
||||
"samesite": "lax",
|
||||
"path": _cookie_path(prefix),
|
||||
}
|
||||
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,
|
||||
prefix: str = "",
|
||||
) -> None:
|
||||
"""Set the session cookies on the response.
|
||||
|
||||
``access_token_expires_in`` is in seconds. Use the provider's reported
|
||||
TTL for the access token.
|
||||
|
||||
``refresh_token`` is written as the RT cookie when non-empty. Nous Portal
|
||||
issues a 24h rotating refresh token (hermes #37247); a provider that
|
||||
omits it returns ``Session.refresh_token == ""`` and we simply don't
|
||||
persist the RT cookie — the session then behaves as access-token-only
|
||||
until the AT expires. No other branch changes between the two cases.
|
||||
|
||||
``prefix`` is the normalised X-Forwarded-Prefix value (e.g. ``/hermes``)
|
||||
or ``""`` for a direct deploy. It influences both the cookie name
|
||||
(``__Host-`` vs ``__Secure-`` vs bare) and the ``Path`` attribute.
|
||||
"""
|
||||
response.set_cookie(
|
||||
_resolved_name(SESSION_AT_COOKIE, use_https=use_https, prefix=prefix),
|
||||
access_token,
|
||||
max_age=access_token_expires_in,
|
||||
**_common_attrs(use_https=use_https, prefix=prefix),
|
||||
)
|
||||
# Contract v1: empty refresh token means "don't persist RT cookie".
|
||||
# Keeping a literal empty-value cookie around would be dead state at
|
||||
# best, attack surface at worst.
|
||||
if refresh_token:
|
||||
response.set_cookie(
|
||||
_resolved_name(SESSION_RT_COOKIE, use_https=use_https, prefix=prefix),
|
||||
refresh_token,
|
||||
max_age=_RT_MAX_AGE,
|
||||
**_common_attrs(use_https=use_https, prefix=prefix),
|
||||
)
|
||||
|
||||
|
||||
def clear_session_cookies(response: Response, *, prefix: str = "") -> None:
|
||||
"""Emit Max-Age=0 deletions for both session cookies.
|
||||
|
||||
To delete a cookie reliably the deletion's ``Path`` must match the
|
||||
set path AND the cookie name must match the variant the setter used.
|
||||
We don't know which variant was originally set (cookie prefix
|
||||
depends on the request that set it), so we emit deletions for every
|
||||
plausible variant under the active path.
|
||||
"""
|
||||
path = _cookie_path(prefix)
|
||||
for variant in _NAME_VARIANTS:
|
||||
response.set_cookie(
|
||||
f"{variant}{SESSION_AT_COOKIE}", "", max_age=0,
|
||||
path=path, httponly=True, samesite="lax",
|
||||
)
|
||||
response.set_cookie(
|
||||
f"{variant}{SESSION_RT_COOKIE}", "", max_age=0,
|
||||
path=path, httponly=True, samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def set_pkce_cookie(
|
||||
response: Response, *, payload: str, use_https: bool, prefix: str = "",
|
||||
) -> None:
|
||||
response.set_cookie(
|
||||
_resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix),
|
||||
payload,
|
||||
max_age=_PKCE_MAX_AGE,
|
||||
**_common_attrs(use_https=use_https, prefix=prefix),
|
||||
)
|
||||
|
||||
|
||||
def clear_pkce_cookie(response: Response, *, prefix: str = "") -> None:
|
||||
path = _cookie_path(prefix)
|
||||
for variant in _NAME_VARIANTS:
|
||||
response.set_cookie(
|
||||
f"{variant}{PKCE_COOKIE}", "", max_age=0,
|
||||
path=path, httponly=True, samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
def _read_with_fallback(
|
||||
request: Request, bare_name: str,
|
||||
) -> Optional[str]:
|
||||
"""Read a cookie by checking every prefix variant in order.
|
||||
|
||||
The setter chooses one variant based on the active request shape;
|
||||
the reader doesn't know which one fired (the request that READS
|
||||
the cookie may not be the same shape as the request that SET it
|
||||
in pathological cases). Trying all three guarantees we find it.
|
||||
"""
|
||||
for variant in _NAME_VARIANTS:
|
||||
value = request.cookies.get(f"{variant}{bare_name}")
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def read_session_cookies(request: Request) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Returns (access_token, refresh_token), either may be None."""
|
||||
at = _read_with_fallback(request, SESSION_AT_COOKIE)
|
||||
rt = _read_with_fallback(request, SESSION_RT_COOKIE)
|
||||
return at, rt
|
||||
|
||||
|
||||
def read_pkce_cookie(request: Request) -> Optional[str]:
|
||||
return _read_with_fallback(request, PKCE_COOKIE)
|
||||
|
||||
|
||||
def detect_https(request: Request) -> bool:
|
||||
"""Decide whether to set the ``Secure`` cookie flag.
|
||||
|
||||
Reads ``request.url.scheme`` — under uvicorn's ``proxy_headers=True``
|
||||
(which start_server enables when the gate is active), this honours
|
||||
``X-Forwarded-Proto`` from Fly's TLS terminator. Loopback traffic is
|
||||
always HTTP so this returns False there.
|
||||
"""
|
||||
return request.url.scheme == "https"
|
||||
@@ -0,0 +1,534 @@
|
||||
"""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>``.
|
||||
|
||||
Visual styling mirrors the Nous Research design system (the
|
||||
``@nous-research/ui`` package the React dashboard uses): the same
|
||||
``Collapse`` / ``Rules Compressed`` typeface, amber-on-dark colour
|
||||
tokens (``#170d02`` / ``#ffac02`` / ``#fff``), uppercase + wide-tracking
|
||||
brand chrome, and the inset-bevel button shadow. Fonts are served
|
||||
out of the SPA's ``/fonts/`` directory which the dashboard-auth gate
|
||||
already allowlists pre-auth (see ``_GATE_PUBLIC_PREFIXES`` in
|
||||
``middleware.py``), so the page renders without needing the React
|
||||
bundle loaded.
|
||||
|
||||
Test-stable class names: the existing test suite extracts the
|
||||
``class="provider-btn"`` anchor href to walk the OAuth flow. That
|
||||
class name MUST NOT change without updating
|
||||
``tests/hermes_cli/test_dashboard_auth_401_reauth.py``.
|
||||
"""
|
||||
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.
|
||||
#
|
||||
# Single curly braces are placeholders for ``str.format``; CSS curlies
|
||||
# are doubled (``{{`` / ``}}``).
|
||||
_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>
|
||||
/* Brand fonts shipped by @nous-research/ui — same files the SPA loads. */
|
||||
@font-face {{
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Regular.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Bold.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Regular.woff2') format('woff2');
|
||||
}}
|
||||
@font-face {{
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Medium.woff2') format('woff2');
|
||||
}}
|
||||
|
||||
:root {{
|
||||
--background-base: #170d02;
|
||||
--background: #170d02;
|
||||
--midground: #ffac02;
|
||||
--foreground: #ffffff;
|
||||
--hairline: color-mix(in srgb, #ffac02 18%, transparent);
|
||||
--hairline-strong: color-mix(in srgb, #ffac02 35%, transparent);
|
||||
}}
|
||||
|
||||
*, *::before, *::after {{ box-sizing: border-box; }}
|
||||
|
||||
html, body {{
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: var(--background-base);
|
||||
color: var(--foreground);
|
||||
font-family: 'Collapse', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}}
|
||||
|
||||
/* Subtle dot-grid backdrop — DS idiom (see `.dither` in globals.css). */
|
||||
body {{
|
||||
background-image:
|
||||
radial-gradient(
|
||||
ellipse at top,
|
||||
color-mix(in srgb, var(--midground) 6%, transparent) 0%,
|
||||
transparent 55%
|
||||
),
|
||||
repeating-conic-gradient(
|
||||
color-mix(in srgb, var(--midground) 4%, transparent) 0% 25%,
|
||||
transparent 0% 50%
|
||||
);
|
||||
background-size: auto, 3px 3px;
|
||||
background-attachment: fixed;
|
||||
}}
|
||||
|
||||
/* Layout: vertically center on tall screens, top-anchor on short. */
|
||||
body {{
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: clamp(1.5rem, 6vh, 6rem) 1.25rem;
|
||||
}}
|
||||
|
||||
main {{
|
||||
width: 100%;
|
||||
max-width: 26rem;
|
||||
position: relative;
|
||||
animation: slide-up 0.6s ease-out both;
|
||||
}}
|
||||
|
||||
@keyframes slide-up {{
|
||||
from {{ opacity: 0; transform: translateY(6px); }}
|
||||
to {{ opacity: 1; transform: translateY(0); }}
|
||||
}}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {{
|
||||
main {{ animation: none; }}
|
||||
}}
|
||||
|
||||
/* Brand wordmark above the card — same uppercase + wide-tracking
|
||||
idiom DS Buttons use. */
|
||||
.brand {{
|
||||
text-align: center;
|
||||
margin-bottom: 1.75rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.32em;
|
||||
text-transform: uppercase;
|
||||
color: var(--midground);
|
||||
}}
|
||||
.brand .dot {{
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--midground);
|
||||
margin: 0 0.55em 0.18em;
|
||||
vertical-align: middle;
|
||||
border-radius: 1px;
|
||||
}}
|
||||
|
||||
.card {{
|
||||
position: relative;
|
||||
padding: 2.25rem 2rem 2rem;
|
||||
background: color-mix(in srgb, #ffffff 2%, var(--background-base));
|
||||
border: 1px solid var(--hairline);
|
||||
/* Hairline highlight + bevel shadow — matches DS Button SHADOW_DEFAULT
|
||||
(`inset -1px -1px 0 #00000080, inset 1px 1px 0 #ffffff80`) at panel scale. */
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 color-mix(in srgb, #ffffff 5%, transparent),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.4),
|
||||
0 24px 60px -20px rgba(0, 0, 0, 0.6);
|
||||
}}
|
||||
|
||||
h1 {{
|
||||
margin: 0 0 0.4rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 1.85rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--foreground);
|
||||
}}
|
||||
|
||||
.subtitle {{
|
||||
margin: 0 0 1.75rem;
|
||||
color: color-mix(in srgb, var(--foreground) 65%, transparent);
|
||||
font-size: 0.95rem;
|
||||
}}
|
||||
|
||||
.provider-list {{
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}}
|
||||
|
||||
/* Provider button — mirrors DS Button (default variant):
|
||||
amber surface, dark text, uppercase + wide tracking, inset bevel. */
|
||||
.provider-btn {{
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.95rem 1rem;
|
||||
text-align: center;
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
font-family: 'Collapse', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 0.78rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border: 0;
|
||||
border-radius: 0; /* DS Button is squared — no rounded corners. */
|
||||
cursor: pointer;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 rgba(255, 255, 255, 0.5),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.5);
|
||||
transition: filter 0.12s ease-out;
|
||||
}}
|
||||
.provider-btn:hover {{
|
||||
filter: brightness(1.08);
|
||||
}}
|
||||
.provider-btn:active {{
|
||||
/* DS Button uses `active:invert` on the default surface. */
|
||||
filter: invert(1);
|
||||
}}
|
||||
.provider-btn:focus-visible {{
|
||||
outline: 2px solid var(--midground);
|
||||
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;
|
||||
color: color-mix(in srgb, var(--foreground) 45%, transparent);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
line-height: 1.7;
|
||||
}}
|
||||
footer .sep {{
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1px;
|
||||
background: var(--hairline-strong);
|
||||
vertical-align: middle;
|
||||
margin: 0 0.6em 0.2em;
|
||||
}}
|
||||
|
||||
/* Selection — DS uses midground bg + background text. */
|
||||
::selection {{
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="brand">Nous<span class="dot"></span>Research</div>
|
||||
<div class="card">
|
||||
<h1>Sign in</h1>
|
||||
<p class="subtitle">Choose a sign-in method to continue to the Hermes Agent dashboard.</p>
|
||||
<div class="provider-list">
|
||||
{provider_buttons}
|
||||
</div>
|
||||
</div>
|
||||
<footer>
|
||||
<span class="sep"></span>Public bind · Auth required<span class="sep"></span>
|
||||
</footer>
|
||||
</main>
|
||||
{password_script}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
_EMPTY_HTML = """\
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sign-in unavailable — Hermes Agent</title>
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Collapse';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/Collapse-Regular.woff2') format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Rules Compressed';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/RulesCompressed-Medium.woff2') format('woff2');
|
||||
}
|
||||
:root {
|
||||
--background-base: #170d02;
|
||||
--midground: #ffac02;
|
||||
--foreground: #ffffff;
|
||||
--hairline: color-mix(in srgb, #ffac02 18%, transparent);
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0; min-height: 100%;
|
||||
background: var(--background-base);
|
||||
color: var(--foreground);
|
||||
font-family: 'Collapse', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 16px; line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
body {
|
||||
display: grid; place-items: center;
|
||||
padding: clamp(1.5rem, 6vh, 6rem) 1.25rem;
|
||||
}
|
||||
main {
|
||||
width: 100%; max-width: 32rem;
|
||||
padding: 2.25rem 2rem;
|
||||
background: color-mix(in srgb, #ffffff 2%, var(--background-base));
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 0 color-mix(in srgb, #ffffff 5%, transparent),
|
||||
inset -1px -1px 0 0 rgba(0, 0, 0, 0.4),
|
||||
0 24px 60px -20px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 1rem;
|
||||
font-family: 'Rules Compressed', 'Collapse', sans-serif;
|
||||
font-weight: 600; font-size: 1.5rem;
|
||||
letter-spacing: 0.05em; text-transform: uppercase;
|
||||
color: var(--midground);
|
||||
}
|
||||
p { margin: 0 0 1rem; }
|
||||
code {
|
||||
background: var(--midground);
|
||||
color: var(--background-base);
|
||||
padding: 0.1em 0.35em;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<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>
|
||||
"""
|
||||
|
||||
|
||||
# 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``.
|
||||
|
||||
``next_path`` — when set, the post-login landing path the user
|
||||
originally requested. Threaded into each provider button's ``href``
|
||||
as a ``next=`` query parameter so the OAuth round trip carries it
|
||||
end-to-end. The caller (``routes.login_page``) is responsible for
|
||||
validating ``next_path`` against the same-origin rules before we
|
||||
emit it; we still HTML-escape it as defence in depth.
|
||||
"""
|
||||
providers = list_providers()
|
||||
if not providers:
|
||||
return _EMPTY_HTML
|
||||
|
||||
if next_path:
|
||||
# URL-encode then HTML-escape. The URL-encode step matches the
|
||||
# gate's ``_safe_next_target`` output shape (also URL-encoded),
|
||||
# so a value that round-tripped from /login?next=... back into
|
||||
# the button href is byte-identical.
|
||||
from urllib.parse import quote
|
||||
next_qs = f"&next={html.escape(quote(next_path, safe=''), quote=True)}"
|
||||
else:
|
||||
next_qs = ""
|
||||
|
||||
buttons = []
|
||||
needs_password_script = False
|
||||
for p in providers:
|
||||
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>'
|
||||
)
|
||||
@@ -0,0 +1,368 @@
|
||||
"""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 :class:`Session` to ``request.state.session``.
|
||||
3. On HTML routes, redirect missing/invalid cookies to ``/login``.
|
||||
On ``/api/*`` routes, return 401 JSON.
|
||||
|
||||
The middleware is a no-op when ``auth_required`` is False (loopback
|
||||
mode); the legacy ``_SESSION_TOKEN`` ``auth_middleware`` handles those
|
||||
binds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, Response
|
||||
|
||||
from hermes_cli.dashboard_auth import list_providers
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.base import ProviderError, RefreshExpiredError
|
||||
from hermes_cli.dashboard_auth.cookies import read_session_cookies
|
||||
from hermes_cli.dashboard_auth.public_paths import PUBLIC_API_PATHS
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Prefixes that bypass the auth gate. Match via ``path == prefix`` or
|
||||
# ``path.startswith(prefix)`` — so ``/assets/`` (with trailing slash)
|
||||
# matches ``/assets/foo.css`` but not ``/assetsleak``. Auth-bootstrap
|
||||
# (login page, OAuth round trip, provider listing) and static asset
|
||||
# mounts go here.
|
||||
_GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
"/auth/login",
|
||||
"/auth/callback",
|
||||
"/auth/password-login",
|
||||
"/auth/logout",
|
||||
"/login",
|
||||
"/api/auth/providers",
|
||||
"/assets/",
|
||||
"/favicon.ico",
|
||||
"/ds-assets/",
|
||||
"/fonts/",
|
||||
"/fonts-terminal/",
|
||||
)
|
||||
|
||||
|
||||
def _path_is_public(path: str) -> bool:
|
||||
"""True if ``path`` bypasses the OAuth auth gate.
|
||||
|
||||
Two sources of public-ness:
|
||||
|
||||
* :data:`PUBLIC_API_PATHS` — the shared ``/api/*`` allowlist that
|
||||
the legacy ``_SESSION_TOKEN`` middleware also honours. Matched
|
||||
exactly (no prefix expansion) so adding ``/api/status`` doesn't
|
||||
accidentally expose ``/api/status/secret-extension``.
|
||||
* :data:`_GATE_PUBLIC_PREFIXES` — auth-bootstrap routes and static
|
||||
mounts. Prefix-matched so ``/assets/foo.css`` lights up via
|
||||
``/assets/``.
|
||||
"""
|
||||
if path in PUBLIC_API_PATHS:
|
||||
return True
|
||||
return any(
|
||||
path == prefix or path.startswith(prefix)
|
||||
for prefix in _GATE_PUBLIC_PREFIXES
|
||||
)
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
def _unauth_response(request: Request, *, reason: str) -> Response:
|
||||
"""API routes → 401 JSON with ``login_url``; HTML routes → 302 → /login.
|
||||
|
||||
The JSON envelope carries a ``login_url`` field with a ``next=`` query
|
||||
string so the SPA's global 401 handler can drop the user back where
|
||||
they were after re-auth. The contract is intentionally simple so any
|
||||
fetch-wrapper can implement the redirect without parsing details:
|
||||
|
||||
if response.status === 401 && body.error in ("unauthenticated",
|
||||
"session_expired"):
|
||||
window.location.assign(body.login_url);
|
||||
|
||||
HTML redirects also carry the ``next=`` query string so direct
|
||||
navigation to ``/sessions`` (etc.) without a cookie comes back to
|
||||
``/sessions`` after login.
|
||||
|
||||
Under a reverse proxy with ``X-Forwarded-Prefix: /hermes``, the
|
||||
``login_url`` is prefixed (``/hermes/login?next=...``) so the
|
||||
browser's window.location.assign / Location: follow lands on the
|
||||
proxied login page rather than the bare ``/login`` (which the
|
||||
proxy doesn't route to the dashboard).
|
||||
"""
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
|
||||
path = request.url.path
|
||||
next_param = _safe_next_target(request)
|
||||
prefix = prefix_from_request(request)
|
||||
login_url = (
|
||||
f"{prefix}/login?next={next_param}" if next_param
|
||||
else f"{prefix}/login"
|
||||
)
|
||||
|
||||
if path.startswith("/api/"):
|
||||
# API routes never get redirects: the browser fetch() API would
|
||||
# follow a 302 into the cross-origin OAuth dance opaquely. Return
|
||||
# 401 with a structured envelope so the SPA can full-page-navigate
|
||||
# to login_url.
|
||||
error_code = (
|
||||
"session_expired"
|
||||
if reason == "invalid_or_expired_session"
|
||||
else "unauthenticated"
|
||||
)
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": error_code,
|
||||
"detail": "Unauthorized",
|
||||
"reason": reason,
|
||||
"login_url": login_url,
|
||||
},
|
||||
status_code=401,
|
||||
)
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
|
||||
|
||||
def _safe_next_target(request: Request) -> str:
|
||||
"""Build the URL-encoded ``next`` query value, or empty string.
|
||||
|
||||
Only same-origin relative paths are accepted; absolute URLs or
|
||||
``//evil.com`` open-redirect attempts are silently dropped. The empty
|
||||
string return means the caller produces a bare ``/login`` URL — fine,
|
||||
user lands at the dashboard root after re-auth.
|
||||
"""
|
||||
path = request.url.path
|
||||
# Reject anything that doesn't start with "/" or starts with "//"
|
||||
# (protocol-relative URL — would open-redirect to an attacker host).
|
||||
if not path or not path.startswith("/") or path.startswith("//"):
|
||||
return ""
|
||||
# Don't redirect back to the auth routes themselves — that loops.
|
||||
if any(
|
||||
path == p or path.startswith(p)
|
||||
for p in ("/login", "/auth/", "/api/auth/")
|
||||
):
|
||||
return ""
|
||||
# Reject ALL ``/api/*`` paths. The 401-envelope code path fires for
|
||||
# any unauthenticated SPA fetch (e.g. ``GET /api/analytics/models``
|
||||
# from ModelsPage), and the SPA's global 401 handler full-page
|
||||
# navigates to ``login_url``. After the OAuth round trip the user
|
||||
# would land on the API URL and see raw JSON instead of the
|
||||
# dashboard. SPA routes survive (they don't start with ``/api/``);
|
||||
# the SPA's own ``sessionStorage["hermes.lastLocation"]`` fallback
|
||||
# in ``web/src/lib/api.ts`` covers the deep-link case.
|
||||
if path == "/api" or path.startswith("/api/"):
|
||||
return ""
|
||||
# Preserve query string if present (e.g. /sessions?page=2).
|
||||
query = request.url.query
|
||||
target = f"{path}?{query}" if query else path
|
||||
# urlencode the whole thing as a single value.
|
||||
from urllib.parse import quote
|
||||
return quote(target, safe="")
|
||||
|
||||
|
||||
async def gated_auth_middleware(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
"""Engaged only when ``app.state.auth_required is True``.
|
||||
|
||||
No-op pass-through in loopback mode so the legacy auth_middleware can
|
||||
handle those binds via ``_SESSION_TOKEN``.
|
||||
"""
|
||||
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 and not _rt:
|
||||
# Neither token present — no session at all. Nothing to verify or
|
||||
# refresh; force login.
|
||||
return _unauth_response(request, reason="no_cookie")
|
||||
|
||||
# Try every registered provider's verify_session in turn. Providers
|
||||
# MUST return None for tokens they don't recognise (not raise). This
|
||||
# lets multiple providers stack — the first one that recognises a
|
||||
# token wins.
|
||||
#
|
||||
# When the access-token cookie is absent but a refresh-token cookie is
|
||||
# present, skip verification and go straight to the refresh path below.
|
||||
# This is the COMMON expiry case, not an edge case: the access-token
|
||||
# cookie is set with ``Max-Age = access_token_expires_in`` (~15 min), so
|
||||
# the browser EVICTS it the moment the token lapses, while the
|
||||
# refresh-token cookie lives for 30 days. From that point the browser
|
||||
# sends only ``hermes_session_rt``. If we bailed on ``not at`` here we'd
|
||||
# bounce the user to /login on every expiry despite holding a perfectly
|
||||
# good refresh token — defeating the whole transparent-refresh feature.
|
||||
session = None
|
||||
if at:
|
||||
# Try every registered provider's verify_session in turn. A provider
|
||||
# that doesn't recognise the token returns None and we move on; the
|
||||
# first provider that returns a Session wins.
|
||||
#
|
||||
# A provider may instead raise ProviderError (its IDP/JWKS is
|
||||
# unreachable, so it can neither confirm nor deny the token). With
|
||||
# multiple providers stacked, that MUST NOT abort the chain — the
|
||||
# token may belong to a *different*, reachable provider. (Concretely:
|
||||
# a self-hosted-OIDC session hits the `nous` provider first, which
|
||||
# tries to reach Nous Portal's JWKS; if that's unreachable it raises,
|
||||
# but the `self-hosted` provider can still verify the token.) So we
|
||||
# remember the unreachable error and keep going. Only if NO provider
|
||||
# verifies the token AND at least one was unreachable do we surface a
|
||||
# 503 — distinguishing "transient IDP outage" (don't force re-login)
|
||||
# from "token genuinely invalid" (fall through to refresh/relogin).
|
||||
unreachable_provider: str | None = 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 during verify: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
if unreachable_provider is None:
|
||||
unreachable_provider = provider.name
|
||||
continue
|
||||
if session is not None:
|
||||
break
|
||||
if session is None and unreachable_provider is not None:
|
||||
# No provider could verify the token and at least one couldn't be
|
||||
# reached — treat as a transient outage rather than forcing a
|
||||
# re-login through a (possibly also-unreachable) refresh.
|
||||
return JSONResponse(
|
||||
{"detail": f"Auth provider {unreachable_provider!r} unreachable"},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
if session is None:
|
||||
# Access token is expired/invalid. Before forcing re-login, try to
|
||||
# rotate it using the refresh token (if the session cookie carries
|
||||
# one). On success we re-set the rotated cookies on the response and
|
||||
# serve the request transparently; on RefreshExpiredError (RT dead /
|
||||
# revoked / reuse-detected) we fall through to clear-and-relogin.
|
||||
refreshed = _attempt_refresh(request, refresh_token=_rt)
|
||||
if refreshed is not None:
|
||||
new_session, refreshing_provider = refreshed
|
||||
request.state.session = new_session
|
||||
response = await call_next(request)
|
||||
# Persist the ROTATED tokens. Portal rotates the refresh token on
|
||||
# every refresh and runs reuse-detection, so writing the new RT
|
||||
# back is mandatory: a stale RT cookie would replay a rotated
|
||||
# token on the next refresh and (outside Portal's grace) revoke
|
||||
# the whole session. Bind cookie Secure/Path to the request shape.
|
||||
from hermes_cli.dashboard_auth.cookies import (
|
||||
detect_https,
|
||||
set_session_cookies,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
|
||||
set_session_cookies(
|
||||
response,
|
||||
access_token=new_session.access_token,
|
||||
refresh_token=new_session.refresh_token,
|
||||
access_token_expires_in=_expires_in_seconds(new_session),
|
||||
use_https=detect_https(request),
|
||||
prefix=prefix_from_request(request),
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_SUCCESS,
|
||||
provider=refreshing_provider,
|
||||
user_id=new_session.user_id,
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return response
|
||||
|
||||
audit_log(
|
||||
AuditEvent.SESSION_VERIFY_FAILURE,
|
||||
reason="no_provider_recognises",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
response = _unauth_response(request, reason="invalid_or_expired_session")
|
||||
# Clear the dead cookies so the browser doesn't keep sending them.
|
||||
# Refresh already failed (or there was no RT), so the only correct
|
||||
# next step is full re-auth via /login. Importing locally avoids a
|
||||
# cycle with cookies → middleware at module load. Pass the active
|
||||
# prefix so the deletion's Path matches the set-Path (otherwise
|
||||
# the browser ignores it).
|
||||
from hermes_cli.dashboard_auth.cookies import clear_session_cookies
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
clear_session_cookies(response, prefix=prefix_from_request(request))
|
||||
return response
|
||||
|
||||
request.state.session = session
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
def _expires_in_seconds(session) -> int:
|
||||
"""Seconds until the access token's ``exp``, floored at 60.
|
||||
|
||||
Mirrors the auth-route's ``max(60, exp - now)`` so the access-token
|
||||
cookie's Max-Age tracks the token lifetime even on a slightly skewed
|
||||
clock. ``time`` imported locally to keep the module's import surface
|
||||
minimal.
|
||||
"""
|
||||
import time
|
||||
|
||||
return max(60, int(session.expires_at) - int(time.time()))
|
||||
|
||||
|
||||
def _attempt_refresh(request: Request, *, refresh_token):
|
||||
"""Try to rotate an expired session via the refresh token.
|
||||
|
||||
Returns ``(new_session, provider_name)`` on success, or ``None`` if
|
||||
there's no RT or every provider's ``refresh_session`` failed with
|
||||
``RefreshExpiredError`` (dead/revoked/reuse-detected RT → force re-login).
|
||||
|
||||
A ``ProviderError`` (Portal unreachable) is NOT swallowed into a re-login
|
||||
here — re-raising would 500 the request; instead we log and return None so
|
||||
the caller forces a clean re-login, which is the safer UX than a hard
|
||||
error on a transient network blip during the narrow refresh window.
|
||||
"""
|
||||
if not refresh_token:
|
||||
return None
|
||||
for provider in list_providers():
|
||||
try:
|
||||
new_session = provider.refresh_session(refresh_token=refresh_token)
|
||||
except RefreshExpiredError:
|
||||
# This provider owns the RT but it's dead — stop trying others
|
||||
# (an RT belongs to exactly one provider) and force re-login.
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="refresh_expired",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return None
|
||||
except ProviderError as e:
|
||||
_log.warning(
|
||||
"dashboard-auth: provider %r unreachable during refresh: %s",
|
||||
provider.name, e,
|
||||
)
|
||||
audit_log(
|
||||
AuditEvent.REFRESH_FAILURE,
|
||||
provider=provider.name,
|
||||
reason="provider_unreachable",
|
||||
ip=_client_ip(request),
|
||||
)
|
||||
return None
|
||||
if new_session is not None:
|
||||
return new_session, provider.name
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Helpers for X-Forwarded-Prefix support.
|
||||
|
||||
Mission-control style deploys reverse-proxy the dashboard at a path
|
||||
prefix (e.g. ``mission-control.tilos.com/hermes/*`` -> dashboard on
|
||||
:9119), injecting ``X-Forwarded-Prefix: /hermes`` so the backend can
|
||||
reconstruct prefixed URLs (Location: headers, OAuth redirect_uri,
|
||||
cookie Path attributes, SPA asset URLs).
|
||||
|
||||
This module is also the home of the ``HERMES_DASHBOARD_PUBLIC_URL`` /
|
||||
``dashboard.public_url`` resolution — when the operator declares a
|
||||
complete public URL (scheme + host + optional path prefix), we use
|
||||
that directly for the OAuth ``redirect_uri`` and skip the
|
||||
X-Forwarded-Prefix reconstruction. Relief valve for deploys where the
|
||||
proxy header chain isn't reliable.
|
||||
|
||||
The single source of truth for both helpers lives here so the gate
|
||||
middleware, the OAuth routes, the cookie helpers, and the SPA mount
|
||||
all agree on validation rules.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import Optional
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
# Characters that, if present in a public_url or prefix value, indicate
|
||||
# either a typo or a header-injection attempt. Reject the whole value
|
||||
# rather than try to sanitise — the operator can fix their config.
|
||||
_REJECT_CHARS = frozenset(('"', "'", "<", ">", " ", "\n", "\r", "\t"))
|
||||
|
||||
# Remember which (source, value) pairs we've already warned about.
|
||||
# ``resolve_public_url`` runs on every authenticated request, so an
|
||||
# un-deduplicated warning would flood the logs once per request for a
|
||||
# misconfigured deploy. Keyed on the raw value too, so changing the
|
||||
# config and reloading surfaces a fresh warning.
|
||||
_warned_malformed_public_urls: set = set()
|
||||
|
||||
|
||||
def _warn_if_malformed(source: str, raw: str) -> None:
|
||||
"""Warn (once per distinct value) when a non-empty public-url value
|
||||
was rejected by :func:`_normalise_public_url`.
|
||||
|
||||
A non-empty value that normalises to ``""`` is almost always a
|
||||
missing scheme (``hermes.example.com`` instead of
|
||||
``https://hermes.example.com``) — the single most common cause of
|
||||
"I set HERMES_DASHBOARD_PUBLIC_URL but the OAuth callback is still
|
||||
http://". Without this warning the value is silently discarded and
|
||||
the dashboard falls back to reconstructing the redirect URI from
|
||||
request headers, which behind a reverse proxy can yield the wrong
|
||||
scheme. Surfacing it turns a silent footgun into a self-diagnosing
|
||||
one.
|
||||
"""
|
||||
cleaned = raw.strip() if raw else ""
|
||||
if not cleaned:
|
||||
return # empty/unset is a legitimate "no override" — not malformed
|
||||
key = (source, cleaned)
|
||||
if key in _warned_malformed_public_urls:
|
||||
return
|
||||
_warned_malformed_public_urls.add(key)
|
||||
_log.warning(
|
||||
"%s is set to %r but was ignored because it is not a valid "
|
||||
"absolute URL — it must include an http:// or https:// scheme "
|
||||
"(e.g. https://%s). Falling back to reconstructing the OAuth "
|
||||
"redirect URI from request headers, which may produce the wrong "
|
||||
"scheme behind a reverse proxy.",
|
||||
source,
|
||||
cleaned,
|
||||
cleaned.split("://")[-1] or "hermes.example.com",
|
||||
)
|
||||
|
||||
|
||||
def normalise_prefix(raw: Optional[str]) -> str:
|
||||
"""Normalise an X-Forwarded-Prefix header value.
|
||||
|
||||
Returns a string like ``"/hermes"`` (no trailing slash) or ``""``
|
||||
when no prefix is set / the header is malformed. We deliberately
|
||||
reject anything containing ``..`` or non-printable bytes so a
|
||||
hostile proxy can't inject HTML or path-traversal sequences via the
|
||||
prefix.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
p = raw.strip()
|
||||
if not p:
|
||||
return ""
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
p = p.rstrip("/")
|
||||
if (
|
||||
"//" in p
|
||||
or ".." in p
|
||||
or any(c in p for c in _REJECT_CHARS)
|
||||
):
|
||||
return ""
|
||||
if len(p) > 64:
|
||||
return ""
|
||||
return p
|
||||
|
||||
|
||||
def prefix_from_request(request) -> str:
|
||||
"""Convenience wrapper that reads the header off a Starlette/FastAPI
|
||||
Request and normalises it. Returns ``""`` when no prefix.
|
||||
"""
|
||||
return normalise_prefix(request.headers.get("x-forwarded-prefix"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HERMES_DASHBOARD_PUBLIC_URL / dashboard.public_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _normalise_public_url(raw: Optional[str]) -> str:
|
||||
"""Normalise a ``dashboard.public_url`` value.
|
||||
|
||||
Returns the cleaned URL (scheme://netloc[/path], trailing slash
|
||||
removed) on success, or ``""`` when the value is empty, malformed,
|
||||
or contains characters that suggest header injection. The caller
|
||||
must treat ``""`` as "fall back to request reconstruction" — never
|
||||
as "the user explicitly chose no public URL", because the two are
|
||||
indistinguishable from an empty env var.
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
url = raw.strip()
|
||||
if not url:
|
||||
return ""
|
||||
# Reject control / quote / whitespace characters before trying to
|
||||
# parse — urlparse is permissive enough to accept some hostile
|
||||
# values (e.g. embedded newlines) and we want a hard "no" rather
|
||||
# than a soft "maybe".
|
||||
if any(c in url for c in _REJECT_CHARS):
|
||||
return ""
|
||||
try:
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
except ValueError:
|
||||
return ""
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
return ""
|
||||
if not parsed.netloc:
|
||||
return ""
|
||||
# Strip a single trailing slash so callers can append paths without
|
||||
# producing ``//`` double-slashes.
|
||||
return url.rstrip("/")
|
||||
|
||||
|
||||
def _load_dashboard_section() -> dict:
|
||||
"""Return the ``dashboard`` block from ``config.yaml`` if it exists
|
||||
and is a dict; otherwise an empty dict.
|
||||
|
||||
Robust to (a) load_config() raising (malformed YAML, IO error,
|
||||
config.yaml absent), and (b) ``dashboard`` being absent or non-dict.
|
||||
Both shapes fall through to ``{}`` so the caller can rely on
|
||||
``.get(...)`` access.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
except Exception:
|
||||
return {}
|
||||
try:
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
_log.debug(
|
||||
"dashboard-auth.prefix: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg.get("dashboard") if isinstance(cfg, dict) else None
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def resolve_public_url() -> str:
|
||||
"""Resolve the operator-declared dashboard public URL.
|
||||
|
||||
Precedence (mirrors ``dashboard.oauth.client_id``):
|
||||
|
||||
1. ``HERMES_DASHBOARD_PUBLIC_URL`` env var (when non-empty after
|
||||
strip — empty values are treated as unset so a provisioned-but-
|
||||
not-populated Fly secret can't shadow a valid config.yaml entry).
|
||||
2. ``dashboard.public_url`` in ``config.yaml``.
|
||||
3. Empty string — signals "no override, reconstruct from request"
|
||||
to the caller.
|
||||
|
||||
Each candidate value is run through :func:`_normalise_public_url`.
|
||||
A malformed env var falls through to the config.yaml entry; a
|
||||
malformed config entry falls through to ``""``. This means a typo
|
||||
in one surface doesn't prevent the other from working.
|
||||
"""
|
||||
env_raw = os.environ.get("HERMES_DASHBOARD_PUBLIC_URL", "")
|
||||
env_clean = _normalise_public_url(env_raw)
|
||||
if env_clean:
|
||||
return env_clean
|
||||
_warn_if_malformed("HERMES_DASHBOARD_PUBLIC_URL env var", env_raw)
|
||||
cfg_raw = str(_load_dashboard_section().get("public_url", ""))
|
||||
cfg_clean = _normalise_public_url(cfg_raw)
|
||||
if not cfg_clean:
|
||||
_warn_if_malformed("dashboard.public_url in config.yaml", cfg_raw)
|
||||
return cfg_clean
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared allowlist of ``/api/*`` paths that bypass dashboard auth.
|
||||
|
||||
Two middlewares enforce dashboard auth and previously kept independent
|
||||
copies of this list:
|
||||
|
||||
* ``hermes_cli.web_server.auth_middleware`` — loopback / ``--insecure``
|
||||
mode, gates on the ephemeral ``_SESSION_TOKEN``.
|
||||
* ``hermes_cli.dashboard_auth.middleware.gated_auth_middleware`` —
|
||||
non-loopback mode, gates on the OAuth session cookie.
|
||||
|
||||
When the lists drifted, ``/api/status`` ended up public under the legacy
|
||||
gate but 401'd under the OAuth gate. That broke the portal's wildcard
|
||||
liveness probe (``nous-account-service`` ``fly-provider.ts``
|
||||
``getInstanceRuntimeStatus``), which fetches ``/api/status`` without a
|
||||
cookie as its sole signal of "agent dashboard is alive": every healthy
|
||||
wildcard-subdomain agent surfaced as STARTING/down in the portal UI even
|
||||
though the dashboard was serving correctly.
|
||||
|
||||
Centralising the allowlist here so both middlewares import the same
|
||||
frozenset prevents the next drift. Keep this list minimal — only truly
|
||||
non-sensitive, read-only endpoints belong here. As a sanity check, every
|
||||
entry should be safe to expose to:
|
||||
|
||||
* external uptime probes (Pingdom, Better Stack, NAS),
|
||||
* the dashboard SPA before the user has logged in,
|
||||
* anyone who happens to ``curl`` the hostname.
|
||||
|
||||
If a new endpoint doesn't pass all three tests, it should be gated and
|
||||
the SPA should bootstrap it after login instead.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
PUBLIC_API_PATHS: frozenset[str] = frozenset({
|
||||
# Liveness probe target. Returns version, gateway state, active
|
||||
# session count, and the dashboard auth-gate shape. No bodies, no
|
||||
# session content, no secrets. Documented as the portal's wildcard
|
||||
# liveness probe in
|
||||
# ``docs/agent-dashboard-public-url-contract.md`` (NAS side).
|
||||
"/api/status",
|
||||
# Read-only config-defaults / schema feeds for the SPA's Config page.
|
||||
"/api/config/defaults",
|
||||
"/api/config/schema",
|
||||
# Read-only model metadata (context windows, etc.) — same shape as
|
||||
# provider catalogs already exposed on the public internet.
|
||||
"/api/model/info",
|
||||
# Read-only theme + plugin manifests for the dashboard skin engine.
|
||||
"/api/dashboard/themes",
|
||||
"/api/dashboard/plugins",
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
"""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: if a provider with the same name is already registered.
|
||||
"""
|
||||
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]:
|
||||
"""Return the registered provider for ``name``, or None if unknown."""
|
||||
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()
|
||||
@@ -0,0 +1,621 @@
|
||||
"""HTTP routes for the dashboard-auth OAuth round trip.
|
||||
|
||||
Mounted at root (no prefix) by ``web_server.py``. The router does not
|
||||
auto-gate; gating is performed by ``gated_auth_middleware``, which
|
||||
allowlists everything under ``/auth/*`` and ``/api/auth/providers``.
|
||||
|
||||
The routes:
|
||||
|
||||
GET /login → server-rendered login page
|
||||
GET /auth/login?provider=N → 302 to IDP, sets PKCE cookie
|
||||
GET /auth/callback?code,state → completes login, sets session cookies
|
||||
POST /auth/logout → clears cookies, best-effort revoke
|
||||
GET /api/auth/providers → list registered providers (login bootstrap)
|
||||
GET /api/auth/me → current Session as JSON (auth-required)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from typing import Any, Deque, Dict, Tuple
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
get_provider,
|
||||
list_providers,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
|
||||
from hermes_cli.dashboard_auth.base import (
|
||||
InvalidCodeError,
|
||||
InvalidCredentialsError,
|
||||
ProviderError,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.cookies import (
|
||||
clear_pkce_cookie,
|
||||
clear_session_cookies,
|
||||
detect_https,
|
||||
read_pkce_cookie,
|
||||
read_session_cookies,
|
||||
set_pkce_cookie,
|
||||
set_session_cookies,
|
||||
)
|
||||
from hermes_cli.dashboard_auth.login_page import render_login_html
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _redirect_uri(request: Request) -> str:
|
||||
"""Reconstruct the absolute callback URL the IDP redirects back to.
|
||||
|
||||
Three resolution tiers:
|
||||
|
||||
1. ``HERMES_DASHBOARD_PUBLIC_URL`` env var or
|
||||
``dashboard.public_url`` in config.yaml — when set, this is
|
||||
the complete authority (scheme + host + optional path prefix)
|
||||
and we append ``/auth/callback`` verbatim. ``X-Forwarded-Prefix``
|
||||
is IGNORED on this code path because the operator has declared
|
||||
the public URL — we no longer need to guess from proxy headers,
|
||||
and stacking the prefix on top would double-prefix the common
|
||||
case where the prefix is already baked into ``public_url``.
|
||||
Relief valve for deploys behind reverse proxies whose forwarded
|
||||
headers aren't reliable.
|
||||
|
||||
2. ``X-Forwarded-Prefix: /hermes`` (Mission Control deploys) — we
|
||||
prepend the prefix to the path FastAPI's ``url_for`` produces
|
||||
(it doesn't natively honour this header — it isn't part of the
|
||||
Starlette/uvicorn proxy_headers set).
|
||||
|
||||
3. Bare ``request.url_for("auth_callback")`` — under uvicorn's
|
||||
``proxy_headers=True`` this picks up the public https URL from
|
||||
``X-Forwarded-Host`` plus ``X-Forwarded-Proto``. Fly.io's
|
||||
default path.
|
||||
"""
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from hermes_cli.dashboard_auth.prefix import (
|
||||
prefix_from_request,
|
||||
resolve_public_url,
|
||||
)
|
||||
|
||||
# Tier 1: operator-declared public URL.
|
||||
public_url = resolve_public_url()
|
||||
if public_url:
|
||||
# ``public_url`` is the complete authority (possibly with a
|
||||
# path prefix already baked in). Append the auth callback path
|
||||
# verbatim. ``resolve_public_url`` already stripped any trailing
|
||||
# slash so we don't produce ``//auth/callback`` double-slashes.
|
||||
return f"{public_url}/auth/callback"
|
||||
|
||||
# Tier 2 + 3: reconstruct from the request URL, optionally with
|
||||
# X-Forwarded-Prefix layered on top of the path.
|
||||
base = str(request.url_for("auth_callback"))
|
||||
prefix = prefix_from_request(request)
|
||||
if not prefix:
|
||||
return base
|
||||
parsed = urlparse(base)
|
||||
return urlunparse(parsed._replace(path=f"{prefix}{parsed.path}"))
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
def _prefix(request: Request) -> str:
|
||||
"""Resolve the X-Forwarded-Prefix header for the active request.
|
||||
|
||||
Local indirection so the routes pass a consistent value to the
|
||||
cookie helpers (cookie name + Path attribute) and the gate's
|
||||
redirect builders (login_url construction). See
|
||||
``hermes_cli.dashboard_auth.prefix`` for the normalisation rules.
|
||||
"""
|
||||
from hermes_cli.dashboard_auth.prefix import prefix_from_request
|
||||
return prefix_from_request(request)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: login page (server-rendered HTML, no SPA bundle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/login", name="login_page")
|
||||
async def login_page(request: Request) -> HTMLResponse:
|
||||
# Read the ``next=`` query the gate's ``_unauth_response`` set on
|
||||
# the redirect URL. Validate against the same same-origin rules the
|
||||
# callback applies (defence in depth — the gate already filters,
|
||||
# but /login is reachable directly too).
|
||||
next_path = _validate_post_login_target(
|
||||
request.query_params.get("next", "")
|
||||
)
|
||||
return HTMLResponse(
|
||||
render_login_html(next_path=next_path),
|
||||
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: provider list for the login-page bootstrap
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/api/auth/providers", name="auth_providers")
|
||||
async def api_auth_providers() -> Any:
|
||||
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,
|
||||
"supports_password": bool(
|
||||
getattr(p, "supports_password", False)
|
||||
),
|
||||
}
|
||||
for p in providers
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: OAuth round trip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/auth/login", name="auth_login")
|
||||
async def auth_login(request: Request, provider: str, next: 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)
|
||||
# Pack the provider name into the PKCE cookie so the callback can
|
||||
# find it without a separate cookie. Provider may or may not have
|
||||
# already included a ``provider=`` segment.
|
||||
pkce = ls.cookie_payload.get("hermes_session_pkce", "")
|
||||
if "provider=" not in pkce:
|
||||
pkce = f"provider={provider};{pkce}" if pkce else f"provider={provider}"
|
||||
# Carry ``next=`` through the round trip in the PKCE cookie. Real
|
||||
# IDPs only echo back ``code`` + ``state`` on the callback URL, so
|
||||
# query-string transport would lose the value — the cookie is the
|
||||
# only server-controlled channel that survives. Validate before we
|
||||
# store it so an attacker who reaches /auth/login directly with
|
||||
# ``next=//evil.example`` can't poison the cookie.
|
||||
safe_next = _validate_post_login_target(next)
|
||||
if safe_next:
|
||||
from urllib.parse import quote
|
||||
pkce = f"{pkce};next={quote(safe_next, safe='')}"
|
||||
set_pkce_cookie(
|
||||
resp, payload=pkce, use_https=detect_https(request),
|
||||
prefix=_prefix(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 ``provider=...;state=...;verifier=...;next=...`` — the
|
||||
# ``next`` segment is optional (only present when /auth/login was
|
||||
# given a next= query). All keys live in the same flat namespace;
|
||||
# ``next`` carries a URL-encoded path so it never contains ``;``.
|
||||
parts = dict(
|
||||
seg.split("=", 1) for seg in pkce_raw.split(";") if "=" in seg
|
||||
)
|
||||
provider_name = parts.get("provider", "")
|
||||
expected_state = parts.get("state", "")
|
||||
verifier = parts.get("verifier", "")
|
||||
# Read next= from the cookie ONLY. The IDP doesn't echo next= back
|
||||
# on the callback URL (it only carries ``code`` + ``state``), so any
|
||||
# next= query parameter on the callback URL is attacker-controlled
|
||||
# and MUST be ignored.
|
||||
next_from_cookie = parts.get("next", "")
|
||||
|
||||
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 not state or 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),
|
||||
)
|
||||
|
||||
expires_in = max(60, session.expires_at - int(time.time()))
|
||||
# Honour the ``next=`` value the gate's _unauth_response set in the
|
||||
# /login redirect URL and that /auth/login persisted into the PKCE
|
||||
# cookie. We re-validate against the same-origin rules here — the
|
||||
# cookie is server-set so this is defence in depth, but a regression
|
||||
# that lets attacker-controlled bytes into the cookie would otherwise
|
||||
# produce an open redirect.
|
||||
landing = _validate_post_login_target(next_from_cookie) or "/"
|
||||
resp = RedirectResponse(url=landing, status_code=302)
|
||||
set_session_cookies(
|
||||
resp,
|
||||
access_token=session.access_token,
|
||||
refresh_token=session.refresh_token,
|
||||
access_token_expires_in=expires_in,
|
||||
use_https=detect_https(request),
|
||||
prefix=_prefix(request),
|
||||
)
|
||||
clear_pkce_cookie(resp, prefix=_prefix(request))
|
||||
return resp
|
||||
|
||||
|
||||
def _validate_post_login_target(raw: str) -> str:
|
||||
"""Return ``raw`` if it's a safe same-origin path, else empty string.
|
||||
|
||||
The ``next`` query param survives a full OAuth round trip — the gate
|
||||
encodes it into the /login redirect, the login page emits it back into
|
||||
/auth/login, and the IDP preserves it across /authorize/callback. We
|
||||
have to re-validate here because the value came back in via the
|
||||
URL (an attacker could craft a /auth/callback URL with their own
|
||||
``next=https://evil.example``).
|
||||
"""
|
||||
if not raw:
|
||||
return ""
|
||||
from urllib.parse import unquote
|
||||
decoded = unquote(raw)
|
||||
if not decoded.startswith("/") or decoded.startswith("//"):
|
||||
return ""
|
||||
# Don't loop back to login pages or auth flow.
|
||||
if any(
|
||||
decoded == p or decoded.startswith(p)
|
||||
for p in ("/login", "/auth/", "/api/auth/")
|
||||
):
|
||||
return ""
|
||||
# Reject any ``/api/*`` target. The gate's ``_safe_next_target``
|
||||
# already filters these out before they reach the cookie, but a
|
||||
# malicious or stale ``next=`` value that re-enters via the
|
||||
# callback URL must not be honoured: a successful redirect to an
|
||||
# API endpoint renders raw JSON in the browser address bar — never
|
||||
# a useful post-login destination, and indistinguishable from an
|
||||
# attacker trying to weaponise the redirect.
|
||||
if decoded == "/api" or decoded.startswith("/api/"):
|
||||
return ""
|
||||
return decoded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public: password (non-redirect) login
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Brute-force throttle. The OAuth flow has no guessable secret on our side
|
||||
# (the IDP owns credentials), but ``/auth/password-login`` accepts a
|
||||
# password we verify locally, so it's a credential-stuffing target. A
|
||||
# simple in-process sliding-window limiter per client IP raises the cost
|
||||
# of online guessing without any external dependency. It is intentionally
|
||||
# best-effort: process-local (resets on restart), and behind a trusting
|
||||
# proxy the IP is the proxy's unless X-Forwarded-For is set — which is why
|
||||
# this is defence-in-depth on top of the provider's own constant-time
|
||||
# verify, not the only line of defence.
|
||||
|
||||
_PW_RATE_MAX_ATTEMPTS = 10
|
||||
_PW_RATE_WINDOW_SEC = 60.0
|
||||
_pw_attempts: Dict[str, Deque[float]] = defaultdict(deque)
|
||||
_pw_attempts_lock = threading.Lock()
|
||||
|
||||
|
||||
def _password_rate_limited(ip: str) -> bool:
|
||||
"""True if ``ip`` has exceeded the password-login attempt budget.
|
||||
|
||||
Sliding window: prune attempts older than the window, then check the
|
||||
count. Records the attempt timestamp when allowed. An empty IP (no
|
||||
discernible client) shares a single bucket — fail-safe toward
|
||||
throttling rather than letting unattributable traffic through
|
||||
unmetered.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
cutoff = now - _PW_RATE_WINDOW_SEC
|
||||
key = ip or "_unknown_"
|
||||
with _pw_attempts_lock:
|
||||
bucket = _pw_attempts[key]
|
||||
while bucket and bucket[0] < cutoff:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= _PW_RATE_MAX_ATTEMPTS:
|
||||
return True
|
||||
bucket.append(now)
|
||||
return False
|
||||
|
||||
|
||||
def _reset_password_rate_limit() -> None:
|
||||
"""Test-only: clear all rate-limit buckets."""
|
||||
with _pw_attempts_lock:
|
||||
_pw_attempts.clear()
|
||||
|
||||
|
||||
class _PasswordLoginBody(BaseModel):
|
||||
provider: str
|
||||
username: str
|
||||
password: str
|
||||
next: str = ""
|
||||
|
||||
|
||||
@router.post("/auth/password-login", name="auth_password_login")
|
||||
async def auth_password_login(request: Request, body: _PasswordLoginBody):
|
||||
"""Authenticate a username/password against a password provider.
|
||||
|
||||
Mirrors the cookie-minting tail of ``/auth/callback`` but skips the
|
||||
PKCE/state/code machinery (those are OAuth-only). On success sets the
|
||||
session cookies and returns JSON ``{"ok": true, "next": <path>}`` —
|
||||
the credential form POSTs via fetch and navigates client-side, so a
|
||||
302 (which fetch follows opaquely) is the wrong shape here.
|
||||
|
||||
Failure modes, all deliberately generic so the endpoint can't be used
|
||||
as a username oracle or a provider-enumeration oracle:
|
||||
* unknown provider / provider lacks password support → 404
|
||||
* bad credentials → 401 ("Invalid credentials")
|
||||
* backing store unreachable → 503
|
||||
* too many attempts from this IP → 429
|
||||
"""
|
||||
ip = _client_ip(request)
|
||||
if _password_rate_limited(ip):
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="rate_limited",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many login attempts. Try again shortly.",
|
||||
)
|
||||
|
||||
p = get_provider(body.provider)
|
||||
if p is None or not getattr(p, "supports_password", False):
|
||||
# Don't leak which providers exist or which support passwords —
|
||||
# same 404 whether the provider is unknown or OAuth-only.
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="unknown_password_provider",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(status_code=404, detail="Unknown provider")
|
||||
|
||||
try:
|
||||
session = p.complete_password_login(
|
||||
username=body.username, password=body.password
|
||||
)
|
||||
except InvalidCredentialsError:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="invalid_credentials",
|
||||
ip=ip,
|
||||
)
|
||||
# Generic message — never distinguish unknown-user from wrong-password.
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
except NotImplementedError:
|
||||
# supports_password was True but the method isn't actually
|
||||
# implemented — a provider bug, not a client error.
|
||||
raise HTTPException(status_code=500, detail="Provider misconfigured")
|
||||
except ProviderError as e:
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_FAILURE,
|
||||
provider=body.provider,
|
||||
reason="provider_unreachable",
|
||||
ip=ip,
|
||||
)
|
||||
raise HTTPException(status_code=503, detail=f"Provider unreachable: {e}")
|
||||
|
||||
audit_log(
|
||||
AuditEvent.LOGIN_SUCCESS,
|
||||
provider=body.provider,
|
||||
user_id=session.user_id,
|
||||
email=session.email,
|
||||
org_id=session.org_id,
|
||||
ip=ip,
|
||||
)
|
||||
|
||||
expires_in = max(60, session.expires_at - int(time.time()))
|
||||
landing = _validate_post_login_target(body.next) or "/"
|
||||
resp = JSONResponse({"ok": True, "next": landing})
|
||||
set_session_cookies(
|
||||
resp,
|
||||
access_token=session.access_token,
|
||||
refresh_token=session.refresh_token,
|
||||
access_token_expires_in=expires_in,
|
||||
use_https=detect_https(request),
|
||||
prefix=_prefix(request),
|
||||
)
|
||||
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. Try every provider so a session minted by
|
||||
# any registered provider is revoked correctly. Failures are
|
||||
# logged but never raised.
|
||||
for provider in list_providers():
|
||||
try:
|
||||
provider.revoke_session(refresh_token=rt)
|
||||
except Exception as e: # noqa: BLE001 — best-effort
|
||||
_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),
|
||||
)
|
||||
|
||||
prefix = _prefix(request)
|
||||
resp = RedirectResponse(url=f"{prefix}/login", status_code=302)
|
||||
clear_session_cookies(resp, prefix=prefix)
|
||||
clear_pkce_cookie(resp, prefix=prefix)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-required: identity probe for the SPA
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/api/auth/me", name="auth_me")
|
||||
async def api_auth_me(request: Request):
|
||||
"""Return the verified session as JSON. Auth-required (gate enforces)."""
|
||||
sess = getattr(request.state, "session", None)
|
||||
if sess is None:
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth-required: WS upgrade ticket (Phase 5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/api/auth/ws-ticket", name="auth_ws_ticket")
|
||||
async def api_auth_ws_ticket(request: Request):
|
||||
"""Mint a short-lived single-use ticket for the authenticated session.
|
||||
|
||||
Browsers cannot set ``Authorization`` on a WebSocket upgrade, so in
|
||||
gated mode the SPA POSTs this endpoint to get a ``?ticket=`` value to
|
||||
append to ``/api/pty``, ``/api/ws``, ``/api/pub``, or ``/api/events``.
|
||||
|
||||
The ticket has a 30-second TTL and is single-use. Calling this endpoint
|
||||
multiple times in quick succession (e.g. one ticket per WS) is the
|
||||
expected pattern.
|
||||
"""
|
||||
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")
|
||||
|
||||
# Import here so the routes module stays usable in test contexts that
|
||||
# don't load the ticket store.
|
||||
from hermes_cli.dashboard_auth.ws_tickets import TTL_SECONDS, mint_ticket
|
||||
|
||||
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": TTL_SECONDS}
|
||||
@@ -0,0 +1,161 @@
|
||||
"""WS-upgrade auth credentials for gated mode.
|
||||
|
||||
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
|
||||
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
|
||||
token is injected into the SPA bundle. In gated mode there is no injected
|
||||
token — so this module provides two credential shapes:
|
||||
|
||||
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
|
||||
The SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
|
||||
upgrade. Single-use, TTL = 30 seconds — a leaked ticket is uninteresting.
|
||||
|
||||
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
|
||||
``consume_internal_credential``). This authenticates *server-spawned*
|
||||
WS clients — specifically the embedded-TUI PTY child, which attaches to
|
||||
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
|
||||
loopback. A single-use 30s ticket is the wrong shape for that link: the
|
||||
child reads its attach URL once at startup and **reuses it on every
|
||||
reconnect**, and on a slow cold boot the child may not dial within 30s.
|
||||
The internal credential is minted once per process, never expires, is
|
||||
multi-use, and — critically — is **never injected into any HTML/SPA**:
|
||||
it only ever leaves the process via the spawned child's environment, so
|
||||
browser-side XSS cannot read it. A leaked internal credential grants no
|
||||
more than a single-use ticket already does (the same two internal WS
|
||||
endpoints), and the same Origin / host guards still apply downstream.
|
||||
|
||||
In-memory; the dashboard is a single process so no distributed coordination
|
||||
is needed. The module exposes a small functional API rather than a class so
|
||||
tests can patch ``time.time`` cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
|
||||
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
|
||||
#: short enough that a leaked ticket is uninteresting.
|
||||
TTL_SECONDS = 30
|
||||
|
||||
_lock = threading.Lock()
|
||||
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
|
||||
|
||||
#: The process-lifetime internal credential (see module docstring). Lazily
|
||||
#: minted on first ``internal_ws_credential()`` call and stable for the life
|
||||
#: of the process. Guarded by ``_lock``.
|
||||
_internal_credential: Optional[str] = None
|
||||
|
||||
#: Identity recorded for connections that authenticate via the internal
|
||||
#: credential, so audit logs distinguish them from browser-initiated tickets.
|
||||
INTERNAL_USER_ID = "server-internal"
|
||||
INTERNAL_PROVIDER = "server-internal"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
The returned token is base64url, 43 bytes of entropy (32-byte random
|
||||
seed). Stash returns the ``info`` dict to the caller on consume so the
|
||||
WS handler can carry the identity forward into its session log.
|
||||
"""
|
||||
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[str, Any]:
|
||||
"""Validate and consume. Raises :class:`TicketInvalid` on missing/expired/used.
|
||||
|
||||
Single-use semantics: a successful consume immediately removes the
|
||||
ticket from the store, so a second call with the same value raises
|
||||
``TicketInvalid("unknown ticket: …")``.
|
||||
"""
|
||||
now = int(time.time())
|
||||
with _lock:
|
||||
entry = _tickets.pop(ticket, None)
|
||||
if entry is None:
|
||||
# Truncate ticket value in the error so misuse never logs the
|
||||
# secret in full.
|
||||
truncated = (ticket[:8] + "…") if ticket else "<empty>"
|
||||
raise TicketInvalid(f"unknown ticket: {truncated}")
|
||||
expires_at, info = entry
|
||||
if expires_at < now:
|
||||
raise TicketInvalid("expired")
|
||||
return info
|
||||
|
||||
|
||||
def _gc_expired_locked() -> None:
|
||||
"""Drop expired tickets. Caller must hold ``_lock``."""
|
||||
now = int(time.time())
|
||||
expired = [t for t, (exp, _) in _tickets.items() if exp < now]
|
||||
for t in expired:
|
||||
_tickets.pop(t, None)
|
||||
|
||||
|
||||
def internal_ws_credential() -> str:
|
||||
"""Return the process-lifetime internal WS credential, minting it once.
|
||||
|
||||
Used by the server to authenticate WS clients it spawns itself (the
|
||||
embedded-TUI PTY child). The value is stable for the life of the process,
|
||||
multi-use, and never expires — so a server-spawned child can reconnect
|
||||
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
|
||||
|
||||
The credential is never injected into the SPA HTML or returned over any
|
||||
REST endpoint; it is only ever passed to a child process via its
|
||||
environment. See the module docstring for the threat-model rationale.
|
||||
"""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
if _internal_credential is None:
|
||||
_internal_credential = secrets.token_urlsafe(32)
|
||||
return _internal_credential
|
||||
|
||||
|
||||
def consume_internal_credential(value: str) -> Dict[str, Any]:
|
||||
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
|
||||
|
||||
Unlike :func:`consume_ticket` this is **not** single-use — the value is
|
||||
not removed on success, so a server-spawned child can present it on every
|
||||
(re)connect. Returns the fixed server-internal identity ``info`` dict
|
||||
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
|
||||
returns, so a caller that wants to record the connecting identity can; the
|
||||
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
|
||||
discards the dict.
|
||||
|
||||
A constant-time compare against the (lazily-minted) credential avoids
|
||||
leaking length / prefix information on mismatch. If no internal
|
||||
credential has been minted yet, any value is rejected.
|
||||
"""
|
||||
with _lock:
|
||||
expected = _internal_credential
|
||||
if not value or expected is None:
|
||||
raise TicketInvalid("no internal credential")
|
||||
if not secrets.compare_digest(value.encode(), expected.encode()):
|
||||
raise TicketInvalid("internal credential mismatch")
|
||||
return {
|
||||
"user_id": INTERNAL_USER_ID,
|
||||
"provider": INTERNAL_PROVIDER,
|
||||
}
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Test-only: drop all tickets and the internal credential."""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
_tickets.clear()
|
||||
_internal_credential = None
|
||||
Reference in New Issue
Block a user