Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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__)}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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 · 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>'
|
||||
)
|
||||
@@ -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
|
||||
@@ -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 4–5 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()
|
||||
@@ -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
|
||||
@@ -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",
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""WS-upgrade auth credentials for gated mode.
|
||||
|
||||
Browsers cannot set ``Authorization`` on a WebSocket upgrade. In loopback
|
||||
mode the legacy ``?token=<_SESSION_TOKEN>`` query param works because the
|
||||
token is injected into the SPA bundle. In gated mode there is no injected
|
||||
token — so this module provides two credential shapes:
|
||||
|
||||
1. **Single-use browser tickets** (``mint_ticket`` / ``consume_ticket``).
|
||||
The SPA gets a fresh ticket via the authenticated REST endpoint
|
||||
``POST /api/auth/ws-ticket`` and passes it as ``?ticket=`` on the WS
|
||||
upgrade. Single-use, TTL = 30 seconds — a leaked ticket is uninteresting.
|
||||
|
||||
2. **A process-lifetime internal credential** (``internal_ws_credential`` /
|
||||
``consume_internal_credential``). This authenticates *server-spawned*
|
||||
WS clients — specifically the embedded-TUI PTY child, which attaches to
|
||||
``/api/ws`` (JSON-RPC gateway) and ``/api/pub`` (event sidecar) over
|
||||
loopback. A single-use 30s ticket is the wrong shape for that link: the
|
||||
child reads its attach URL once at startup and **reuses it on every
|
||||
reconnect**, and on a slow cold boot the child may not dial within 30s.
|
||||
The internal credential is minted once per process, never expires, is
|
||||
multi-use, and — critically — is **never injected into any HTML/SPA**:
|
||||
it only ever leaves the process via the spawned child's environment, so
|
||||
browser-side XSS cannot read it. A leaked internal credential grants no
|
||||
more than a single-use ticket already does (the same two internal WS
|
||||
endpoints), and the same Origin / host guards still apply downstream.
|
||||
|
||||
In-memory; the dashboard is a single process so no distributed coordination
|
||||
is needed. The module exposes a small functional API rather than a class so
|
||||
tests can patch ``time.time`` cleanly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
#: Time-to-live for newly-minted tickets in seconds. 30 s is long enough
|
||||
#: that the SPA can call ``getWsTicket()`` and immediately open the WS,
|
||||
#: short enough that a leaked ticket is uninteresting.
|
||||
TTL_SECONDS = 30
|
||||
|
||||
_lock = threading.Lock()
|
||||
_tickets: Dict[str, Tuple[int, Dict[str, Any]]] = {} # ticket -> (expires_at, info)
|
||||
|
||||
#: The process-lifetime internal credential (see module docstring). Lazily
|
||||
#: minted on first ``internal_ws_credential()`` call and stable for the life
|
||||
#: of the process. Guarded by ``_lock``.
|
||||
_internal_credential: Optional[str] = None
|
||||
|
||||
#: Identity recorded for connections that authenticate via the internal
|
||||
#: credential, so audit logs distinguish them from browser-initiated tickets.
|
||||
INTERNAL_USER_ID = "server-internal"
|
||||
INTERNAL_PROVIDER = "server-internal"
|
||||
|
||||
|
||||
class TicketInvalid(Exception):
|
||||
"""Ticket missing, expired, or already consumed."""
|
||||
|
||||
|
||||
def mint_ticket(*, user_id: str, provider: str) -> str:
|
||||
"""Generate a one-shot ticket bound to this user identity.
|
||||
|
||||
The returned token is base64url, 43 bytes of entropy (32-byte random
|
||||
seed). Stash returns the ``info`` dict to the caller on consume so the
|
||||
WS handler can carry the identity forward into its session log.
|
||||
"""
|
||||
ticket = secrets.token_urlsafe(32)
|
||||
info = {
|
||||
"user_id": user_id,
|
||||
"provider": provider,
|
||||
"minted_at": int(time.time()),
|
||||
}
|
||||
with _lock:
|
||||
_tickets[ticket] = (int(time.time()) + TTL_SECONDS, info)
|
||||
_gc_expired_locked()
|
||||
return ticket
|
||||
|
||||
|
||||
def consume_ticket(ticket: str) -> Dict[str, Any]:
|
||||
"""Validate and consume. Raises :class:`TicketInvalid` on missing/expired/used.
|
||||
|
||||
Single-use semantics: a successful consume immediately removes the
|
||||
ticket from the store, so a second call with the same value raises
|
||||
``TicketInvalid("unknown ticket: …")``.
|
||||
"""
|
||||
now = int(time.time())
|
||||
with _lock:
|
||||
entry = _tickets.pop(ticket, None)
|
||||
if entry is None:
|
||||
# Truncate ticket value in the error so misuse never logs the
|
||||
# secret in full.
|
||||
truncated = (ticket[:8] + "…") if ticket else "<empty>"
|
||||
raise TicketInvalid(f"unknown ticket: {truncated}")
|
||||
expires_at, info = entry
|
||||
if expires_at < now:
|
||||
raise TicketInvalid("expired")
|
||||
return info
|
||||
|
||||
|
||||
def _gc_expired_locked() -> None:
|
||||
"""Drop expired tickets. Caller must hold ``_lock``."""
|
||||
now = int(time.time())
|
||||
expired = [t for t, (exp, _) in _tickets.items() if exp < now]
|
||||
for t in expired:
|
||||
_tickets.pop(t, None)
|
||||
|
||||
|
||||
def internal_ws_credential() -> str:
|
||||
"""Return the process-lifetime internal WS credential, minting it once.
|
||||
|
||||
Used by the server to authenticate WS clients it spawns itself (the
|
||||
embedded-TUI PTY child). The value is stable for the life of the process,
|
||||
multi-use, and never expires — so a server-spawned child can reconnect
|
||||
its ``/api/ws`` / ``/api/pub`` sockets indefinitely without re-minting.
|
||||
|
||||
The credential is never injected into the SPA HTML or returned over any
|
||||
REST endpoint; it is only ever passed to a child process via its
|
||||
environment. See the module docstring for the threat-model rationale.
|
||||
"""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
if _internal_credential is None:
|
||||
_internal_credential = secrets.token_urlsafe(32)
|
||||
return _internal_credential
|
||||
|
||||
|
||||
def consume_internal_credential(value: str) -> Dict[str, Any]:
|
||||
"""Validate an internal credential. Raises :class:`TicketInvalid` on mismatch.
|
||||
|
||||
Unlike :func:`consume_ticket` this is **not** single-use — the value is
|
||||
not removed on success, so a server-spawned child can present it on every
|
||||
(re)connect. Returns the fixed server-internal identity ``info`` dict
|
||||
(``{user_id, provider}``), mirroring the ``info`` shape ``consume_ticket``
|
||||
returns, so a caller that wants to record the connecting identity can; the
|
||||
current ``_ws_auth_ok`` caller validates for the boolean outcome only and
|
||||
discards the dict.
|
||||
|
||||
A constant-time compare against the (lazily-minted) credential avoids
|
||||
leaking length / prefix information on mismatch. If no internal
|
||||
credential has been minted yet, any value is rejected.
|
||||
"""
|
||||
with _lock:
|
||||
expected = _internal_credential
|
||||
if not value or expected is None:
|
||||
raise TicketInvalid("no internal credential")
|
||||
if not secrets.compare_digest(value.encode(), expected.encode()):
|
||||
raise TicketInvalid("internal credential mismatch")
|
||||
return {
|
||||
"user_id": INTERNAL_USER_ID,
|
||||
"provider": INTERNAL_PROVIDER,
|
||||
}
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Test-only: drop all tickets and the internal credential."""
|
||||
global _internal_credential
|
||||
with _lock:
|
||||
_tickets.clear()
|
||||
_internal_credential = None
|
||||
Reference in New Issue
Block a user