Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
"""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,
TokenPrincipal,
LoginStart,
InvalidCodeError,
InvalidCredentialsError,
ProviderError,
RefreshExpiredError,
assert_protocol_compliance,
classify_jwks_lookup_error,
)
from hermes_cli.dashboard_auth.registry import (
register_provider,
get_provider,
list_providers,
list_token_providers,
list_session_providers,
clear_providers,
)
__all__ = [
"DashboardAuthProvider",
"Session",
"TokenPrincipal",
"LoginStart",
"InvalidCodeError",
"InvalidCredentialsError",
"ProviderError",
"RefreshExpiredError",
"assert_protocol_compliance",
"classify_jwks_lookup_error",
"register_provider",
"get_provider",
"list_providers",
"list_token_providers",
"list_session_providers",
"clear_providers",
]
+95
View File
@@ -0,0 +1,95 @@
"""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"
TOKEN_AUTH_SUCCESS = "token_auth_success"
TOKEN_AUTH_FAILURE = "token_auth_failure"
# RFC 8252 native-app (system-browser + loopback + PKCE) flow.
NATIVE_AUTHORIZE_START = "native_authorize_start"
NATIVE_CODE_ISSUED = "native_code_issued"
NATIVE_TOKEN_SUCCESS = "native_token_success"
NATIVE_TOKEN_FAILURE = "native_token_failure"
def _resolve_log_path() -> Path:
"""``$HERMES_HOME/logs/dashboard-auth.log``.
Uses ``hermes_constants.get_hermes_home()`` (a leaf module — no import
cycle) so profile overrides and the native-Windows ``%LOCALAPPDATA%``
fallback are honored.
"""
from hermes_constants import get_hermes_home
return get_hermes_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)
+353
View File
@@ -0,0 +1,353 @@
"""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 TokenPrincipal:
"""A verified non-interactive (service-to-service) caller.
The token analog of :class:`Session`. Where a ``Session`` represents an
interactive human identity behind a session cookie, a ``TokenPrincipal``
represents a machine/service caller that authenticated by presenting a
bearer token in the ``Authorization`` request header on a single
request — no login, no cookie, no refresh.
Returned by :meth:`DashboardAuthProvider.verify_token` and attached to
``request.state.token_principal`` by the token-auth middleware seam so a
route handler can see *who* called it.
Fields:
* ``principal`` — stable identifier for the caller (e.g. the provider
name, a service account id, or an agent id). Opaque to the seam.
* ``provider`` — the ``name`` of the provider that verified the token.
* ``scopes`` — capability strings this principal is authorised for.
Empty tuple means "unscoped" (the provider vouches for the caller but
attaches no capability list); a route MAY enforce a required scope.
"""
principal: str
provider: str
scopes: tuple[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) with a TTL ≤ 10 minutes (the
login lifetime).
SameSite: use ``Lax`` by default. The one exception is the PKCE state
cookie, which is ``SameSite=None; Secure`` over HTTPS — it is set on
the ``/auth/login`` 302 and has to survive the cross-site redirect
chain back from the IDP, which Chromium drops intermittently under
``Lax`` (crbug 40508226). Over plain HTTP it stays ``Lax``, since
``SameSite=None`` requires ``Secure``. See
:func:`hermes_cli.dashboard_auth.cookies.set_pkce_cookie`.
"""
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):
"""This provider rejects the refresh token as dead or invalid.
In a multi-provider deployment this does not prove token ownership, so
middleware may try remaining providers. It clears cookies and forces
re-login only after every reachable provider rejects the token.
"""
def classify_jwks_lookup_error(exc: BaseException) -> Exception:
"""Map a ``PyJWKClient.get_signing_key_from_jwt`` failure to the protocol.
Only a genuine transport failure (the IDP's JWKS endpoint could not be
fetched) is a :class:`ProviderError` — middleware turns that into 503
"auth provider unreachable" so a flaky IDP never forces a logout.
Everything else means the token itself cannot be verified by this
provider and is an :class:`InvalidCodeError` (``verify_session`` returns
``None``, the middleware tries the next provider / refresh / 401):
* ``jwt.DecodeError`` — the bearer is not a JWT at all (an opaque peer
key, a legacy session token, garbage). #94558: hosted agents answered
every non-JWT bearer with a fast 503 ``Auth provider 'nous'
unreachable`` even though Portal was healthy, because "cannot parse"
and "cannot reach" were folded into one branch.
* ``jwt.PyJWKSetError`` — the JWKS was fetched fine but holds no key for
this token's ``kid`` (rotated/foreign key). The provider was reached;
the token is simply not one of ours.
``PyJWKClientConnectionError`` is the only ``PyJWKClientError`` subclass
that denotes unreachability; a bare ``PyJWKClientError`` (unexpected
JWKS shape) is kept as a provider fault since the IDP misbehaved.
"""
try:
import jwt
except Exception: # pragma: no cover - jwt is a hard dep of these providers
return ProviderError(f"JWKS lookup failed: {exc!r}")
if isinstance(exc, jwt.PyJWKClientConnectionError):
return ProviderError(f"JWKS lookup failed: {exc}")
if isinstance(exc, (jwt.DecodeError, jwt.PyJWKSetError)):
return InvalidCodeError(f"token not verifiable by this provider: {exc}")
if isinstance(exc, jwt.PyJWKClientError):
return ProviderError(f"JWKS lookup failed: {exc}")
if isinstance(exc, jwt.InvalidTokenError):
return InvalidCodeError(f"token not verifiable by this provider: {exc}")
return ProviderError(f"JWKS lookup failed: {exc!r}")
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 invalid for that provider. Middleware tries the remaining
providers because an opaque foreign token can be indistinguishable
from an expired one; it forces re-login only after every reachable
provider rejects the token. Raises ``ProviderError`` on network
failure; middleware still tries remaining providers, but returns 503
without clearing cookies if none succeeds and any was unavailable.
* ``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
# When True, this provider can verify a non-interactive bearer token
# (``verify_token``) presented on a single request by a service-to-service
# caller — no login, no cookie, no refresh. This is the generic
# API-token capability flag, mirroring ``supports_password``: a route
# opts into token auth (see ``token_auth`` middleware seam) and the
# gate consults every ``supports_token`` provider in turn until one
# recognises the token. OAuth/password providers leave this False and
# are completely unaffected. The drain bearer-secret plugin is the
# first consumer, but the capability is deliberately generic so any
# future machine-credential provider drops in without core changes.
supports_token: bool = False
# When True, this provider does the interactive cookie-session flow (login,
# verify, refresh). The login page, /auth/login, and the gate's
# verify/refresh loops consult only supports_session providers, so a
# token-only credential (e.g. drain) is never offered a login. Mirrors
# supports_token.
supports_session: bool = True
@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 verify_token(self, *, token: str) -> "Optional[TokenPrincipal]":
"""Verify a non-interactive bearer token; return its principal.
The token analog of ``verify_session``. Only consulted when
``supports_token`` is True. Called by the ``token_auth`` middleware
seam for every request to a token-authable route, in registration
order, until one provider returns a non-None principal.
Contract (mirrors ``verify_session`` stacking semantics):
* Return a :class:`TokenPrincipal` if this provider recognises and
accepts the token.
* Return ``None`` for a token this provider does NOT recognise —
never raise, so the seam can fall through to the next provider.
A malformed/expired/wrong token is "not recognised" → ``None``.
* Raise ``ProviderError`` ONLY for a genuine backing-store outage
(the provider can neither confirm nor deny). The seam treats this
like ``verify_session``: remember it, keep trying other providers,
and surface 503 only if NO provider accepts the token AND at least
one was unreachable.
Implementations MUST use a constant-time comparison
(``hmac.compare_digest``) when matching a shared secret so the
endpoint isn't a timing oracle.
The default raises ``NotImplementedError`` so a provider that sets
``supports_token`` but forgets to implement this fails loudly rather
than silently accepting every caller.
"""
raise NotImplementedError(
f"{type(self).__name__} does not support token auth "
"(set supports_token = True and override verify_token)"
)
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__)}"
)
+525
View File
@@ -0,0 +1,525 @@
"""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)
The two session cookies are ``SameSite=Lax`` and live under the prefix's
Path. The PKCE cookie is the exception: ``SameSite=None`` over HTTPS,
falling back to ``Lax`` on plain HTTP (where ``SameSite=None`` is invalid
without ``Secure``). It is set on the ``/auth/login`` 302 and must survive
the cross-site redirect chain out to the IDP and back to
``/auth/callback``; Chromium intermittently drops ``Lax`` cookies set on a
302 in such a chain (crbug 40508226), which surfaces as "Missing PKCE
state cookie". ``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.
NOTE: uvicorn only honours ``X-Forwarded-Proto`` from a peer inside its
``forwarded_allow_ips`` (default: ``127.0.0.1``). A TLS terminator that
reaches the dashboard from a non-loopback address — e.g. a reverse proxy
in its own container — is not trusted, so the request still looks like
HTTP here and these cookies are written in their HTTP shape.
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
import base64
import binascii
import json
import re
from typing import Literal, Optional, Tuple
from urllib.parse import unquote
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"
# Provider that minted the session. This non-secret routing hint prevents a
# refresh token from being handed to the wrong provider when several dashboard
# auth plugins are enabled (for example Basic + Nous OAuth).
SESSION_PROVIDER_COOKIE = "hermes_session_provider"
PKCE_COOKIE = "hermes_session_pkce"
# One-shot loop-guard marker for the auto-SSO redirect (Phase 1,
# cloud-auto-discovery). Set when the gate auto-initiates the portal OAuth
# redirect on an unauthenticated document load; its mere PRESENCE on the next
# unauthenticated load tells the gate "we already bounced once" so a genuinely
# absent portal session degrades to the /login page instead of ping-ponging.
# Carries no secret — it's a boolean breadcrumb — but is set HttpOnly/Lax/Secure
# like the others for consistency. Short TTL so a user who returns later gets a
# fresh silent attempt rather than a permanently-disabled one.
SSO_ATTEMPT_COOKIE = "hermes_sso_attempt"
# 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
# Auto-SSO loop-guard marker TTL. Just long enough to cover one redirect
# round trip to the portal and back (a few seconds in practice); kept at 60s
# so a slow portal hop or a manual back-button still trips the guard, while a
# user returning minutes later gets a fresh silent attempt rather than being
# stuck on /login forever. The marker is also cleared explicitly on a
# successful callback and whenever the gate falls back to /login.
_SSO_ATTEMPT_MAX_AGE = 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_provider_cookie(
response: Response,
*,
provider: str,
use_https: bool,
prefix: str = "",
) -> None:
"""Persist the non-secret provider routing hint for token refresh."""
if not provider:
return
response.set_cookie(
_resolved_name(SESSION_PROVIDER_COOKIE, use_https=use_https, prefix=prefix),
provider,
max_age=_RT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
def set_session_cookies(
response: Response,
*,
access_token: str,
refresh_token: str,
access_token_expires_in: int,
use_https: bool,
prefix: str = "",
provider: 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),
)
set_session_provider_cookie(
response,
provider=provider,
use_https=use_https,
prefix=prefix,
)
def _clear_cookie_variants(
response: Response,
bare_name: str,
*,
prefix: str,
https_samesite: Literal["lax", "strict", "none"],
bare_attrs: dict,
) -> None:
"""Emit Max-Age=0 deletions for every plausible name variant of a cookie.
Cookie-prefix rules make the deletion shape load-bearing: a Set-Cookie
for a ``__Host-``/``__Secure-`` name is rejected outright by the
browser unless it carries ``Secure`` (and ``__Host-`` additionally
requires ``Path=/``), so those deletions always carry the attributes
their name demands. The bare-name deletion mirrors the shape the
setter uses (``bare_attrs``) — under RFC 6265bis a deletion sent from
a secure origin may omit ``Secure`` and still delete a Secure cookie,
while a ``Secure`` deletion on a plain-HTTP origin can be ignored, so
matching the setter is the shape that works on both origins.
"""
for variant in _NAME_VARIANTS:
if variant == "__Host-":
# __Host- demands Secure AND Path=/ or the header is invalid.
response.set_cookie(
f"{variant}{bare_name}", "", max_age=0,
path="/", httponly=True, samesite=https_samesite,
secure=True,
)
elif variant == "__Secure-":
response.set_cookie(
f"{variant}{bare_name}", "", max_age=0,
path=_cookie_path(prefix), httponly=True,
samesite=https_samesite, secure=True,
)
else:
response.set_cookie(
bare_name, "", max_age=0, **bare_attrs,
)
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.
"""
bare_attrs = {
"path": _cookie_path(prefix), "httponly": True, "samesite": "lax",
}
for name in (SESSION_AT_COOKIE, SESSION_RT_COOKIE, SESSION_PROVIDER_COOKIE):
_clear_cookie_variants(
response, name,
prefix=prefix, https_samesite="lax", bare_attrs=bare_attrs,
)
def _pkce_attrs(*, use_https: bool, prefix: str) -> dict:
"""Cookie attributes for the PKCE cookie's set AND clear paths.
Single source of truth so a deletion always matches the shape the
setter emitted for the same origin — a shape mismatch means the
browser silently keeps the stale cookie.
"""
attrs = _common_attrs(use_https=use_https, prefix=prefix)
if use_https:
attrs["samesite"] = "none"
return attrs
def encode_pkce_payload(parts: dict[str, str]) -> str:
"""Serialise PKCE segments to the wire value: ``base64url(JSON)``.
The urlsafe base64 alphabet (``A-Za-z0-9-_``, padding stripped) is a
strict subset of the RFC 6265 cookie-octet set — no ``;`` (attribute
terminator), no ``"`` and no ``\\`` (the chars that make Python's
http.cookies emit the quoted ``\\073`` form, which strict cookie-aware
proxy hops such as Go's net/http reject outright). The ``=`` padding
is stripped because http.cookies treats ``=`` as outside its legal
unquoted set and would re-wrap the value in the quoted form this
codec exists to avoid; the parser restores the padding. JSON carries
the segments, so no delimiter can ever collide with segment values —
the delimiter/quoting bug class this codec replaces (see
:func:`parse_pkce_payload` for the two legacy formats it superseded).
"""
raw = json.dumps(parts, separators=(",", ":"), sort_keys=True)
return (
base64.urlsafe_b64encode(raw.encode("utf-8"))
.decode("ascii")
.rstrip("=")
)
def set_pkce_cookie(
response: Response,
*,
payload: dict[str, str],
use_https: bool,
prefix: str = "",
) -> None:
# SameSite=None when HTTPS: the PKCE cookie is set on the /auth/login
# 302 response (redirecting to the IDP) and must survive the cross-site
# redirect chain (same-site → IDP → same-site callback). Chromium has a
# long-standing bug (crbug 40508226) where SameSite=Lax cookies set on a
# 302 in a cross-site redirect chain are intermittently dropped, causing
# "Missing PKCE state cookie" on the callback. SameSite=None + Secure
# sidesteps the bug — these cookies are explicitly designed for cross-site
# delivery and Chromium processes them reliably during redirects.
# Loopback HTTP degrades to Lax (SameSite=None requires Secure).
#
# Value encoding: ``payload`` is the segment dict
# (``{"provider": …, "state": …, "verifier": …, "next": …}``) and goes
# on the wire as base64url(JSON) via encode_pkce_payload() — plain
# RFC 6265 cookie-octets end to end, so every cookie-aware hop
# (browsers, Go net/http proxies, Python parsers) passes the value
# through untouched. Readers decode via parse_pkce_payload(), which
# also keeps a compatibility ladder for cookies minted by the two
# earlier wire formats during a rolling upgrade.
response.set_cookie(
_resolved_name(PKCE_COOKIE, use_https=use_https, prefix=prefix),
encode_pkce_payload(payload),
max_age=_PKCE_MAX_AGE,
**_pkce_attrs(use_https=use_https, prefix=prefix),
)
def clear_pkce_cookie(
response: Response, *, use_https: bool, prefix: str = "",
) -> None:
"""Emit Max-Age=0 deletions for every plausible PKCE cookie variant.
A deletion is only honoured when its shape is acceptable to the
browser on the current origin: a ``Secure`` deletion can be dropped
on a plain-HTTP origin, while the ``__Host-``/``__Secure-`` name
variants REQUIRE ``Secure`` to be valid at all. So the bare-name
deletion mirrors the setter's shape for the active origin (Lax
without ``Secure`` over HTTP; ``SameSite=None; Secure`` over HTTPS,
matching :func:`set_pkce_cookie`), and the prefixed variants — which
can only ever have been set on an HTTPS origin — always carry
``Secure; SameSite=None``.
"""
_clear_cookie_variants(
response, PKCE_COOKIE,
prefix=prefix, https_samesite="none",
bare_attrs=_pkce_attrs(use_https=use_https, prefix=prefix),
)
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_session_provider(request: Request) -> Optional[str]:
"""Return the provider routing hint associated with the session cookies."""
return _read_with_fallback(request, SESSION_PROVIDER_COOKIE)
def read_pkce_cookie(request: Request) -> Optional[str]:
return _read_with_fallback(request, PKCE_COOKIE)
# base64url wire values are exactly the urlsafe alphabet (padding is
# stripped by the encoder; the decoder restores it). Used as a cheap
# pre-filter before attempting the JSON decode so legacy wire forms
# (which always contain ``%`` or ``;``) never even reach the base64
# decoder.
_B64URL_RE = re.compile(r"^[A-Za-z0-9_-]+={0,2}$")
def parse_pkce_payload(raw: str) -> dict[str, str]:
"""Decode + parse a PKCE cookie value into its segment dict.
Single inverse of :func:`set_pkce_cookie` /
:func:`encode_pkce_payload`. EVERY reader of the PKCE cookie must go
through this helper — a reader that interprets the raw wire value
itself parses zero segments and silently disables whatever check it
was feeding (provider dispatch, CSRF state, native-flow broker
binding).
Compatibility ladder — the PKCE cookie has a 10-minute TTL and is
opaque + server-set, so during a rolling upgrade a cookie minted by
one server version can arrive at another. Three formats, tried in
order; each rung is unambiguous:
1. **base64url(JSON)** (current): the wire value is pure urlsafe
base64 that decodes to a JSON object. Legacy forms can never
match — they always contain ``%`` (URL-encoded, #99176) or a raw
``;`` (oldest flat form), both outside the base64url alphabet.
2. **Oldest flat form** (pre-#99176): raw ``;`` between segments
(``provider=…;state=…;verifier=…``). Split as-is WITHOUT
unquoting the payload — the ``next`` segment carries its own
single URL-encoding, and unquoting here would turn a ``%3B``
inside it into a bogus delimiter and truncate the post-login
target. Neither newer format can contain a raw ``;``.
3. **URL-encoded flat form** (#99176): the whole flat payload passed
through ``quote(payload, safe="")`` — no raw ``;`` possible
(it is ``%3B``); unquote once, then split.
Rollout directions: OLD cookie → NEW server is handled here (rungs
2 and 3 parse both legacy forms correctly). NEW cookie → OLD server
(a rollback, or a mixed fleet routing the callback to a not-yet-
upgraded instance) fails the OAuth state check — the old reader
can't find a ``state`` segment in the base64url blob — and the user
simply retries login against the now-consistent fleet; no data loss,
nothing minted.
"""
if _B64URL_RE.match(raw):
try:
padded = raw + "=" * (-len(raw) % 4)
decoded = json.loads(
base64.urlsafe_b64decode(padded.encode("ascii"))
)
except (binascii.Error, ValueError, UnicodeDecodeError):
decoded = None
if isinstance(decoded, dict):
return {str(k): str(v) for k, v in decoded.items()}
if ";" in raw:
# Oldest flat form: already flat, split as-is (no unquote).
return dict(
seg.split("=", 1) for seg in raw.split(";") if "=" in seg
)
# #99176 URL-encoded flat form: unquote once, then split.
return dict(
seg.split("=", 1) for seg in unquote(raw).split(";") if "=" in seg
)
def set_sso_attempt_cookie(
response: Response, *, use_https: bool, prefix: str = "",
) -> None:
"""Set the one-shot auto-SSO loop-guard marker (Phase 1).
Written by the gate the moment it auto-initiates the portal OAuth
redirect on an unauthenticated document load. The value is a constant
(``"1"``) — only its presence matters. Short Max-Age so a stale marker
can't permanently suppress a future silent attempt.
"""
response.set_cookie(
_resolved_name(SSO_ATTEMPT_COOKIE, use_https=use_https, prefix=prefix),
"1",
max_age=_SSO_ATTEMPT_MAX_AGE,
**_common_attrs(use_https=use_https, prefix=prefix),
)
def read_sso_attempt_cookie(request: Request) -> Optional[str]:
"""Return the auto-SSO marker value if present (any variant), else None."""
return _read_with_fallback(request, SSO_ATTEMPT_COOKIE)
def clear_sso_attempt_cookie(response: Response, *, prefix: str = "") -> None:
"""Emit Max-Age=0 deletions for the auto-SSO marker, every name variant.
Called on a successful callback and whenever the gate falls back to
/login, so the marker never lingers to suppress a later silent attempt.
"""
_clear_cookie_variants(
response, SSO_ATTEMPT_COOKIE,
prefix=prefix, https_samesite="lax",
bare_attrs={
"path": _cookie_path(prefix), "httponly": True, "samesite": "lax",
},
)
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"
+537
View File
@@ -0,0 +1,537 @@
"""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_session_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 &middot; 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;
}
a { color: var(--midground); }
</style>
</head>
<body>
<main>
<h1>Sign-in unavailable</h1>
<p>This dashboard is bound to a non-loopback host but no authentication
providers are available.</p>
<p>Configure the bundled username/password provider or an OAuth provider.
See the <a href="https://hermes-agent.nousresearch.com/docs/user-guide/features/web-dashboard#authentication-gated-mode">dashboard
authentication documentation</a> for setup instructions.</p>
<p>For auth-free local use, bind to <code>127.0.0.1</code> and connect through
an SSH tunnel or Tailscale.</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_session_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>'
)
+591
View File
@@ -0,0 +1,591 @@
"""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_session_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
ProviderError,
RefreshExpiredError,
)
from hermes_cli.dashboard_auth.cookies import (
clear_sso_attempt_cookie,
read_session_cookies,
read_session_provider,
read_sso_attempt_cookie,
set_session_provider_cookie,
set_sso_attempt_cookie,
)
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/native/authorize",
"/auth/native/token",
"/auth/native/refresh",
"/auth/password-login",
"/auth/logout",
"/login",
"/api/auth/providers",
"/api/mcp/oauth/callback/",
"/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 _ordered_session_providers(
provider_hint: str | None,
) -> list[DashboardAuthProvider]:
"""Prefer the hinted provider without making the hint authoritative.
The cookie can outlive a provider rename/removal or become stale after a
deployment change. A stable sort moves a matching provider to the front
while preserving registration order for every remaining candidate; an
unknown hint therefore leaves the normal scan unchanged.
"""
providers = list_session_providers()
if provider_hint:
providers.sort(key=lambda provider: provider.name != provider_hint)
return providers
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 _auto_sso_response(request: Request) -> Response | None:
"""Maybe auto-initiate the portal OAuth redirect on an unauth HTML load.
Returns a 302 → ``/auth/login`` (the existing OAuth-initiation route)
when ALL of the following hold, else ``None`` (caller falls back to the
ordinary ``/login`` interstitial):
* the request is an HTML document navigation, not an ``/api/*`` fetch
(a fetch() would follow the 302 into the cross-origin OAuth dance
opaquely — same reason ``_unauth_response`` never redirects APIs);
* exactly ONE interactive provider is registered — with two or more we
can't pick for the user, so the ``/login`` chooser must render; with
zero there's nothing to redirect to;
* that provider is OAuth-style, not a password form provider. Password
providers must render ``/login`` so the user can enter credentials;
* the one-shot loop-guard marker is ABSENT. Its presence means we
already bounced to the portal once and came back still
unauthenticated (no portal session) — auto-redirecting again would
ping-pong, so we fall through to ``/login`` and clear the marker.
The portal ``/oauth/authorize`` auto-approves any current member of the
dashboard's org and is a silent 302 when the user already holds a portal
session, so for the common case (clicked a dashboard link while signed
in to the portal) this removes the interstitial CLICK entirely. It
removes a click, not a security check: the redirect lands on
``/auth/login`` which runs the unchanged PKCE auth-code flow.
"""
path = request.url.path
# APIs never auto-redirect (see _unauth_response). Only document loads.
if path.startswith("/api/"):
return None
# Already bounced once and still no session → portal has no session for
# this user. Stop here, clear the marker, let /login render.
if read_sso_attempt_cookie(request):
from hermes_cli.dashboard_auth.prefix import prefix_from_request
resp = _unauth_response(request, reason="no_cookie")
clear_sso_attempt_cookie(resp, prefix=prefix_from_request(request))
return resp
# list_session_providers() already filters on supports_session=True, so
# token-only credentials (drain/service providers) are never candidates.
providers = list_session_providers()
if len(providers) != 1:
# Zero → nothing to redirect to. Two+ → user must choose at /login.
return None
from hermes_cli.dashboard_auth.prefix import prefix_from_request
provider = providers[0]
if getattr(provider, "supports_password", False):
return None
prefix = prefix_from_request(request)
next_param = _safe_next_target(request)
from urllib.parse import quote
auth_login = f"{prefix}/auth/login?provider={quote(provider.name, safe='')}"
if next_param:
auth_login = f"{auth_login}&next={next_param}"
resp = RedirectResponse(url=auth_login, status_code=302)
# Drop the one-shot marker so a return trip that's STILL unauthenticated
# (portal had no session) trips the guard above next time instead of
# looping. Detect HTTPS for the Secure flag the same way the auth routes
# do; bind Path via the active prefix.
from hermes_cli.dashboard_auth.cookies import detect_https
set_sso_attempt_cookie(
resp, use_https=detect_https(request), prefix=prefix,
)
audit_log(
AuditEvent.LOGIN_START,
provider=provider.name,
reason="auto_sso",
ip=_client_ip(request),
)
return resp
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="")
def _extract_bearer(request: Request) -> str:
"""Return the ``Authorization: Bearer <token>`` value, or ""."""
auth = request.headers.get("authorization", "")
parts = auth.split(" ", 1)
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
return parts[1].strip()
return ""
def _verify_bearer(request: Request, *, access_token: str):
"""Verify a native-app bearer access token via the session-provider stack.
Returns the :class:`Session` on success, or ``None`` if no provider
recognises the token (expired/invalid/unknown). Mirrors the cookie path's
verify loop, including the "one provider unreachable ⇒ don't force
re-login" semantics: a transient IDP outage returns a 503 rather than a
401, so the desktop retries instead of dropping the user to full re-login.
Unlike the cookie path there is no server-side refresh — the desktop owns
its refresh token and rotates via ``/auth/native/refresh``.
"""
unreachable_provider: str | None = None
for provider in list_session_providers():
try:
session = provider.verify_session(access_token=access_token)
except ProviderError as e:
_log.warning(
"dashboard-auth: provider %r unreachable during bearer verify: %s",
provider.name, e,
)
if unreachable_provider is None:
unreachable_provider = provider.name
continue
if session is not None:
return session
if unreachable_provider is not None:
# Signal transient outage to the caller via a sentinel exception the
# middleware turns into 503. Raising keeps the "don't logout on a
# flaky IDP" contract identical to the cookie path.
raise ProviderError(unreachable_provider)
return None
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)
# A request already authenticated by the token-auth seam (a service caller
# on a registered token route) carries ``token_authenticated`` — it is NOT
# a cookie session and must not be bounced to /login. Pass it through; the
# seam already attached ``request.state.token_principal``.
if getattr(request.state, "token_authenticated", False):
return await call_next(request)
path = request.url.path
if _path_is_public(path):
return await call_next(request)
# RFC 8252 native-app bearer path (goal: no session cookies). The desktop
# authenticates REST with ``Authorization: Bearer <access_token>`` — the
# SAME provider-minted access token the cookie flow stores in
# ``hermes_session_at``. Verify it with the identical ``verify_session``
# provider stack and attach the Session; on success we're done, with no
# cookie set or read. A missing/expired/invalid bearer falls through to
# the cookie path (a request may legitimately carry neither). Token
# rotation for this path is the desktop's job via /auth/native/refresh —
# the gate never sets a cookie here, so the transparent cookie-rotation
# below must not run for a bearer caller.
bearer = _extract_bearer(request)
if bearer:
try:
bearer_session = _verify_bearer(request, access_token=bearer)
except ProviderError as e:
# At least one provider's IDP/JWKS was unreachable and none
# verified the token — transient outage, not bad credentials.
return JSONResponse(
{"detail": f"Auth provider {str(e)!r} unreachable"},
status_code=503,
)
if bearer_session is not None:
request.state.session = bearer_session
return await call_next(request)
# A bearer was presented but didn't verify (expired/invalid/unknown).
# Return the structured 401 so the desktop knows to refresh or
# re-login, rather than falling through to the cookie/login redirect.
return _unauth_response(request, reason="invalid_or_expired_session")
at, _rt = read_session_cookies(request)
provider_hint = read_session_provider(request)
if not at and not _rt:
# Neither token present — no session at all. Nothing to verify or
# refresh. Before falling back to the /login interstitial, try to
# silently bounce the user through the portal OAuth flow: the portal
# auto-approves org members and 302s straight back when they already
# hold a portal session, so the interstitial click is pure friction
# for the common case. The one-shot loop-guard inside _auto_sso_response
# prevents a ping-pong when the portal genuinely has no session.
auto = _auto_sso_response(request)
if auto is not None:
return auto
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 _ordered_session_providers(provider_hint):
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; only after every provider rejects
# the RT do we fall through to clear-and-relogin.
try:
refreshed = _attempt_refresh(
request,
refresh_token=_rt,
provider_hint=provider_hint,
)
except ProviderError as e:
# At least one provider could not confirm or reject the RT, and no
# other provider refreshed it. Preserve the cookies and surface a
# transient outage instead of turning uncertainty into a logout.
return JSONResponse(
{"detail": f"Auth provider {str(e)!r} unreachable"},
status_code=503,
)
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),
provider=refreshing_provider,
)
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
response = await call_next(request)
if not provider_hint and session.provider:
from hermes_cli.dashboard_auth.cookies import detect_https
from hermes_cli.dashboard_auth.prefix import prefix_from_request
set_session_provider_cookie(
response,
provider=session.provider,
use_https=detect_https(request),
prefix=prefix_from_request(request),
)
return response
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, provider_hint: str | None = None):
"""Try to rotate an expired session via the refresh token.
The provider hint only changes candidate order. ``RefreshExpiredError``
rejects the token for that candidate, but cannot prove ownership because
providers such as Basic raise it for foreign opaque tokens too. Likewise,
``ProviderError`` only makes that candidate unavailable. Both are audited
and the remaining providers are tried. Returns ``None`` only when there is
no RT or every reachable provider rejects it. If no provider succeeds and
at least one raised ``ProviderError``, re-raises with that provider's name
so the caller can return 503 without clearing potentially valid cookies.
"""
if not refresh_token:
return None
unavailable_provider: str | None = None
for provider in _ordered_session_providers(provider_hint):
try:
new_session = provider.refresh_session(refresh_token=refresh_token)
except RefreshExpiredError:
audit_log(
AuditEvent.REFRESH_FAILURE,
provider=provider.name,
reason="refresh_expired",
ip=_client_ip(request),
)
continue
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),
)
if unavailable_provider is None:
unavailable_provider = provider.name
continue
if new_session is not None:
return new_session, provider.name
if unavailable_provider is not None:
raise ProviderError(unavailable_provider)
return None
+306
View File
@@ -0,0 +1,306 @@
"""Gateway-brokered RFC 8252 (OAuth 2.0 for Native Apps) authorization store.
The desktop app is a *native* OAuth client that wants to sign in to a gated
gateway **without an embedded webview and without relying on browser session
cookies**. It cannot be a direct OAuth client of the upstream IDP (Nous
Portal): the Portal ``client_id`` is per-gateway-instance
(``agent:{instance_id}``) and the Portal validates that the ``redirect_uri``
ends in ``/auth/callback`` on the gateway's own public origin — a desktop
loopback ``127.0.0.1`` redirect is rejected. So the **gateway brokers** the
flow: it is the authorization server *to the desktop*, and an OAuth client *to
the Portal*. This is still a textbook RFC 8252 deployment — system browser,
loopback redirect, PKCE, tokens returned to the app (never cookies).
Wire shape (all gateway-side state lives in this module):
1. Desktop generates its OWN PKCE pair ``(cv_d, cc_d)`` and a ``state``, opens
a loopback listener on ``127.0.0.1:<port>``, and opens the system browser
to the gateway's ``GET /auth/native/authorize?...`` carrying ``cc_d``,
``state``, and its loopback ``redirect_uri``.
2. The gateway ``authorize`` route stashes a **pending authorization**
(``register_pending``) keyed by an opaque ``broker_state`` and runs the
EXISTING upstream PKCE flow (``provider.start_login`` → Portal
``/oauth/authorize`` → gateway ``/auth/callback``). The desktop's
``cc_d`` / ``state`` / loopback ``redirect_uri`` ride through the upstream
round trip inside the gateway's own PKCE cookie, so no desktop secret is
ever exposed to the Portal.
3. On the upstream callback the gateway holds a verified :class:`Session`. It
**mints a one-time gateway authorization code** (``complete_pending``)
bound to the desktop's ``cc_d``, and 302s the browser to the desktop's
``redirect_uri?code=<gw_code>&state=<state>``.
4. The desktop's loopback listener catches ``gw_code``, then POSTs
``/auth/native/token`` with ``gw_code`` + its ``cv_d``. The gateway
verifies ``SHA256(cv_d) == cc_d`` (``redeem_code``), consumes the code
(single use), and returns the upstream ``access_token`` /
``refresh_token`` / ``expires_at`` **in the JSON body**.
5. The desktop stores those in the OS keychain and authenticates REST with
``Authorization: Bearer <access_token>`` (via the existing ``token_auth``
seam) and mints ws-tickets the same way — no cookies anywhere.
Password providers ride the same broker with step 2 swapped: there is no
upstream IDP, so ``/auth/native/authorize`` sends the system browser to the
interactive ``/login`` form (broker_state in the PKCE cookie) and a successful
``/auth/password-login`` plays the role of the upstream callback — it calls
:func:`complete_pending` and bounces the browser to the loopback redirect.
Steps 45 are identical. The point of brokering a password login at all is
that the system browser can autofill from the OS password manager (macOS
Passwords, etc.), which no embedded desktop webview can.
Security properties this module guarantees:
* **PKCE binding (RFC 7636).** A gateway code is redeemable only by the client
that presented the matching ``code_challenge``. An attacker who intercepts
the loopback ``gw_code`` (e.g. a hostile process racing the redirect) cannot
exchange it without ``cv_d``, which never leaves the desktop.
* **Single use.** ``redeem_code`` pops the entry; a replay finds nothing.
* **Short TTLs.** A pending authorization lives ``_PENDING_TTL`` seconds (the
interactive login window); a minted code lives ``_CODE_TTL`` seconds (the
loopback round trip is sub-second). Expired entries are refused and GC'd.
* **Opaque, high-entropy handles.** ``broker_state`` and ``gw_code`` are
256-bit ``secrets.token_urlsafe`` values; comparison is constant-time.
* **No secret logging.** The module stores tokens transiently in memory only
between callback and redemption; nothing here writes them to disk (the
audit log strips token fields).
In-memory and process-local: the dashboard is a single process, so no
distributed coordination is needed (mirrors ``ws_tickets``). A functional API
(not a class) keeps ``time.time`` patchable in tests.
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import threading
import time
from dataclasses import dataclass
from typing import Dict, Optional
from hermes_cli.dashboard_auth.base import Session
# TTL for a pending authorization (step 2→3): the whole interactive login,
# including the user typing Portal credentials / approving in the browser.
_PENDING_TTL_SECONDS = 600 # 10 minutes — mirrors the PKCE cookie lifetime.
# TTL for a minted gateway code (step 3→4): only the loopback redirect + the
# desktop's immediate token POST, which is sub-second in practice.
_CODE_TTL_SECONDS = 120 # 2 minutes — generous for a slow local hop.
# Cap the number of concurrent pending/issued entries so a misbehaving or
# malicious client cannot grow the store unbounded. Well above any legitimate
# concurrent-login count for a single desktop user.
_MAX_ENTRIES = 256
# Per-IP cap on concurrent PENDING authorizations. /auth/native/authorize is a
# public (pre-auth) route, so without this a single unauthenticated spammer
# could fill the global store (600s TTL each) and lock out legitimate native
# logins for the pending window. A real desktop runs at most a couple of
# concurrent sign-ins from one address; 8 is generous.
_MAX_PENDING_PER_IP = 8
_lock = threading.Lock()
@dataclass
class _Pending:
"""An in-flight native authorization awaiting the upstream callback.
Created when the desktop hits ``/auth/native/authorize`` and consumed when
the upstream ``/auth/callback`` completes and mints the gateway code.
"""
code_challenge: str # the DESKTOP's S256 challenge (cc_d), base64url no-pad
redirect_uri: str # the desktop's loopback redirect (127.0.0.1:<port>/...)
client_state: str # the desktop's own ``state`` (echoed back on redirect)
client_ip: str # requester IP at authorize time (per-IP pending cap)
expires_at: int
@dataclass
class _IssuedCode:
"""A minted one-time gateway authorization code bound to a Session."""
code_challenge: str # cc_d — verified against cv_d at redemption
session: Session
expires_at: int
# broker_state -> _Pending
_pending: Dict[str, _Pending] = {}
# gw_code -> _IssuedCode
_issued: Dict[str, _IssuedCode] = {}
class NativeFlowError(Exception):
"""Base for native-flow failures (bad/expired/replayed handle, PKCE fail)."""
class PendingNotFound(NativeFlowError):
"""The broker_state is unknown or expired (login window lapsed)."""
class CodeInvalid(NativeFlowError):
"""The gateway code is unknown, expired, already redeemed, or PKCE-mismatched."""
def _b64url_no_pad(raw: bytes) -> str:
"""Base64url without ``=`` padding (RFC 7636 §4)."""
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
def _s256(verifier: str) -> str:
"""RFC 7636 S256 transform: base64url(sha256(ascii(verifier)))."""
return _b64url_no_pad(hashlib.sha256(verifier.encode("ascii")).digest())
def _gc_locked(now: int) -> None:
"""Drop expired pending + issued entries. Caller holds ``_lock``."""
expired_p = [k for k, v in _pending.items() if v.expires_at < now]
for k in expired_p:
_pending.pop(k, None)
expired_c = [k for k, v in _issued.items() if v.expires_at < now]
for k in expired_c:
_issued.pop(k, None)
def _capacity_ok_locked() -> bool:
return (len(_pending) + len(_issued)) < _MAX_ENTRIES
def register_pending(
*,
code_challenge: str,
redirect_uri: str,
client_state: str,
client_ip: str = "",
now: Optional[int] = None,
) -> str:
"""Stash a pending native authorization; return an opaque ``broker_state``.
Called by ``/auth/native/authorize``. ``code_challenge`` is the DESKTOP's
S256 challenge (``cc_d``) — we never see the verifier until redemption.
``redirect_uri`` is the desktop's loopback callback and ``client_state`` is
the desktop's own CSRF ``state`` (echoed verbatim on the final redirect).
``client_ip`` is the requester's address, used only for the per-IP pending
cap below.
The returned ``broker_state`` is what the gateway threads through its OWN
upstream PKCE round trip (inside the ``hermes_session_pkce`` cookie), so the
callback can find this entry again via :func:`complete_pending`.
Raises ``NativeFlowError`` if the store is at capacity or the caller's IP
already holds ``_MAX_PENDING_PER_IP`` live pending entries (fail closed —
this is a public pre-auth route, so one spammer must not be able to fill
the global store and deny sign-in to everyone else).
"""
now = int(time.time()) if now is None else now
broker_state = secrets.token_urlsafe(32)
with _lock:
_gc_locked(now)
if not _capacity_ok_locked():
raise NativeFlowError("native-flow authorization store at capacity")
if client_ip and (
sum(1 for v in _pending.values() if v.client_ip == client_ip)
>= _MAX_PENDING_PER_IP
):
raise NativeFlowError(
"too many pending native authorizations from this address"
)
_pending[broker_state] = _Pending(
code_challenge=code_challenge,
redirect_uri=redirect_uri,
client_state=client_state,
client_ip=client_ip,
expires_at=now + _PENDING_TTL_SECONDS,
)
return broker_state
def get_pending(broker_state: str, *, now: Optional[int] = None) -> _Pending:
"""Return the pending authorization for ``broker_state`` without consuming it.
Read-only peek used by the callback to learn the desktop's ``redirect_uri``
and ``client_state`` for the final 302. Raises :class:`PendingNotFound` if
unknown or expired (the entry is GC'd on expiry).
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
entry = _pending.get(broker_state)
if entry is None:
raise PendingNotFound("unknown or expired native authorization")
return entry
def complete_pending(
broker_state: str,
*,
session: Session,
now: Optional[int] = None,
) -> str:
"""Consume a pending authorization and mint a one-time gateway code.
Called by ``/auth/callback`` once the upstream :class:`Session` is verified.
Pops the pending entry (single use), binds a fresh ``gw_code`` to the
desktop's ``code_challenge`` + the verified ``session``, and returns the
``gw_code`` for the loopback redirect.
Raises :class:`PendingNotFound` if the broker_state is unknown/expired.
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
pending = _pending.pop(broker_state, None)
if pending is None:
raise PendingNotFound("unknown or expired native authorization")
if not _capacity_ok_locked():
raise NativeFlowError("native-flow code store at capacity")
gw_code = secrets.token_urlsafe(32)
_issued[gw_code] = _IssuedCode(
code_challenge=pending.code_challenge,
session=session,
expires_at=now + _CODE_TTL_SECONDS,
)
return gw_code
def redeem_code(
*,
code: str,
code_verifier: str,
now: Optional[int] = None,
) -> Session:
"""Verify PKCE + consume a gateway code; return the bound :class:`Session`.
Called by ``/auth/native/token``. Enforces:
* the code exists and is unexpired (else :class:`CodeInvalid`);
* ``S256(code_verifier) == code_challenge`` in constant time (RFC 7636);
* single use — the entry is popped BEFORE the PKCE check so a wrong
verifier cannot be retried against the same code.
On any failure the code is already consumed (no oracle, no replay).
"""
now = int(time.time()) if now is None else now
with _lock:
_gc_locked(now)
issued = _issued.pop(code, None)
# Pop happened under the lock; every return path below has already
# consumed the code, so a replay (valid or not) finds nothing.
if issued is None:
raise CodeInvalid("unknown, expired, or already-redeemed code")
if issued.expires_at < now:
raise CodeInvalid("code expired")
expected = issued.code_challenge
actual = _s256(code_verifier)
if not hmac.compare_digest(expected, actual):
raise CodeInvalid("PKCE verification failed")
return issued.session
def _reset_for_tests() -> None:
"""Test-only: drop all pending + issued state."""
with _lock:
_pending.clear()
_issued.clear()
+232
View File
@@ -0,0 +1,232 @@
"""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__)
# Home Assistant Supervisor ingress prefixes are already 63 chars before
# deployments add their own sub-path. Keep a bounded header budget, but leave
# room for mainstream reverse-proxy path mounts.
_MAX_PREFIX_LENGTH = 256
# 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()
_warned_malformed_prefixes: 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 _warn_if_malformed_prefix(raw: Optional[str], reason: str) -> None:
"""Warn once when a non-empty X-Forwarded-Prefix value is rejected."""
cleaned = raw.strip() if raw else ""
if not cleaned:
return
key = (cleaned, reason)
if key in _warned_malformed_prefixes:
return
_warned_malformed_prefixes.add(key)
_log.warning(
"X-Forwarded-Prefix header %r was ignored because %s. "
"Dashboard URLs will be generated without a reverse-proxy path prefix.",
cleaned,
reason,
)
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)
):
_warn_if_malformed_prefix(
raw,
"it contains a disallowed character or path sequence",
)
return ""
if len(p) > _MAX_PREFIX_LENGTH:
_warn_if_malformed_prefix(
raw,
f"it is longer than {_MAX_PREFIX_LENGTH} characters",
)
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
+60
View File
@@ -0,0 +1,60 @@
"""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({
# Minimal process liveness probe for desktop/backend boot handshakes. It
# intentionally avoids gateway config, platform discovery, MCP setup, and
# host-local detail so readiness checks cannot spend their budget inside
# cold plugin imports.
"/api/health",
# 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",
# Chronos managed-cron fire webhook (NAS -> agent). NOT cookie-gated: it
# carries its own short-lived NAS-minted JWT (purpose=cron_fire), which the
# handler verifies as the real auth. Must bypass the dashboard auth gate so
# the NAS relay's bearer-only callback reaches the verifier instead of a
# 401 no_cookie. The JWT — not this allowlist — is the security boundary.
"/api/cron/fire",
})
+167
View File
@@ -0,0 +1,167 @@
"""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_constants import hermes_home_key
from hermes_cli.dashboard_auth.base import (
DashboardAuthProvider,
assert_protocol_compliance,
)
_log = logging.getLogger(__name__)
_lock = threading.Lock()
_providers: dict[str, DashboardAuthProvider] = {}
_scoped_providers: dict[str, dict[str, DashboardAuthProvider]] = {}
def _merged(scope: Optional[str] = None) -> dict[str, DashboardAuthProvider]:
providers = dict(_providers)
providers.update(_scoped_providers.get(scope or hermes_home_key(), {}))
return providers
def register_provider(
provider: DashboardAuthProvider,
*,
scope: Optional[str] = None,
) -> 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:
target = _providers if scope is None else _scoped_providers.setdefault(scope, {})
effective = target if scope is None else _merged(scope)
if provider.name in effective:
raise ValueError(
f"dashboard-auth provider already registered: {provider.name!r}"
)
target[provider.name] = provider
_log.info(
"dashboard-auth: registered provider %r (%s)",
provider.name, provider.display_name,
)
def get_provider(
name: str,
*,
scope: Optional[str] = None,
) -> Optional[DashboardAuthProvider]:
"""Return the registered provider for ``name``, or None if unknown."""
with _lock:
return _merged(scope).get(name)
def snapshot_registration(
name: str,
*,
scope: Optional[str] = None,
) -> Optional[DashboardAuthProvider]:
with _lock:
target = _providers if scope is None else _scoped_providers.get(scope, {})
return target.get(name)
def restore_registration(
name: str,
current: DashboardAuthProvider,
previous: Optional[DashboardAuthProvider],
*,
scope: Optional[str] = None,
) -> bool:
"""Restore a host-owned provider registration if it is still current."""
with _lock:
target = _providers if scope is None else _scoped_providers.setdefault(scope, {})
if target.get(name) is not current:
return False
if previous is None:
target.pop(name, None)
else:
target[name] = previous
if scope is not None and not target:
_scoped_providers.pop(scope, None)
return True
def list_providers(*, scope: Optional[str] = None) -> List[DashboardAuthProvider]:
"""All registered providers, in registration order."""
with _lock:
return list(_merged(scope).values())
def list_token_providers() -> List[DashboardAuthProvider]:
"""Registered providers that support non-interactive token auth.
The subset of ``list_providers()`` whose ``supports_token`` flag is True,
in registration order. The ``token_auth`` middleware seam consults these
(and only these) when a token-authable route is hit, so OAuth/password-only
providers are never asked to ``verify_token``. Returns an empty list when
no token provider is registered — a token-authable route then fails
closed (401), never open.
"""
return [p for p in list_providers() if getattr(p, "supports_token", False)]
def list_session_providers() -> List[DashboardAuthProvider]:
"""Registered providers with supports_session True (interactive cookie
sessions). The login page, /auth/login, and the gate's verify/refresh loops
consult only these. Mirror of list_token_providers.
"""
return [p for p in list_providers() if getattr(p, "supports_session", True)]
def register_global_provider(provider: DashboardAuthProvider) -> None:
"""Register a host-owned provider in the process-global slot (upsert).
The dashboard auth registry is process-global and shared across every
profile the dashboard serves from one process, so its providers must
outlive any single per-home plugin manager. Unlike ``register_provider``
this always targets the global ``_providers`` map (never a per-home
overlay) and *replaces* any same-name entry instead of raising, so a
forced plugin re-discovery (e.g. after a password change) rotates the
provider in place. Pairs with ``unregister_global_provider`` for teardown
of the exact object still current (#91701).
"""
assert_protocol_compliance(type(provider))
with _lock:
_providers[provider.name] = provider
_log.info(
"dashboard-auth: registered global provider %r (%s)",
provider.name, provider.display_name,
)
def unregister_global_provider(
name: str,
provider: DashboardAuthProvider,
) -> bool:
"""Remove a global provider registration if ``provider`` is still current.
Identity-conditional so a stale handle (whose provider was already
replaced by a later ``register_global_provider``) never clears the live
registration.
"""
with _lock:
if _providers.get(name) is provider:
_providers.pop(name, None)
return True
return False
def clear_providers() -> None:
"""Test-only: drop all registrations."""
with _lock:
_providers.clear()
_scoped_providers.clear()
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
"""Route-agnostic non-interactive (bearer-token) auth seam for the dashboard.
This is the generic API-token capability (decisions.md Q-C): a reusable seam
that ANY service-to-service / machine-credential provider plugs into, NOT a
drain-specific hook. The drain bearer-secret plugin is merely the first
consumer.
How it fits the existing auth framework:
* The interactive gate (``gated_auth_middleware``) authenticates a human
via a session cookie on every non-public route. A service caller has no
cookie — it presents a bearer token in the ``Authorization`` header on a
single request. That is what this seam verifies.
* A route opts in by registering its exact path via
:func:`register_token_route`. Only registered paths are token-authable;
everything else is untouched, so this can never accidentally widen the
auth surface of an existing route.
* :func:`token_auth_middleware` runs OUTERMOST (installed last in
``web_server.py``). For a token route it fully owns the auth decision:
authenticate via the stacked token providers, attach the verified
:class:`~hermes_cli.dashboard_auth.base.TokenPrincipal` to
``request.state.token_principal`` + set ``request.state.token_authenticated``,
and pass through; otherwise reject (401 unauthenticated, or 503 when a
provider's backing store was unreachable). The downstream cookie/session
gates honour ``token_authenticated`` and skip enforcement, so a
token-authed service request is never bounced to ``/login``.
* Fails closed: a token route with no registered token provider, no token,
or an unrecognised token gets 401 — never an open pass-through.
Provider stacking mirrors ``verify_session``: each ``supports_token`` provider
is consulted in registration order until one returns a principal. A provider
that doesn't recognise the token returns ``None`` and the seam moves on; a
provider whose backing store is unreachable raises ``ProviderError``, which the
seam remembers and surfaces as 503 only if NO provider accepts the token.
"""
from __future__ import annotations
import logging
import threading
from typing import Awaitable, Callable, Optional, Tuple
from fastapi import Request
from fastapi.responses import JSONResponse, Response
from hermes_cli.dashboard_auth import list_token_providers
from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log
from hermes_cli.dashboard_auth.base import ProviderError, TokenPrincipal
_log = logging.getLogger(__name__)
# Exact paths that accept non-interactive bearer-token auth. A route registers
# itself here at import/startup; the seam only acts on registered paths.
_token_routes: set[str] = set()
_lock = threading.Lock()
def register_token_route(path: str) -> None:
"""Mark ``path`` (exact match) as token-authable.
Idempotent. Call at module import / app setup so the seam knows which
routes to guard. Registering a route does NOT make it public — it makes
it authenticate by token instead of by session cookie.
"""
with _lock:
_token_routes.add(path)
def is_token_route(path: str) -> bool:
"""True if ``path`` was registered as token-authable (exact match)."""
with _lock:
return path in _token_routes
def clear_token_routes() -> None:
"""Test-only: drop all registered token routes."""
with _lock:
_token_routes.clear()
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 extract_bearer_token(request: Request) -> str:
"""Return the bearer token from the ``Authorization`` header, or "".
Accepts ``<scheme> <token>`` where scheme is "bearer" (case-insensitive).
Returns an empty string for a missing/malformed header or a non-bearer
scheme — the caller treats "" as "no token presented".
"""
auth = request.headers.get("authorization", "")
parts = auth.split(" ", 1)
if len(parts) == 2 and parts[0].strip().lower() == "bearer":
return parts[1].strip()
return ""
def authenticate_token(
request: Request,
) -> Tuple[Optional[TokenPrincipal], Optional[str]]:
"""Try every token provider against the request's bearer token.
Returns ``(principal, unreachable_provider_name)``:
* ``(TokenPrincipal, None)`` — a provider recognised and accepted the token.
* ``(None, None)`` — no token, or no provider recognised it (reject 401).
* ``(None, name)`` — no provider accepted it AND at least one provider's
backing store was unreachable (the caller surfaces 503, not 401, so a
transient outage doesn't read as "bad credentials").
Never raises: a provider ``ProviderError`` is caught and remembered.
"""
token = extract_bearer_token(request)
if not token:
return None, None
unreachable: Optional[str] = None
for provider in list_token_providers():
try:
principal = provider.verify_token(token=token)
except ProviderError as e:
_log.warning(
"dashboard-auth: token provider %r unreachable during verify: %s",
provider.name, e,
)
if unreachable is None:
unreachable = provider.name
continue
except Exception as e: # noqa: BLE001 — a buggy provider must not 500 the gate
_log.warning(
"dashboard-auth: token provider %r raised during verify: %s",
provider.name, e,
)
continue
if principal is not None:
return principal, None
return None, unreachable
async def token_auth_middleware(
request: Request,
call_next: Callable[[Request], Awaitable[Response]],
) -> Response:
"""Outermost auth seam for token-authable routes.
No-op pass-through for any path not registered via
:func:`register_token_route`. For a registered path, token auth is the
only accepted scheme:
* valid token → attach principal + ``token_authenticated`` flag, pass through.
* unreachable → 503 (provider backing store down; not "bad credentials").
* otherwise → 401 unauthenticated.
Runs before the cookie/session gates (installed last in ``web_server.py``).
The cookie gates honour ``request.state.token_authenticated`` and skip
enforcement, so a token-authed request is never redirected to ``/login``.
"""
path = request.url.path
if not is_token_route(path):
return await call_next(request)
principal, unreachable = authenticate_token(request)
if principal is not None:
request.state.token_principal = principal
request.state.token_authenticated = True
return await call_next(request)
if unreachable:
audit_log(
AuditEvent.TOKEN_AUTH_FAILURE,
provider=unreachable,
reason="provider_unreachable",
path=path,
ip=_client_ip(request),
)
return JSONResponse(
{"detail": f"Auth provider {unreachable!r} unreachable"},
status_code=503,
)
audit_log(
AuditEvent.TOKEN_AUTH_FAILURE,
reason="no_provider_recognises_token",
path=path,
ip=_client_ip(request),
)
return JSONResponse(
{"error": "unauthenticated", "detail": "Unauthorized"},
status_code=401,
)
+161
View File
@@ -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