refactor(plugins/dashboard_auth): shared session/JWT/config helpers in _shared; compact basic/drain/nous/self_hosted

This commit is contained in:
Teknium
2026-09-02 11:23:21 -07:00
parent 191cc8d30a
commit 238260e6aa
7 changed files with 753 additions and 1569 deletions
+251
View File
@@ -0,0 +1,251 @@
"""Helpers shared by the bundled dashboard-auth providers.
Each provider module keeps its own ``logger`` / ``LAST_SKIP_REASON`` (the
gate reads those by module) and its ``register(ctx)``; the config/env
resolution, PKCE login start, token-endpoint exchange and JWT verification
boilerplate lives here.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import secrets
import urllib.parse
from typing import Any, Dict, Optional
import httpx
from hermes_cli.dashboard_auth import (
InvalidCodeError,
LoginStart,
ProviderError,
Session,
classify_jwks_lookup_error,
)
# JWKS Cache-Control max-age (nous contract C7); self-hosted mirrors it.
JWKS_CACHE_SECONDS = 300
TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0
JSON_HEADERS = {"Accept": "application/json"}
# ---- Config / env resolution ----
def load_config_section(logger: logging.Logger, tag: str, *path: str) -> dict:
"""Return the ``config.yaml`` block at ``path`` as a dict, or ``{}``.
Robust to load_config() raising (fresh install, malformed YAML), keys
being absent, or the value not being a dict — every shape yields ``{}``.
"""
try:
from hermes_cli.config import cfg_get, load_config
cfg = load_config()
except Exception as exc: # noqa: BLE001 — broad catch is intentional
logger.debug("%s: load_config() raised %s; falling back to env-only configuration", tag, exc)
return {}
section = cfg_get(cfg, *path, default=None)
return section if isinstance(section, dict) else {}
def resolve_env_or_cfg(env_name: str, cfg_value: Any) -> str:
"""Env-wins-over-config; an empty env value is treated as unset so a
provisioned-but-blank secret can't shadow a valid config.yaml entry."""
return os.environ.get(env_name, "").strip() or str(cfg_value or "").strip()
# ---- OAuth / PKCE ----
def b64url_no_pad(raw: bytes) -> str:
"""Base64url-encode without ``=`` padding (RFC 7636 §4)."""
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def validate_redirect_uri(redirect_uri: str) -> None:
"""Fast-fail obviously-broken redirect_uris before bouncing to the IDP.
The IDP's allowlist is authoritative. Any ``http://`` host is allowed so
dashboards behind TLS-terminating proxies / on LAN IPs aren't rejected.
"""
parsed = urllib.parse.urlparse(redirect_uri)
if parsed.scheme not in ("https", "http"):
raise ProviderError(f"redirect_uri must be http(s), got {redirect_uri!r}")
if not parsed.path or not parsed.path.endswith("/auth/callback"):
raise ProviderError(f"redirect_uri path must end with '/auth/callback', got {redirect_uri!r}")
def pkce_login_start(
authorize_url: str, *, client_id: str, scope: str, redirect_uri: str
) -> LoginStart:
"""Build the authorization-code + PKCE (S256) redirect and cookie payload.
Callers validate ``redirect_uri`` first. The auth-route layer expects
``cookie_payload["hermes_session_pkce"]`` as a flat ``state=…;verifier=…``
string (it prepends ``provider=``).
"""
code_verifier = b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
state = b64url_no_pad(secrets.token_bytes(32))
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"state": state,
"code_challenge": b64url_no_pad(hashlib.sha256(code_verifier.encode("ascii")).digest()),
"code_challenge_method": "S256",
}
return LoginStart(
redirect_url=f"{authorize_url}?{urllib.parse.urlencode(params)}",
cookie_payload={"hermes_session_pkce": f"state={state};verifier={code_verifier}"},
)
def parse_json_body(response: httpx.Response) -> Dict[str, Any]:
"""JSON object body, or ``{}`` for non-JSON content-type / parse error / non-dict."""
if not response.headers.get("content-type", "").startswith("application/json"):
return {}
try:
body = response.json()
except ValueError:
return {}
return body if isinstance(body, dict) else {}
def exchange_token(
url: str,
data: Dict[str, str],
*,
headers: Optional[Dict[str, str]] = None,
bad_request_exc: type[Exception],
idp: str,
endpoint: str,
token_key: str,
missing_msg: str,
) -> tuple[str, Dict[str, Any]]:
"""POST a token grant and return ``(token, payload)``.
A 400 (OAuth-shaped error envelope) raises ``bad_request_exc`` —
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError`` for
refresh — so the middleware's distinct handling is preserved. Any other
non-200, transport failure, missing ``token_key`` or non-bearer
``token_type`` raises ``ProviderError``. Redirects are deliberately NOT
followed: the body carries an auth code / refresh token.
"""
try:
response = httpx.post(
url, data=data, headers={**JSON_HEADERS, **(headers or {})}, timeout=TOKEN_ENDPOINT_TIMEOUT_SEC
)
except httpx.RequestError as exc:
raise ProviderError(f"{endpoint} unreachable: {exc}") from exc
if response.status_code == 400:
error_code = parse_json_body(response).get("error", "invalid_request")
raise bad_request_exc(f"{idp} rejected token request: {error_code}")
if response.status_code != 200:
raise ProviderError(f"{endpoint} returned {response.status_code}: {response.text[:200]!r}")
payload = parse_json_body(response)
token = payload.get(token_key)
if not token or not isinstance(token, str):
raise ProviderError(missing_msg)
token_type = str(payload.get("token_type", "")).lower()
if token_type and token_type != "bearer":
raise ProviderError(f"unexpected token_type={token_type!r}")
return token, payload
def refresh_token_from(payload: Dict[str, Any], fallback: str = "") -> str:
"""The token response's refresh token, or ``fallback`` when absent/non-string
(the session then behaves as access-token-only until expiry)."""
rt = payload.get("refresh_token")
return rt if isinstance(rt, str) and rt else fallback
def session_from_claims(
provider: str,
claims: Dict[str, Any],
*,
access_token: str,
refresh_token: str,
label: str = "token",
email: str = "",
display_name: str = "",
org_id: str = "",
) -> Session:
"""Map verified JWT claims onto a Session; ``sub`` is mandatory."""
user_id = str(claims.get("sub", ""))
if not user_id:
raise ProviderError(f"{label} missing 'sub' (user_id) claim")
return Session(
user_id=user_id,
email=email,
display_name=display_name,
org_id=org_id,
provider=provider,
expires_at=int(claims["exp"]),
access_token=access_token,
refresh_token=refresh_token,
)
# ---- JWT verification ----
def make_jwks_client(jwks_url: str) -> Any:
"""PyJWKClient with explicit Accept/User-Agent (some WAFs block the
library default). Imported lazily so plugin discovery stays cheap."""
from jwt import PyJWKClient
return PyJWKClient(
jwks_url, cache_keys=True, lifespan=JWKS_CACHE_SECONDS,
headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"},
)
def verify_jwt(
token: str,
jwks_client: Any,
*,
algorithms: list[str],
audience: str,
issuer: str,
label: str,
) -> Dict[str, Any]:
"""Verify ``token`` against ``jwks_client`` with pinned ``aud``/``iss``.
Unreachable JWKS → ``ProviderError`` (503); a bearer that is not one of
our JWTs (opaque peer key, foreign kid) → ``InvalidCodeError`` (None /
next provider); folding both into 503 broke peer-key bearers. Expiry raises
``InvalidCodeError`` (verify_session maps it to None); any other claim
failure raises ``ProviderError`` with the unverified iss/aud appended so
operators can spot config drift.
"""
import jwt # lazy — keeps startup fast for the ungated path
try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
except Exception as exc:
raise classify_jwks_lookup_error(exc) from exc
try:
return jwt.decode(
token, signing_key.key, algorithms=algorithms, audience=audience, issuer=issuer,
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
)
except jwt.ExpiredSignatureError as exc:
raise InvalidCodeError(f"{label} expired: {exc}") from exc
except jwt.InvalidTokenError as exc:
# Decoding without verification is safe here: verification already
# failed and these values are surfaced for diagnostics only, never trusted.
details = ""
try:
unverified = jwt.decode(token, options={"verify_signature": False, "verify_exp": False})
details = (
f" [token iss={unverified.get('iss')!r} aud={unverified.get('aud')!r}; "
f"expected iss={issuer!r} aud={audience!r}]"
)
except Exception:
pass
raise ProviderError(f"{label} verification failed: {exc}{details}") from exc
+111 -258
View File
@@ -1,58 +1,36 @@
"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP).
A self-hosted "just put a password on my dashboard" provider. It plugs
into the same ``DashboardAuthProvider`` framework as the Nous OAuth
provider, but authenticates with a username + password instead of an
OAuth redirect: it sets ``supports_password = True`` and implements
``complete_password_login``. The login page renders a credential form for
it; everything downstream of login (session cookies, verify, refresh,
ws-tickets, logout) is identical to the OAuth path because a password
session is just a :class:`Session` with provider-minted opaque tokens.
Same ``DashboardAuthProvider`` framework as the OAuth providers, but login is
a credential form (``supports_password = True`` + ``complete_password_login``);
everything downstream (cookies, verify, refresh, ws-tickets, logout) is shared.
No IDP and no database: sessions are stateless HMAC-signed tokens this provider
mints and verifies — zero infrastructure for a single-box dashboard.
This provider has **no external IDP and no database**. Credentials are
configured up front; sessions are stateless HMAC-signed tokens this
provider mints and verifies itself. That keeps it zero-infrastructure —
appropriate for a single-box self-hosted dashboard.
Configuration (env wins over config.yaml when set non-empty)::
Configuration surfaces (env wins over config.yaml when set non-empty),
mirroring the Nous provider's precedence convention:
dashboard:
basic_auth:
username: admin # required
password_hash: "scrypt$..." # preferred — see hash_password()
password: "s3cret" # OR plaintext, hashed in-memory at load
secret: "<32+ random bytes, base64 or hex>" # optional signing key
session_ttl_seconds: 43200 # optional access-token lifetime (12h)
``config.yaml`` — canonical surface::
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback
HERMES_DASHBOARD_BASIC_AUTH_SECRET
HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS
dashboard:
basic_auth:
username: admin # required
# Provide EITHER a precomputed scrypt hash (preferred — no
# plaintext at rest) ...
password_hash: "scrypt$..." # see hash_password()
# ... OR a plaintext password (hashed in-memory at load).
password: "s3cret"
secret: "<32+ random bytes, base64 or hex>" # optional; token-signing key
session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h)
Without ``secret`` a random per-process key is generated: sessions then don't
survive a restart or span multiple worker processes.
Environment overrides::
Passwords use stdlib :func:`hashlib.scrypt` (no third-party dependency).
``complete_password_login`` is constant-time and always performs a hash even
for an unknown username, so the endpoint is not a username-enumeration timing oracle.
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback
HERMES_DASHBOARD_BASIC_AUTH_SECRET
HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS
If ``secret`` is not configured, a random per-process secret is generated
at startup. That's fine for a single-process dashboard, but means all
sessions are invalidated on restart and sessions don't survive across
multiple worker processes — set an explicit ``secret`` for stable
multi-worker / restart-surviving sessions.
Password hashing uses stdlib :func:`hashlib.scrypt` (memory-hard, no
third-party dependency). ``complete_password_login`` runs a constant-time
comparison and always performs a hash even for an unknown username, so
the endpoint is not a username-enumeration timing oracle.
Skip reasons:
Like the Nous provider, this exposes a module-level ``LAST_SKIP_REASON``
the gate's fail-closed branch can surface when the plugin loads but
declines to register (no username/password configured).
``LAST_SKIP_REASON`` is read by the gate's fail-closed branch when the plugin
loads but declines to register.
"""
from __future__ import annotations
@@ -65,7 +43,7 @@ import logging
import os
import secrets
import time
from typing import Any, Optional
from typing import Optional
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
@@ -74,66 +52,44 @@ from hermes_cli.dashboard_auth import (
RefreshExpiredError,
Session,
)
from plugins.dashboard_auth._shared import load_config_section, resolve_env_or_cfg
logger = logging.getLogger(__name__)
# The middleware transparently refreshes via the 30-day refresh token when the
# access token lapses, so the TTL controls refresh frequency, not login length.
_DEFAULT_TTL_SECONDS = 12 * 60 * 60
_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
# Access-token lifetime. The middleware transparently refreshes via the
# refresh token (30-day) when the access token lapses, so this controls
# how often a refresh round trip happens, not how long the user stays
# logged in.
_DEFAULT_TTL_SECONDS = 12 * 60 * 60 # 12h
_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 # 30d
# scrypt parameters (RFC 7914 / stdlib hashlib.scrypt). n must be a power
# of two; these are the widely-recommended interactive-login parameters
# (~16 MiB, a few ms on commodity hardware).
# Interactive-login scrypt parameters (~16 MiB, a few ms); n must be a power of two.
_SCRYPT_N = 2**14
_SCRYPT_R = 8
_SCRYPT_P = 1
_SCRYPT_DKLEN = 32
_SCRYPT_SALT_BYTES = 16
# Length of the HMAC-SHA256 digest appended as a fixed-length suffix to
# signed tokens (no separator — binary HMAC bytes can't be confused with
# a delimiter).
# HMAC-SHA256 digest is appended to signed tokens as a fixed-length suffix
# (no separator — binary HMAC bytes can't be confused with a delimiter).
_SIG_LEN = hashlib.sha256().digest_size
LAST_SKIP_REASON: str = ""
# ---------------------------------------------------------------------------
# Password hashing (stdlib scrypt)
# ---------------------------------------------------------------------------
# ---- Password hashing (stdlib scrypt) ----
def hash_password(password: str) -> str:
"""Return a ``scrypt$n$r$p$<salt_b64>$<dk_b64>`` hash string.
Use this to precompute ``password_hash`` for config.yaml so plaintext
never sits at rest. Exposed as a module function so operators can run
``python -c "from plugins.dashboard_auth.basic import hash_password;
print(hash_password('pw'))"``.
Public so operators can precompute ``password_hash`` for config.yaml (the
plaintext then never sits at rest):
``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('pw'))"``.
"""
salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
dk = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=_SCRYPT_N,
r=_SCRYPT_R,
p=_SCRYPT_P,
dklen=_SCRYPT_DKLEN,
maxmem=0,
)
return (
f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
f"{base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
dklen=_SCRYPT_DKLEN, maxmem=0,
)
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
def _verify_password(password: str, encoded: str) -> bool:
@@ -149,29 +105,20 @@ def _verify_password(password: str, encoded: str) -> bool:
return False
try:
actual = hashlib.scrypt(
password.encode("utf-8"),
salt=salt,
n=n,
r=r,
p=p,
dklen=len(expected),
maxmem=0,
password.encode("utf-8"), salt=salt, n=n, r=r, p=p,
dklen=len(expected), maxmem=0,
)
except (ValueError, MemoryError):
return False
return hmac.compare_digest(actual, expected)
# A fixed dummy hash used to spend ~equal time when the username is
# unknown, so an attacker can't distinguish "no such user" (fast) from
# "wrong password" (slow scrypt) by timing. Computed once at import.
# Verified against when the username is unknown so "no such user" and "wrong
# password" take comparable time.
_DUMMY_HASH = hash_password("dummy-password-for-constant-time-verify")
# ---------------------------------------------------------------------------
# Token signing (stateless HMAC-signed blobs)
# ---------------------------------------------------------------------------
# ---- Token signing (stateless HMAC-signed blobs) ----
def _sign(payload: dict, secret: bytes) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode()
@@ -179,7 +126,9 @@ def _sign(payload: dict, secret: bytes) -> str:
return base64.urlsafe_b64encode(raw + sig).decode()
def _unsign(token: str, secret: bytes) -> Optional[dict]:
def _unsign(token: str, secret: bytes, kind: str) -> Optional[dict]:
"""Return the payload if the signature is valid, ``kind`` matches and it
is unexpired; ``None`` otherwise (including on any decode error)."""
try:
blob = base64.urlsafe_b64decode(token.encode())
if len(blob) <= _SIG_LEN:
@@ -188,15 +137,15 @@ def _unsign(token: str, secret: bytes) -> Optional[dict]:
expected = hmac.new(secret, raw, hashlib.sha256).digest()
if not hmac.compare_digest(sig, expected):
return None
return json.loads(raw)
payload = json.loads(raw)
except Exception:
return None
if payload.get("kind") != kind or payload.get("exp", 0) <= int(time.time()):
return None
return payload
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
# ---- Provider ----
class BasicAuthProvider(DashboardAuthProvider):
"""Username/password provider with stateless HMAC-signed sessions."""
@@ -228,27 +177,21 @@ class BasicAuthProvider(DashboardAuthProvider):
def start_login(self, *, redirect_uri: str) -> LoginStart:
raise NotImplementedError(
"BasicAuthProvider is password-only; there is no OAuth redirect "
"flow. The login page POSTs to /auth/password-login instead."
"BasicAuthProvider is password-only; there is no OAuth redirect flow. "
"The login page POSTs to /auth/password-login instead."
)
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
) -> Session:
raise NotImplementedError(
"BasicAuthProvider is password-only; use complete_password_login."
)
raise NotImplementedError("BasicAuthProvider is password-only; use complete_password_login.")
# ---- password login ----------------------------------------------------
def complete_password_login(
self, *, username: str, password: str
) -> Session:
# Constant-time-ish: always run a scrypt verify (against the real
# hash if the username matches, else a dummy hash) so an unknown
# username and a wrong password take comparable time. Compare the
# username with compare_digest too, to avoid a length/byte timing
# leak on the username itself.
def complete_password_login(self, *, username: str, password: str) -> Session:
# Always run a scrypt verify (real hash if the username matches, else
# the dummy) and compare the username with compare_digest too, so
# neither the username nor its length leaks via timing.
username_ok = hmac.compare_digest(
username.encode("utf-8"), self._username.encode("utf-8")
)
@@ -261,31 +204,23 @@ class BasicAuthProvider(DashboardAuthProvider):
# ---- session lifecycle -------------------------------------------------
def verify_session(self, *, access_token: str) -> Optional[Session]:
payload = _unsign(access_token, self._secret)
if (
payload is None
or payload.get("kind") != "access"
or payload.get("exp", 0) <= int(time.time())
):
payload = _unsign(access_token, self._secret, "access")
if payload is None:
return None
return self._session_from_payload(access_token, "", payload)
user_id = str(payload.get("sub", ""))
return self._session(user_id, int(payload["exp"]), access_token, "")
def refresh_session(self, *, refresh_token: str) -> Session:
if not refresh_token:
raise RefreshExpiredError("no refresh token present in session")
payload = _unsign(refresh_token, self._secret)
if (
payload is None
or payload.get("kind") != "refresh"
or payload.get("exp", 0) <= int(time.time())
):
payload = _unsign(refresh_token, self._secret, "refresh")
if payload is None:
raise RefreshExpiredError("refresh token expired or invalid")
return self._mint_session(str(payload.get("sub", self._username)))
def revoke_session(self, *, refresh_token: str) -> None:
# Stateless tokens — nothing to revoke server-side. The session
# expires within its TTL. Best-effort no-op, must not raise.
_ = refresh_token
# Stateless tokens — nothing to revoke server-side; the session
# expires within its TTL. Must not raise.
return None
# ---- internals ---------------------------------------------------------
@@ -293,94 +228,40 @@ class BasicAuthProvider(DashboardAuthProvider):
def _mint_session(self, user_id: str) -> Session:
now = int(time.time())
exp = now + self._ttl
access_token = _sign(
{"sub": user_id, "kind": "access", "exp": exp}, self._secret
)
refresh_token = _sign(
{"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS},
self._secret,
return self._session(
user_id, exp,
_sign({"sub": user_id, "kind": "access", "exp": exp}, self._secret),
_sign({"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS}, self._secret),
)
def _session(self, user_id: str, exp: int, access_token: str, refresh_token: str) -> Session:
return Session(
user_id=user_id,
email="",
display_name=user_id,
org_id="",
provider=self.name,
expires_at=exp,
access_token=access_token,
refresh_token=refresh_token,
)
def _session_from_payload(
self, access_token: str, refresh_token: str, payload: dict
) -> Session:
user_id = str(payload.get("sub", ""))
return Session(
user_id=user_id,
email="",
display_name=user_id,
org_id="",
provider=self.name,
expires_at=int(payload["exp"]),
access_token=access_token,
refresh_token=refresh_token,
user_id=user_id, email="", display_name=user_id, org_id="", provider=self.name,
expires_at=exp, access_token=access_token, refresh_token=refresh_token,
)
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
# ---- Plugin entry point ----
def _load_config_basic_auth_section() -> dict:
"""Return ``dashboard.basic_auth`` from config.yaml, or ``{}``.
Robust to load_config() raising, the keys being absent, or the value
not being a dict — every shape falls through to ``{}``.
"""
try:
from hermes_cli.config import cfg_get, load_config
cfg = load_config()
except Exception as exc: # noqa: BLE001 — broad catch is intentional
logger.debug(
"dashboard-auth-basic: load_config() raised %s; "
"falling back to env-only configuration",
exc,
)
return {}
section = cfg_get(cfg, "dashboard", "basic_auth", default=None)
return section if isinstance(section, dict) else {}
def _resolve(env_name: str, cfg_section: dict, cfg_key: str) -> str:
"""Env-wins-over-config resolution; empty env treated as unset."""
env = os.environ.get(env_name, "").strip()
if env:
return env
return str(cfg_section.get(cfg_key, "") or "").strip()
return load_config_section(logger, "dashboard-auth-basic", "dashboard", "basic_auth")
def _resolve_secret(cfg_section: dict) -> bytes:
"""Resolve the token-signing secret.
"""Resolve the token-signing secret (base64, hex, or raw text).
Accepts base64 or hex or raw text from config/env. When unset,
generates a random per-process secret (sessions then don't survive a
restart or span multiple workers — logged at INFO).
When unset, generates a random per-process secret (sessions then don't
survive a restart or span multiple workers — logged at INFO).
"""
raw = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section, "secret"
)
raw = resolve_env_or_cfg("HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section.get("secret"))
if not raw:
logger.info(
"dashboard-auth-basic: no 'secret' configured; generating a "
"random per-process signing key. Sessions will not survive a "
"restart or span multiple workers. Set dashboard.basic_auth."
"secret (or HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable "
"sessions."
"dashboard-auth-basic: no 'secret' configured; generating a random "
"per-process signing key. Sessions will not survive a restart or span "
"multiple workers. Set dashboard.basic_auth.secret (or "
"HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable sessions."
)
return secrets.token_bytes(32)
# Try base64, then hex, then fall back to the raw UTF-8 bytes.
for decoder in (base64.b64decode, bytes.fromhex):
try:
decoded = decoder(raw)
@@ -392,64 +273,44 @@ def _resolve_secret(cfg_section: dict) -> bytes:
def register(ctx) -> None:
"""Plugin entry — registers BasicAuthProvider when credentials exist.
Loopback / ``--insecure`` operators and anyone using the OAuth
provider leave ``dashboard.basic_auth`` unset, so this plugin is a
no-op for them. When username + (password or password_hash) are
configured, it registers a password provider that the login page
renders as a credential form.
"""
"""Register ``BasicAuthProvider`` when username + (password or
password_hash) are configured; a no-op for OAuth / ``--insecure`` setups."""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
section = _load_config_basic_auth_section()
username = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME", section, "username"
)
password_hash = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", section, "password_hash"
)
plaintext = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", section, "password"
)
ttl_raw = _resolve(
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", section, "session_ttl_seconds"
)
def setting(env_name: str, cfg_key: str) -> str:
return resolve_env_or_cfg(env_name, section.get(cfg_key, ""))
username = setting("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "username")
password_hash = setting("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", "password_hash")
plaintext = setting("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "password")
ttl_raw = setting("HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", "session_ttl_seconds")
if not username:
LAST_SKIP_REASON = (
"dashboard.basic_auth.username is not set (and "
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME is empty). Set a username "
"and a password (or password_hash) under dashboard.basic_auth in "
"config.yaml to enable username/password dashboard login, or use "
"the OAuth provider, or pass --insecure to skip the auth gate."
"dashboard.basic_auth.username is not set (and HERMES_DASHBOARD_BASIC_AUTH_USERNAME "
"is empty). Set a username and a password (or password_hash) under "
"dashboard.basic_auth in config.yaml to enable username/password dashboard "
"login, or use the OAuth provider, or pass --insecure to skip the auth gate."
)
logger.debug("dashboard-auth-basic: %s", LAST_SKIP_REASON)
return
if not password_hash and not plaintext:
LAST_SKIP_REASON = (
"dashboard.basic_auth.username is set but neither password_hash "
"nor password is configured. Provide one of them (password_hash "
"is preferred — compute it with "
"plugins.dashboard_auth.basic.hash_password)."
"dashboard.basic_auth.username is set but neither password_hash nor password "
"is configured. Provide one of them (password_hash is preferred — compute it "
"with plugins.dashboard_auth.basic.hash_password)."
)
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
return
# Precedence (env-wins convention): a password supplied via the
# HERMES_DASHBOARD_BASIC_AUTH_PASSWORD env var overrides a config.yaml
# password_hash, so an operator can rotate the password by setting an
# env var without editing config. A password_hash (precomputed) wins
# over a config-only plaintext password at the same tier — it's the
# preferred at-rest form. Concretely:
# * env password set → hash it (overrides any config hash)
# * else config password_hash set → use it
# * else config plaintext password → hash it in-memory
plaintext_from_env = os.environ.get(
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", ""
).strip()
# Precedence: env password (hashed in-memory) overrides any config
# password_hash so operators can rotate without editing config; a config
# password_hash wins over a config-only plaintext password (preferred at-rest form).
plaintext_from_env = os.environ.get("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "").strip()
if plaintext_from_env:
password_hash = hash_password(plaintext_from_env)
logger.info(
@@ -457,7 +318,6 @@ def register(ctx) -> None:
"(overrides any config password_hash)."
)
elif not password_hash:
# config-only plaintext password.
password_hash = hash_password(plaintext)
logger.info(
"dashboard-auth-basic: hashed plaintext password in-memory. "
@@ -465,8 +325,6 @@ def register(ctx) -> None:
"and remove the plaintext password from config."
)
secret = _resolve_secret(section)
try:
ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
except ValueError:
@@ -474,10 +332,8 @@ def register(ctx) -> None:
try:
provider = BasicAuthProvider(
username=username,
password_hash=password_hash,
secret=secret,
ttl_seconds=ttl,
username=username, password_hash=password_hash,
secret=_resolve_secret(section), ttl_seconds=ttl,
)
except ValueError as exc:
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
@@ -485,7 +341,4 @@ def register(ctx) -> None:
return
ctx.register_dashboard_auth_provider(provider)
logger.info(
"dashboard-auth-basic: registered password provider (username=%s)",
username,
)
logger.info("dashboard-auth-basic: registered password provider (username=%s)", username)
+54 -127
View File
@@ -1,55 +1,33 @@
"""DrainSecretProvider — shared-bearer-secret auth for the drain-control endpoint.
Task 2.0b of the safe-shutdown plan, and the FIRST consumer of the generic
non-interactive token-auth capability added in Task 2.0a
(``supports_token`` / ``verify_token`` on the ``DashboardAuthProvider`` ABC +
the route-agnostic ``token_auth`` middleware seam).
Uses the non-interactive token capability of the ``DashboardAuthProvider``
ABC (``supports_token`` / ``verify_token`` + the route-agnostic ``token_auth``
middleware seam). ``nous-account-service`` (NAS) provisions a **per-agent
unique** shared secret into each deployed agent's environment; this provider
verifies an inbound ``Authorization`` bearer against it with a constant-time
compare and vouches for the caller as the ``drain-control`` principal. No
login, cookie, session or refresh — the interactive ABC methods raise
``NotImplementedError``. A real auth plugin (not an ad-hoc header check on the
route) so the credential lives inside the dashboard auth framework.
What it is
----------
A service-to-service auth provider. ``nous-account-service`` (NAS) provisions a
**per-agent unique** shared secret into each deployed agent's environment; this
provider verifies an inbound ``Authorization`` bearer token against that secret
with a constant-time compare and, on a match, vouches for the caller as the
``drain-control`` principal. It is NOT an interactive identity provider — there
is no login, cookie, session, or refresh. It implements ONLY the token
capability (``supports_token = True`` + ``verify_token``); the five interactive
ABC methods raise ``NotImplementedError``.
Security properties:
* Per-agent unique secret — a leak's blast radius is one agent.
* Fail-CLOSED entropy gate at registration: a weak/short/low-entropy secret is
never silently accepted (>= 43 url-safe-base64 chars ~= 256 bits, enough
distinct characters, Shannon entropy floor).
* ``hmac.compare_digest`` on the request path — not a timing oracle.
Why a plugin (not an ad-hoc header check on the drain route)
------------------------------------------------------------
Decisions.md Q-A: the drain credential MUST be a real auth plugin in the
dashboard auth framework, not a bolt-on. Q-C: the framework widening that
hosts it is generic (Task 2.0a) and this plugin is merely its first consumer.
Configuration — the secret is a CREDENTIAL, so it is env-only (provisioned by
NAS at deploy time); behavioural knobs live in config.yaml::
Security properties (decisions.md Q-A)
--------------------------------------
* **Per-agent unique secret** — each agent gets a distinct secret; a leak's
blast radius is one agent.
* **Entropy gate at registration** — a weak/short/low-entropy secret fails
CLOSED at load (the plugin declines to register and records a skip reason);
it is never silently accepted. Bar: >= 256 bits of entropy / >= 43
url-safe-base64 chars, and the value must not be obviously structured
(all-one-character, too few distinct characters).
* **Constant-time compare** — ``hmac.compare_digest`` on the request path, so
the endpoint is not a timing oracle.
Configuration
-------------
The secret is a CREDENTIAL, so it is carried via an env var (the ``.env``-is-
for-secrets-only rule), provisioned by NAS at deploy time (Phase 3):
HERMES_DASHBOARD_DRAIN_SECRET # the per-agent shared secret (>=43 url-safe-b64 chars)
Behavioural knobs live in config.yaml (canonical surface):
HERMES_DASHBOARD_DRAIN_SECRET # per-agent shared secret (>=43 url-safe-b64 chars)
dashboard:
drain_auth:
scope: drain # capability label attached to the principal
min_secret_chars: 43 # entropy bar (optional; default 43 ~= 256 bits)
min_secret_chars: 43 # entropy bar (default 43 ~= 256 bits)
When ``HERMES_DASHBOARD_DRAIN_SECRET`` is unset, the plugin is a no-op (records
a skip reason) — agents that don't want NAS-driven drain just don't set it.
When the env var is unset the plugin is a no-op (records a skip reason).
"""
from __future__ import annotations
@@ -66,53 +44,40 @@ from hermes_cli.dashboard_auth import (
Session,
TokenPrincipal,
)
from plugins.dashboard_auth._shared import load_config_section
logger = logging.getLogger(__name__)
# Default entropy bar: 43 url-safe-base64 chars ~= 256 bits. token_urlsafe(32)
# produces 43 chars, so a correctly-provisioned secret clears this exactly.
# token_urlsafe(32) produces exactly 43 chars, so a correctly-provisioned
# secret clears the default bar exactly.
_DEFAULT_MIN_SECRET_CHARS = 43
# A secret must contain at least this many DISTINCT characters — rejects
# degenerate values like "aaaa..." that are long but trivially low-entropy.
# Rejects degenerate values like "aaaa..." that are long but trivially low-entropy.
_MIN_DISTINCT_CHARS = 16
# Shannon entropy floor (bits) over the secret's characters — a second,
# distribution-aware guard on top of the length + distinct-count checks.
# Distribution-aware second guard on top of length + distinct-count.
_MIN_SHANNON_BITS = 128.0
# The path the begin/cancel-drain endpoint lives on. Registered as a
# token-authable route by ``register()`` so the generic seam guards it. Kept
# here (not imported from web_server) to avoid a heavy import at plugin load.
# Kept here (not imported from web_server) to avoid a heavy import at plugin load.
DRAIN_ROUTE_PATH = "/api/gateway/drain"
LAST_SKIP_REASON: str = ""
def _shannon_bits(value: str) -> float:
"""Total Shannon entropy (bits) of ``value`` over its character distribution.
H = len * sum(-p_i * log2(p_i)). A long string drawn from a wide alphabet
scores high; a long run of one character scores ~0.
"""
"""Total Shannon entropy (bits) of ``value`` over its character distribution."""
if not value:
return 0.0
counts = Counter(value)
n = len(value)
per_char = -sum((c / n) * math.log2(c / n) for c in counts.values())
per_char = -sum((c / n) * math.log2(c / n) for c in Counter(value).values())
return per_char * n
def assess_secret_strength(
secret: str, *, min_chars: int = _DEFAULT_MIN_SECRET_CHARS
) -> Optional[str]:
"""Return a rejection reason if ``secret`` is too weak, else ``None``.
"""Return a human-readable rejection reason if ``secret`` is too weak, else ``None``.
Fail-closed entropy gate (decisions.md Q-A). Checks, in order:
* length >= ``min_chars`` (default 43 url-safe-b64 chars ~= 256 bits),
* at least ``_MIN_DISTINCT_CHARS`` distinct characters,
* Shannon entropy >= ``_MIN_SHANNON_BITS`` bits.
A ``None`` return means the secret passes. Any string return is a
human-readable reason the caller logs + records as the skip reason.
Checks, in order: length >= ``min_chars``, distinct chars >=
``_MIN_DISTINCT_CHARS``, Shannon entropy >= ``_MIN_SHANNON_BITS``.
"""
if not secret:
return "secret is empty"
@@ -146,9 +111,8 @@ class DrainSecretProvider(DashboardAuthProvider):
supports_session = False
def __init__(self, *, secret: str, scope: str = "drain") -> None:
# Defence in depth: construction also enforces the entropy bar, so a
# caller that bypasses register()'s check still can't build a weak
# provider. register() does the friendly skip-reason path; this raises.
# Defence in depth: construction enforces the entropy bar too, so a
# caller bypassing register() still can't build a weak provider.
reason = assess_secret_strength(secret)
if reason is not None:
raise ValueError(f"drain secret rejected: {reason}")
@@ -158,80 +122,48 @@ class DrainSecretProvider(DashboardAuthProvider):
# ---- token capability (the only thing this provider implements) --------
def verify_token(self, *, token: str) -> Optional[TokenPrincipal]:
"""Constant-time compare against the per-agent shared secret.
Returns a ``drain-control`` principal on an exact match, else ``None``
(the generic seam falls through / fails closed). Uses
``hmac.compare_digest`` so a wrong token can't be recovered by timing.
"""
if not token:
return None
if hmac.compare_digest(token.encode("utf-8"), self._secret.encode("utf-8")):
return TokenPrincipal(
principal="drain-control",
provider=self.name,
scopes=(self._scope,),
)
"""Constant-time compare; ``drain-control`` principal on match, else
``None`` so the generic seam falls through / fails closed."""
if token and hmac.compare_digest(token.encode("utf-8"), self._secret.encode("utf-8")):
return TokenPrincipal(principal="drain-control", provider=self.name, scopes=(self._scope,))
return None
# ---- interactive methods: unsupported (service credential only) --------
def start_login(self, *, redirect_uri: str) -> LoginStart:
raise NotImplementedError(
"DrainSecretProvider is a non-interactive service credential; "
"there is no login flow."
"DrainSecretProvider is a non-interactive service credential; there is no login flow."
)
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
) -> Session:
raise NotImplementedError(
"DrainSecretProvider is a non-interactive service credential."
)
raise NotImplementedError("DrainSecretProvider is a non-interactive service credential.")
def verify_session(self, *, access_token: str) -> Optional[Session]:
# Not a cookie-session provider — it never mints a Session, so it can
# never recognise a session cookie. Return None (don't raise) so it
# stacks harmlessly in the cookie-verify loop.
# Never mints a Session, so never recognises a cookie. Return None
# (don't raise) so it stacks harmlessly in the cookie-verify loop.
return None
def refresh_session(self, *, refresh_token: str) -> Session:
raise NotImplementedError(
"DrainSecretProvider is a non-interactive service credential."
)
raise NotImplementedError("DrainSecretProvider is a non-interactive service credential.")
def revoke_session(self, *, refresh_token: str) -> None:
return None
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
# ---- Plugin entry point ----
def _load_config_drain_auth_section() -> dict:
"""Return ``dashboard.drain_auth`` from config.yaml, or ``{}``."""
try:
from hermes_cli.config import cfg_get, load_config
cfg = load_config()
except Exception as exc: # noqa: BLE001 — broad catch is intentional
logger.debug(
"dashboard-auth-drain: load_config() raised %s; "
"falling back to env-only configuration",
exc,
)
return {}
section = cfg_get(cfg, "dashboard", "drain_auth", default=None)
return section if isinstance(section, dict) else {}
return load_config_section(logger, "dashboard-auth-drain", "dashboard", "drain_auth")
def register(ctx) -> None:
"""Plugin entry — registers DrainSecretProvider when a strong secret is set.
"""Register ``DrainSecretProvider`` when a strong secret is set.
No-op (records a skip reason) when ``HERMES_DASHBOARD_DRAIN_SECRET`` is
unset or fails the entropy gate. On success, also registers the
begin/cancel-drain route as token-authable via the generic seam.
unset or fails the entropy gate. On success also registers the drain
route as token-authable via the generic seam.
"""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
@@ -239,10 +171,9 @@ def register(ctx) -> None:
secret = os.environ.get("HERMES_DASHBOARD_DRAIN_SECRET", "").strip()
if not secret:
LAST_SKIP_REASON = (
"HERMES_DASHBOARD_DRAIN_SECRET is not set. Set a per-agent "
">=256-bit secret (e.g. `python -c \"import secrets; "
"print(secrets.token_urlsafe(32))\"`) to enable NAS-driven drain "
"coordination; leave it unset to disable the drain endpoint."
"HERMES_DASHBOARD_DRAIN_SECRET is not set. Set a per-agent >=256-bit secret "
"(e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`) to enable "
"NAS-driven drain coordination; leave it unset to disable the drain endpoint."
)
logger.debug("dashboard-auth-drain: %s", LAST_SKIP_REASON)
return
@@ -272,20 +203,16 @@ def register(ctx) -> None:
ctx.register_dashboard_auth_provider(provider)
# Opt the begin/cancel-drain endpoint into the generic token-auth seam so
# the dashboard's interactive cookie gate doesn't bounce NAS's bearer call.
# Opt the drain endpoint into the token-auth seam so the interactive
# cookie gate doesn't bounce NAS's bearer call.
try:
from hermes_cli.dashboard_auth.token_auth import register_token_route
register_token_route(DRAIN_ROUTE_PATH)
except Exception as exc: # noqa: BLE001 — seam import must not crash plugin load
logger.warning(
"dashboard-auth-drain: could not register token route %s: %s",
DRAIN_ROUTE_PATH, exc,
)
logger.warning("dashboard-auth-drain: could not register token route %s: %s", DRAIN_ROUTE_PATH, exc)
logger.info(
"dashboard-auth-drain: registered drain service-credential provider "
"(scope=%s, route=%s)",
"dashboard-auth-drain: registered drain service-credential provider (scope=%s, route=%s)",
scope, DRAIN_ROUTE_PATH,
)
+123 -547
View File
@@ -1,154 +1,70 @@
"""NousDashboardAuthProvider — Nous Portal OAuth (authorization-code + PKCE).
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``
(PR #180). The plugin auto-loads (bundled, kind=backend) but only registers
its provider when a client_id is configured — either via ``config.yaml`` or
via the Portal-injected env var — so loopback / ``--insecure`` operators
are unaffected.
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``.
Bundled and auto-loaded, but only registers when a client_id is configured, so
loopback / ``--insecure`` operators are unaffected.
Configuration surfaces (env wins over config.yaml when set non-empty):
Configuration (env wins over config.yaml when set non-empty; an empty env value
is treated as unset so a provisioned-but-blank Fly secret can't shadow config)::
``config.yaml`` — canonical surface::
dashboard:
oauth:
client_id: agent:{agent_instance_id} # required
portal_url: https://portal.example # optional
dashboard:
oauth:
client_id: agent:{agent_instance_id} # required
portal_url: https://portal.example # optional
HERMES_DASHBOARD_OAUTH_CLIENT_ID — shape ``agent:{agent_instance_id}``
HERMES_DASHBOARD_PORTAL_URL — default https://portal.nousresearch.com
Environment overrides — used by Fly.io's platform-secret injection so
per-deploy values don't need to bake into ``config.yaml``:
Contract points encoded here:
- client_id is per-instance; its suffix is cross-checked against the token's
``agent_instance_id`` claim (defense-in-depth).
- scope is ``agent_dashboard:access`` only; audience is the bare client_id.
- access tokens are RS256 JWTs verified against ``/.well-known/jwks.json``
(JWKS cached 5 minutes).
- Portal issues a 24h *rotating* refresh token with reuse detection: the
middleware MUST persist ``Session.refresh_token`` back to the cookie on
every refresh, or the next refresh replays a rotated token and (outside a
60s grace) trips reuse-detection, revoking the whole session. A 400 on
refresh (expired / revoked / reuse-detected) → ``RefreshExpiredError`` →
re-login.
- ``oauth_contract_version``: missing → warn + proceed; ``!= 1`` → refuse.
HERMES_DASHBOARD_OAUTH_CLIENT_ID — shape ``agent:{agent_instance_id}``
HERMES_DASHBOARD_PORTAL_URL — defaults to
``https://portal.nousresearch.com``
(production Portal). Override only
for staging (``portal.rewbs.uk``)
or a custom deployment.
Empty env var values are treated as unset so a provisioned-but-not-populated
Fly secret can't shadow a valid config.yaml entry.
Key contract points encoded here:
- client_id is per-instance (``agent:{instance_id}``); the suffix is also
cross-checked against the token's ``agent_instance_id`` claim as
defense-in-depth.
- scope is ``agent_dashboard:access`` only (no OIDC scopes).
- tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
JWKS is cached for 5 minutes.
- the dashboard auth-code grant issues a 24h rotating refresh token
(Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
to rotate the access token; ``complete_login`` and ``refresh_session``
both populate ``Session.refresh_token`` with the (rotating) value the
middleware persists back to the HttpOnly cookie. On a dead/expired/
reuse-detected refresh token Portal returns 400 → ``RefreshExpiredError``
→ middleware redirects to ``/auth/login``.
- audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
- tolerant ``oauth_contract_version`` check: missing → warn + proceed;
present and ``!= 1`` → refuse.
The cookie payload returned by ``start_login`` stashes the PKCE
``code_verifier`` and the OAuth ``state`` parameter for the
``/auth/callback`` handler to retrieve. The auth-route layer is the owner
of cookie names; this provider just hands back ``{"code_verifier": …,
"state": …}`` and the route serializes those into the ``hermes_session_pkce``
cookie.
Refresh-token rotation: Portal rotates the refresh token on every
successful refresh and runs reuse-detection (replaying a rotated token
outside Portal's 60s grace revokes the whole session). The host
middleware therefore MUST persist the rotated ``Session.refresh_token``
back to the cookie on every refresh.
Skip reasons:
The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
fail-closed branch reads to surface a useful operator error message
("Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …") instead of the bare "no
providers registered" the gate would otherwise emit.
``LAST_SKIP_REASON`` is read by the gate's fail-closed branch to surface a
specific operator message when the plugin loads but declines to register.
"""
from __future__ import annotations
import base64
import hashlib
import logging
import os
import secrets
import urllib.parse
from typing import Any, Dict, Optional
import httpx
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
InvalidCodeError,
LoginStart,
ProviderError,
RefreshExpiredError,
classify_jwks_lookup_error,
Session,
)
from plugins.dashboard_auth._shared import (
exchange_token,
load_config_section,
make_jwks_client,
pkce_login_start,
refresh_token_from,
resolve_env_or_cfg,
session_from_claims,
validate_redirect_uri,
verify_jwt,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
# Production Portal URL. Override via HERMES_DASHBOARD_PORTAL_URL for
# staging (portal.rewbs.uk) or a custom deployment. Contract docs name
# this as the production issuer.
_DEFAULT_PORTAL_URL = "https://portal.nousresearch.com"
_SCOPE = "agent_dashboard:access" # contract C3
_EXPECTED_CONTRACT_VERSION = 1 # contract C11
# ---------------------------------------------------------------------------
# Skip-reason channel for operator-friendly error messages
# ---------------------------------------------------------------------------
#
# When the plugin loads but refuses to register (missing / malformed
# env vars), the auth gate downstream just sees "zero providers" and
# emits a generic "install a provider" error. That's misleading for the
# common case where the provider IS installed but mis-configured. The
# plugin writes the *specific* reason to this module-level slot; the
# gate reads it back when building its fail-closed SystemExit message.
#
# Cleared on every register() call so repeated dashboard starts in the
# same process (tests, hot-reload) don't leak stale reasons.
LAST_SKIP_REASON: str = ""
# ---------------------------------------------------------------------------
# Contract constants
# ---------------------------------------------------------------------------
# Contract C3: scope name for the dashboard flow.
_SCOPE = "agent_dashboard:access"
# Contract C11: emitted claim should equal 1; tolerant (warn) if missing.
_EXPECTED_CONTRACT_VERSION = 1
# Contract C7: JWKS Cache-Control max-age=300.
_JWKS_CACHE_SECONDS = 300
# httpx timeout for the token endpoint POST.
_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _b64url_no_pad(raw: bytes) -> str:
"""Base64url-encode without ``=`` padding (RFC 7636 §4)."""
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
LAST_SKIP_REASON: str = "" # cleared on every register() so restarts don't leak stale reasons
class NousDashboardAuthProvider(DashboardAuthProvider):
@@ -158,350 +74,105 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
display_name = "Nous Research"
def __init__(self, *, client_id: str, portal_url: str) -> None:
# Defense-in-depth: register() filters too, but a malformed id must
# never construct a provider.
if not client_id.startswith("agent:"):
# Defense-in-depth. The plugin entry point already filters, but
# the provider should never be constructible with a malformed id.
raise ValueError(
"client_id must match contract shape 'agent:{instance_id}', "
f"got {client_id!r}"
)
raise ValueError(f"client_id must match contract shape 'agent:{{instance_id}}', got {client_id!r}")
self._client_id = client_id
self._agent_instance_id = client_id[len("agent:") :]
self._portal_url = portal_url.rstrip("/")
self._jwks_url = f"{self._portal_url}/.well-known/jwks.json"
self._authorize_url = f"{self._portal_url}/oauth/authorize"
self._token_url = f"{self._portal_url}/api/oauth/token"
# PyJWKClient is lazily imported so plugin discovery doesn't pay the
# crypto-import cost when the provider isn't activated.
self._jwks_client: Any = None
self._jwks_client: Any = None # lazily built (crypto import cost)
# ---- public API (DashboardAuthProvider) -------------------------------
def start_login(self, *, redirect_uri: str) -> LoginStart:
self._validate_redirect_uri(redirect_uri)
code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
code_challenge = _b64url_no_pad(
hashlib.sha256(code_verifier.encode("ascii")).digest()
validate_redirect_uri(redirect_uri)
return pkce_login_start(
self._authorize_url, client_id=self._client_id, scope=_SCOPE, redirect_uri=redirect_uri
)
state = _b64url_no_pad(secrets.token_bytes(32))
params = {
"response_type": "code",
"client_id": self._client_id,
"redirect_uri": redirect_uri,
"scope": _SCOPE,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
redirect_url = f"{self._authorize_url}?{urllib.parse.urlencode(params)}"
# The auth-route layer expects ``cookie_payload[\"hermes_session_pkce\"]``
# as a single semicolon-delimited string of ``key=value`` segments,
# matching the stub provider's shape. The route handler prepends
# ``provider=`` so the callback knows which plugin to dispatch to.
cookie_payload = {
"hermes_session_pkce": f"state={state};verifier={code_verifier}",
}
return LoginStart(redirect_url=redirect_url, cookie_payload=cookie_payload)
def complete_login(
self,
*,
code: str,
state: str,
code_verifier: str,
redirect_uri: str,
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
) -> Session:
# ``state`` is verified by the auth-route layer before this call
# (it checks the cookie-stashed state matches the query-param state);
# we just receive it for symmetry with the protocol. Nous Portal
# doesn't re-check state at the token endpoint, so we ignore it here.
_ = state
try:
response = httpx.post(
self._token_url,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._client_id,
"code_verifier": code_verifier,
},
headers={"Accept": "application/json"},
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except httpx.RequestError as exc:
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
# The dashboard auth-code grant now issues a rotating refresh token
# (24h session, reuse-detected) — Portal NAS PR #293. A 400 here means
# the code/PKCE/redirect_uri failed, surfaced as InvalidCodeError.
return self._token_response_to_session(
response, bad_request_exc=InvalidCodeError
# ``state`` is verified by the auth-route layer; Portal doesn't
# re-check it at the token endpoint.
return self._token_grant(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._client_id,
"code_verifier": code_verifier,
},
bad_request_exc=InvalidCodeError,
)
def refresh_session(self, *, refresh_token: str) -> Session:
"""Rotate the access token using the refresh token.
Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
refresh token is sent in the ``X-Refresh-Token`` header (not the body)
so it never lands in Portal's request-body access logs — mirroring the
device-flow CLI convention; Portal reconciles header vs. body and
rejects conflicts.
Portal rotates the refresh token on every successful refresh, so the
returned ``Session.refresh_token`` is a NEW value the caller MUST
persist (replacing the old cookie). Failing to persist it means the
next refresh replays a rotated token and — outside Portal's 60s grace
— trips reuse-detection and revokes the whole session.
Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
detected), so the middleware clears cookies and forces re-login.
Raises ``ProviderError`` if Portal is unreachable.
"""
if not refresh_token:
# No RT to present — treat as a dead session so middleware
# forces a clean re-login rather than emitting a malformed POST.
raise RefreshExpiredError("no refresh token present in session")
try:
response = httpx.post(
self._token_url,
# The refresh token goes in BOTH the body and the
# ``x-nous-refresh-token`` header. Portal's token endpoint
# requires ``refresh_token`` in the body (its request schema
# rejects a header-only request as ``invalid_request``), and
# additionally reconciles the header against the body — sending
# both lets Portal keep the value out of body-access-logs while
# still satisfying the schema. The header name must match
# Portal's ``REFRESH_TOKEN_HEADER`` exactly (``x-nous-refresh-
# token``); any other name is silently ignored. (Verified
# against the NAS #293 preview deploy: header-only → 400
# invalid_request; body → accepted.)
data={
"grant_type": "refresh_token",
"client_id": self._client_id,
"refresh_token": refresh_token,
},
headers={
"Accept": "application/json",
"x-nous-refresh-token": refresh_token,
},
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except httpx.RequestError as exc:
raise ProviderError(
f"Portal token endpoint unreachable: {exc}"
) from exc
# A 400 on refresh means the RT is expired / revoked / reuse-detected;
# surface as RefreshExpiredError so middleware forces re-login.
return self._token_response_to_session(
response, bad_request_exc=RefreshExpiredError
# The RT goes in BOTH the body (Portal's request schema requires it)
# and the ``x-nous-refresh-token`` header (Portal reconciles the two
# and keeps the value out of body access logs). Header-only → 400.
return self._token_grant(
{
"grant_type": "refresh_token",
"client_id": self._client_id,
"refresh_token": refresh_token,
},
headers={"x-nous-refresh-token": refresh_token},
bad_request_exc=RefreshExpiredError,
)
def _token_response_to_session(
self,
response: httpx.Response,
*,
bad_request_exc: type[Exception],
) -> Session:
"""Translate a Portal ``/api/oauth/token`` response into a Session.
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
(refresh grant). ``bad_request_exc`` is the exception type raised on a
400 — ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
for the refresh path — so the middleware's distinct handling
(400-on-callback vs. force-relogin) is preserved.
"""
if response.status_code == 400:
# Contract: invalid_code / invalid_grant / redirect_uri_mismatch
# (auth-code) and expired / revoked / reuse-detected (refresh) all
# surface as 400 with an OAuth-shaped JSON error envelope.
body = self._parse_json_body(response)
error_code = body.get("error", "invalid_request")
raise bad_request_exc(f"Portal rejected token request: {error_code}")
if response.status_code != 200:
raise ProviderError(
f"Portal token endpoint returned {response.status_code}: "
f"{response.text[:200]!r}"
)
payload = self._parse_json_body(response)
access_token = payload.get("access_token")
if not access_token or not isinstance(access_token, str):
raise ProviderError("Portal token response missing access_token")
token_type = str(payload.get("token_type", "")).lower()
if token_type and token_type != "bearer":
raise ProviderError(f"unexpected token_type={token_type!r}")
claims = self._verify_jwt(access_token)
# The dashboard grant issues a rotating refresh token; capture it so
# the caller can persist it. Empty string if Portal omitted it (the
# session then behaves as access-token-only until expiry).
refresh_token = payload.get("refresh_token") or ""
if not isinstance(refresh_token, str):
refresh_token = ""
return self._session_from_claims(access_token, refresh_token, claims)
def verify_session(self, *, access_token: str) -> Optional[Session]:
# Contract: returns None on expiry/invalidity (the middleware then
# tries refresh_session with the RT cookie, falling back to
# redirect-to-login if that also fails); raises ProviderError if the
# IDP is unreachable.
# None on expiry/invalidity (middleware then tries refresh); a
# ProviderError (JWKS unreachable) bubbles up so middleware emits 503.
try:
claims = self._verify_jwt(access_token)
except InvalidCodeError:
# Expired/invalid token — middleware contract is None, not raise.
return None
except ProviderError:
# JWKS unreachable, etc. Bubble up so middleware emits 503.
raise
# verify_session validates the AT in isolation and has no access to the
# refresh token (it lives in a separate cookie the middleware reads);
# pass "" here — the RT-driven rotation path is middleware's job.
return self._session_from_claims(access_token, "", claims)
return self._session(access_token, "", claims)
def revoke_session(self, *, refresh_token: str) -> None:
# Portal exposes no public refresh-token revocation grant on its token
# endpoint (revocation is driven from the authenticated /sessions UI,
# keyed by sessionId + userId, not by the RT value). So logout is
# client-side cookie clearing; the server-side refresh session simply
# expires within its 24h TTL. Best-effort no-op, must not raise.
#
# If Portal later adds a token-endpoint revoke grant (e.g.
# grant_type=... + X-Refresh-Token), implement it here so logout
# invalidates the RT server-side immediately rather than waiting out
# the TTL.
_ = refresh_token
# Portal exposes no token-endpoint revocation grant; logout is
# client-side cookie clearing and the RT expires within its 24h TTL.
return None
# ---- internals --------------------------------------------------------
def _validate_redirect_uri(self, redirect_uri: str) -> None:
"""Surface obviously-broken redirect_uris before bouncing to Portal.
The Portal-side check (``agent-redirect-uri.ts``) is authoritative;
this is a fast-fail for the common operator-error case. We allow any
``http://`` host (not just localhost) so self-hosted dashboards reached
over plain HTTP — LAN IPs, internal hostnames, reverse proxies that
terminate TLS upstream — are not rejected here; Portal makes the final
call on which redirect_uris are permitted.
"""
parsed = urllib.parse.urlparse(redirect_uri)
if parsed.scheme not in ("https", "http"):
raise ProviderError(
f"redirect_uri must be http(s), got {redirect_uri!r}"
)
if not parsed.path or not parsed.path.endswith("/auth/callback"):
raise ProviderError(
"redirect_uri path must end with '/auth/callback', "
f"got {redirect_uri!r}"
)
def _parse_json_body(self, response: httpx.Response) -> Dict[str, Any]:
ctype = response.headers.get("content-type", "")
if not ctype.startswith("application/json"):
return {}
try:
body = response.json()
except ValueError:
return {}
return body if isinstance(body, dict) else {}
def _token_grant(
self, data: Dict[str, str], *, bad_request_exc: type[Exception],
headers: Optional[Dict[str, str]] = None,
) -> Session:
access_token, payload = exchange_token(
self._token_url, data, headers=headers, bad_request_exc=bad_request_exc,
idp="Portal", endpoint="Portal token endpoint", token_key="access_token",
missing_msg="Portal token response missing access_token",
)
# Rotating RT the caller MUST persist back to the cookie.
return self._session(access_token, refresh_token_from(payload), self._verify_jwt(access_token))
def _get_jwks_client(self) -> Any:
if self._jwks_client is None:
from jwt import PyJWKClient # lazy import
self._jwks_client = PyJWKClient(
self._jwks_url,
cache_keys=True,
lifespan=_JWKS_CACHE_SECONDS,
headers={
"Accept": "application/json",
"User-Agent": "HermesAgent/1.0",
},
)
self._jwks_client = make_jwks_client(self._jwks_url)
return self._jwks_client
def _verify_jwt(self, access_token: str) -> Dict[str, Any]:
# Lazy import — keeps startup fast for operators who never trigger
# the gated path.
import jwt
try:
signing_key = self._get_jwks_client().get_signing_key_from_jwt(
access_token
)
except Exception as exc:
# Unreachable JWKS -> ProviderError (503); a bearer that is not
# one of our JWTs (opaque peer key, foreign kid) -> InvalidCodeError
# (None / next provider). Folding both into 503 produced #94558.
raise classify_jwks_lookup_error(exc) from exc
try:
claims = jwt.decode(
access_token,
signing_key.key,
algorithms=["RS256"],
# Contract C2: aud is the bare client_id.
audience=self._client_id,
# Contract: issuer is the Portal base URL.
issuer=self._portal_url,
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
)
except jwt.ExpiredSignatureError as exc:
# verify_session() catches this and returns None per protocol.
raise InvalidCodeError(f"access token expired: {exc}") from exc
except jwt.InvalidTokenError as exc:
# Surface the actual claim values that failed verification so
# operators don't have to dig into the JWT to debug config drift
# between HERMES_DASHBOARD_PORTAL_URL / HERMES_DASHBOARD_OAUTH_CLIENT_ID
# and what Portal is actually emitting. Decoding without verification
# is safe here: we've already failed to verify, and we never trust
# these values — they're surfaced for diagnostics only.
details = ""
try:
unverified = jwt.decode(
access_token,
options={"verify_signature": False, "verify_exp": False},
)
details = (
f" [token iss={unverified.get('iss')!r} "
f"aud={unverified.get('aud')!r}; "
f"expected iss={self._portal_url!r} "
f"aud={self._client_id!r}]"
)
except Exception:
pass
raise ProviderError(
f"access token verification failed: {exc}{details}"
) from exc
self._check_agent_instance_id(claims)
self._check_contract_version(claims)
return claims
def _check_agent_instance_id(self, claims: Dict[str, Any]) -> None:
"""Contract C9: cross-check agent_instance_id against our config."""
claims = verify_jwt(
access_token, self._get_jwks_client(), algorithms=["RS256"],
audience=self._client_id, # contract C2: bare client_id
issuer=self._portal_url, label="access token",
)
# Contract C9: agent_instance_id is "should" not "must" — tolerated
# when absent (the aud check already binds the token to this instance).
token_instance_id = claims.get("agent_instance_id")
if token_instance_id is None:
# Tolerated — the claim is documented as "should" not "must".
# Our audience check on the bare client_id already binds the
# token to this instance; agent_instance_id is defense-in-depth.
return
if token_instance_id != self._agent_instance_id:
if token_instance_id is not None and token_instance_id != self._agent_instance_id:
raise ProviderError(
f"agent_instance_id mismatch: token={token_instance_id!r} "
f"vs configured={self._agent_instance_id!r}"
)
def _check_contract_version(self, claims: Dict[str, Any]) -> None:
"""Contract C11 — tolerant treatment per OQ-C2."""
contract_version = claims.get("oauth_contract_version")
if contract_version is None:
logger.warning(
@@ -509,165 +180,70 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
"(contract says it should be %d); proceeding anyway.",
_EXPECTED_CONTRACT_VERSION,
)
return
if contract_version != _EXPECTED_CONTRACT_VERSION:
elif contract_version != _EXPECTED_CONTRACT_VERSION:
raise ProviderError(
f"unsupported oauth_contract_version={contract_version!r}, "
f"expected {_EXPECTED_CONTRACT_VERSION}"
)
return claims
def _session_from_claims(
self,
access_token: str,
refresh_token: str,
claims: Dict[str, Any],
) -> Session:
# Contract C4: no email / display_name in tokens. AuthWidget will
# show user_id (truncated). Session fields kept for forward-compat.
user_id = str(claims.get("sub", ""))
if not user_id:
raise ProviderError("token missing 'sub' (user_id) claim")
return Session(
user_id=user_id,
email="",
display_name="",
def _session(self, access_token: str, refresh_token: str, claims: Dict[str, Any]) -> Session:
# Contract C4: no email / display_name in tokens.
return session_from_claims(
self.name, claims, access_token=access_token, refresh_token=refresh_token,
org_id=str(claims.get("org_id") or ""),
provider=self.name,
expires_at=int(claims["exp"]),
access_token=access_token,
refresh_token=refresh_token,
)
# ---------------------------------------------------------------------------
# Plugin entry point
# ---------------------------------------------------------------------------
# ---- Plugin entry point ----
def _load_config_oauth_section() -> dict:
"""Return the ``dashboard.oauth`` 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 — common in fresh installs), (b) the
``dashboard`` key being absent or non-dict, and (c) the ``oauth``
sub-key being present but not a dict (user typo). Each shape falls
through to ``{}`` so register() can rely on `.get(...)` access.
"""
try:
from hermes_cli.config import cfg_get, load_config
cfg = load_config()
except Exception as exc: # noqa: BLE001 — broad catch is intentional
logger.debug(
"dashboard-auth-nous: load_config() raised %s; "
"falling back to env-only configuration",
exc,
)
return {}
section = cfg_get(cfg, "dashboard", "oauth", default=None)
return section if isinstance(section, dict) else {}
def _resolve_client_id() -> str:
"""Resolve the OAuth client_id with env-overrides-config precedence.
Order:
1. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` 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.oauth.client_id`` in ``config.yaml``.
3. Empty string — signals "no client_id configured" to the caller.
"""
env = os.environ.get("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "").strip()
if env:
return env
cfg_value = _load_config_oauth_section().get("client_id", "")
return str(cfg_value).strip()
def _resolve_portal_url() -> str:
"""Resolve the Portal URL with env-overrides-config precedence.
Order:
1. ``HERMES_DASHBOARD_PORTAL_URL`` env var (non-empty after strip).
2. ``dashboard.oauth.portal_url`` in ``config.yaml``.
3. :data:`_DEFAULT_PORTAL_URL` (production Portal).
"""
env = os.environ.get("HERMES_DASHBOARD_PORTAL_URL", "").strip()
if env:
return env
cfg_value = str(
_load_config_oauth_section().get("portal_url", "")
).strip()
return cfg_value or _DEFAULT_PORTAL_URL
return load_config_section(logger, "dashboard-auth-nous", "dashboard", "oauth")
def register(ctx) -> None:
"""Plugin entry — called by the plugin loader at startup.
"""Register ``NousDashboardAuthProvider`` when a client_id is configured.
Registers ``NousDashboardAuthProvider`` only when a client_id is
configured (either via ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var
or via ``dashboard.oauth.client_id`` in ``config.yaml``). The env
var wins when set non-empty — Fly.io's platform-secret injection
pushes the per-deploy value through this path.
When skipping, writes a short human-readable reason to the module-
level :data:`LAST_SKIP_REASON` so the dashboard's fail-closed branch
can surface "Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …" instead of the
bare "no providers registered" the gate would otherwise emit. The
reason mentions BOTH configuration surfaces so operators don't
guess wrong about which one to populate.
Operator-owned dashboards (loopback / ``--insecure``) leave both
surfaces unset, so this plugin is a no-op for them. The gate-
engagement layer (``hermes_cli.web_server.should_require_auth`` +
the fail-closed check in ``start_server``) handles the "public bind
with zero providers" case independently.
On skip, writes a reason naming BOTH configuration surfaces to
:data:`LAST_SKIP_REASON` for the gate's fail-closed error message.
"""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
client_id = _resolve_client_id()
portal_url = _resolve_portal_url()
section = _load_config_oauth_section()
client_id = resolve_env_or_cfg("HERMES_DASHBOARD_OAUTH_CLIENT_ID", section.get("client_id", ""))
portal_url = (
resolve_env_or_cfg("HERMES_DASHBOARD_PORTAL_URL", section.get("portal_url", ""))
or _DEFAULT_PORTAL_URL
)
if not client_id:
LAST_SKIP_REASON = (
"HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and "
"dashboard.oauth.client_id in config.yaml is empty). The "
"Nous Portal provisions this env var (shape "
"'agent:{instance_id}') when it deploys a Hermes Agent "
"instance — set it to your provisioned client id (either "
"as an env var or under dashboard.oauth.client_id in "
"config.yaml), or pass --insecure to skip the OAuth gate "
"entirely."
"HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and dashboard.oauth.client_id "
"in config.yaml is empty). The Nous Portal provisions this env var (shape "
"'agent:{instance_id}') when it deploys a Hermes Agent instance — set it to "
"your provisioned client id (either as an env var or under "
"dashboard.oauth.client_id in config.yaml), or pass --insecure to skip the "
"OAuth gate entirely."
)
logger.debug("dashboard-auth-nous: %s", LAST_SKIP_REASON)
return
if not client_id.startswith("agent:"):
LAST_SKIP_REASON = (
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match "
f"the contract shape 'agent:{{instance_id}}'. The Nous Portal "
f"provisions this value at deploy time; check your Fly app's "
f"secrets or override with the value from the Portal admin UI."
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match the contract "
f"shape 'agent:{{instance_id}}'. The Nous Portal provisions this value at deploy "
f"time; check your Fly app's secrets or override with the value from the Portal admin UI."
)
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
return
try:
provider = NousDashboardAuthProvider(
client_id=client_id, portal_url=portal_url
)
provider = NousDashboardAuthProvider(client_id=client_id, portal_url=portal_url)
except ValueError as exc:
LAST_SKIP_REASON = f"NousDashboardAuthProvider construction failed: {exc}"
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
return
ctx.register_dashboard_auth_provider(provider)
logger.info(
"dashboard-auth-nous: registered provider (client_id=%s, portal=%s)",
client_id,
portal_url,
)
logger.info("dashboard-auth-nous: registered provider (client_id=%s, portal=%s)", client_id, portal_url)
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import plugins.dashboard_auth.nous as nous_plugin
from plugins.dashboard_auth._shared import JWKS_CACHE_SECONDS
from hermes_cli.dashboard_auth import (
InvalidCodeError,
LoginStart,
@@ -422,7 +423,7 @@ class TestCompleteLogin:
"refresh_token": "rt_initial_value",
},
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
session = provider.complete_login(
code="abc",
state="state-val",
@@ -443,7 +444,7 @@ class TestCompleteLogin:
def test_400_raises_invalid_code(self, provider):
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
with pytest.raises(InvalidCodeError, match="invalid_grant"):
provider.complete_login(
code="bad", state="s", code_verifier="v",
@@ -453,7 +454,7 @@ class TestCompleteLogin:
def test_500_raises_provider_error(self, provider):
mock_resp = self._mock_post(500, "internal server error", ctype="text/plain")
mock_resp.text = "internal server error"
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError, match="500"):
provider.complete_login(
code="x", state="s", code_verifier="v",
@@ -462,7 +463,7 @@ class TestCompleteLogin:
def test_missing_access_token_raises(self, provider):
mock_resp = self._mock_post(200, {"token_type": "Bearer"})
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError, match="access_token"):
provider.complete_login(
code="x", state="s", code_verifier="v",
@@ -474,7 +475,7 @@ class TestCompleteLogin:
mock_resp = self._mock_post(
200, {"access_token": access_token, "token_type": "DPoP"}
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
with pytest.raises(ProviderError, match="token_type"):
provider.complete_login(
code="x", state="s", code_verifier="v",
@@ -483,7 +484,7 @@ class TestCompleteLogin:
def test_network_error_raises_provider_error(self, provider):
with patch(
"plugins.dashboard_auth.nous.httpx.post",
"plugins.dashboard_auth._shared.httpx.post",
side_effect=httpx.ConnectError("conn refused"),
):
with pytest.raises(ProviderError, match="unreachable"):
@@ -506,7 +507,7 @@ class TestCompleteLogin:
"refresh_token": "rt-opaque",
},
)
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
session = provider.complete_login(
code="x", state="s", code_verifier="v",
redirect_uri="https://hermes.fly.dev/auth/callback",
@@ -538,7 +539,7 @@ class TestVerifySession:
client_cls.assert_called_once_with(
provider._jwks_url,
cache_keys=True,
lifespan=nous_plugin._JWKS_CACHE_SECONDS,
lifespan=JWKS_CACHE_SECONDS,
headers={
"Accept": "application/json",
"User-Agent": "HermesAgent/1.0",
@@ -638,7 +639,7 @@ class TestRefreshAndRevoke:
},
)
with patch(
"plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp
"plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp
) as mock_post:
session = provider.refresh_session(refresh_token="rt_old_value")
@@ -30,6 +30,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
import plugins.dashboard_auth.self_hosted as oidc_plugin
from plugins.dashboard_auth._shared import JWKS_CACHE_SECONDS
from hermes_cli.dashboard_auth import (
InvalidCodeError,
LoginStart,
@@ -584,7 +585,7 @@ class TestVerifySession:
client_cls.assert_called_once_with(
_DISCOVERY_DOC["jwks_uri"],
cache_keys=True,
lifespan=oidc_plugin._JWKS_CACHE_SECONDS,
lifespan=JWKS_CACHE_SECONDS,
headers={
"Accept": "application/json",
"User-Agent": "HermesAgent/1.0",