From 238260e6aa3f44d50f60b5072dd1bc2c486a7eb7 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 2 Sep 2026 11:23:21 -0700 Subject: [PATCH] refactor(plugins/dashboard_auth): shared session/JWT/config helpers in _shared; compact basic/drain/nous/self_hosted --- plugins/dashboard_auth/_shared.py | 251 ++++++ plugins/dashboard_auth/basic/__init__.py | 369 +++----- plugins/dashboard_auth/drain/__init__.py | 181 ++-- plugins/dashboard_auth/nous/__init__.py | 670 +++----------- .../dashboard_auth/self_hosted/__init__.py | 829 +++++------------- .../dashboard_auth/test_nous_provider.py | 19 +- .../test_self_hosted_provider.py | 3 +- 7 files changed, 753 insertions(+), 1569 deletions(-) create mode 100644 plugins/dashboard_auth/_shared.py diff --git a/plugins/dashboard_auth/_shared.py b/plugins/dashboard_auth/_shared.py new file mode 100644 index 0000000000..72c12b296c --- /dev/null +++ b/plugins/dashboard_auth/_shared.py @@ -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 diff --git a/plugins/dashboard_auth/basic/__init__.py b/plugins/dashboard_auth/basic/__init__.py index 12ec0fe513..5b0c361857 100644 --- a/plugins/dashboard_auth/basic/__init__.py +++ b/plugins/dashboard_auth/basic/__init__.py @@ -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$$`` 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) diff --git a/plugins/dashboard_auth/drain/__init__.py b/plugins/dashboard_auth/drain/__init__.py index deac516059..132ebf5853 100644 --- a/plugins/dashboard_auth/drain/__init__.py +++ b/plugins/dashboard_auth/drain/__init__.py @@ -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, ) diff --git a/plugins/dashboard_auth/nous/__init__.py b/plugins/dashboard_auth/nous/__init__.py index fdb28173ee..1c0400b50b 100644 --- a/plugins/dashboard_auth/nous/__init__.py +++ b/plugins/dashboard_auth/nous/__init__.py @@ -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) diff --git a/plugins/dashboard_auth/self_hosted/__init__.py b/plugins/dashboard_auth/self_hosted/__init__.py index 7b7283b2df..decdd7078a 100644 --- a/plugins/dashboard_auth/self_hosted/__init__.py +++ b/plugins/dashboard_auth/self_hosted/__init__.py @@ -1,54 +1,31 @@ """SelfHostedOIDCProvider — generic self-hosted OpenID Connect dashboard auth. -A standards-compliant OpenID Connect Relying Party for the ``hermes dashboard`` -OAuth gate. Unlike the bundled ``nous`` provider (which encodes Nous Portal's -bespoke contract — ``agent:{instance_id}`` client ids, a custom access-token -JWT, the ``x-nous-refresh-token`` header, an ``oauth_contract_version`` claim), -this provider speaks **plain OIDC** so it works against any conformant -self-hosted identity provider: +A standards-compliant OIDC Relying Party for the ``hermes dashboard`` gate. +Unlike the ``nous`` provider (Nous Portal's bespoke contract), this speaks +plain OIDC so it works against Authentik, Keycloak, Zitadel, Authelia, Auth0, +Okta, Google, … The HTTP round trip, cookies, CSRF ``state`` check and +``redirect_uri`` reconstruction are owned by ``hermes_cli/dashboard_auth/ +routes.py``; this provider only: - Authentik · Keycloak · Zitadel · Authelia · Auth0 · Okta · Google · … - -It is a pure drop-in plugin: it implements the five -:class:`~hermes_cli.dashboard_auth.DashboardAuthProvider` methods and touches -nothing in core auth/runtime/login. The HTTP round trip, cookies, CSRF -``state`` check and ``redirect_uri`` reconstruction are all owned by -``hermes_cli/dashboard_auth/routes.py``; this provider only: - - 1. discovers the IDP's endpoints from ``{issuer}/.well-known/openid-configuration``, + 1. discovers endpoints from ``{issuer}/.well-known/openid-configuration``, 2. builds the ``/authorize`` URL with PKCE (S256), - 3. exchanges the authorization code for tokens at the discovered - ``token_endpoint``, - 4. verifies the **ID token** (RS256/ES256) against the discovered - ``jwks_uri`` with ``iss`` / ``aud`` pinned to the configured issuer / - client id, and maps standard OIDC claims (``sub``, ``email``, ``name``) - onto a :class:`~hermes_cli.dashboard_auth.Session`. + 3. exchanges the code at the discovered ``token_endpoint``, + 4. verifies the **ID token** against the discovered ``jwks_uri`` with + ``iss``/``aud`` pinned, mapping ``sub``/``email``/``name`` onto a Session. -Why the ID token (not the access token)? OIDC guarantees the ID token is a -signed JWT carrying identity claims — that is its entire purpose. The access -token's format is opaque to the client per the spec; many IDPs issue random -opaque strings the client cannot verify locally. Verifying the ID token is the -only choice that is universally correct across self-hosted IDPs. (The ``nous`` -provider verifies its *access* token because Nous Portal mints a custom JWT -access token with the dashboard claims baked in — a non-OIDC shortcut.) +Why the ID token, not the access token: OIDC guarantees the ID token is a +signed JWT carrying identity claims; the access token's format is opaque per +spec (many IDPs issue random strings). The ``nous`` provider verifies its +access token only because Portal mints a custom JWT there. -Both **public** (PKCE-only) and **confidential** (PKCE + ``client_secret``) -clients are supported. A self-hoster who registers a public client configures -no secret and the token-endpoint calls authenticate with PKCE alone (the -default). A self-hoster whose IDP defaults the client to *confidential* -(Authentik and Keycloak commonly do) sets ``client_secret`` and the provider -additionally authenticates the client at the token endpoint, choosing -``client_secret_basic`` (HTTP Basic header) or ``client_secret_post`` (secret -in the form body) from the IDP's advertised -``token_endpoint_auth_methods_supported``. PKCE is sent in **both** modes — -the secret is client authentication layered on top, never a replacement for -PKCE (OAuth 2.1 / RFC 9700 keep PKCE mandatory regardless). +Public (PKCE-only) and confidential (PKCE + ``client_secret``) clients are +both supported. With a secret, the client additionally authenticates at the +token endpoint via ``client_secret_basic`` or ``client_secret_post`` chosen +from ``token_endpoint_auth_methods_supported``. PKCE is sent in both modes — +the secret is layered on top, never a replacement (OAuth 2.1 / RFC 9700). -Configuration surfaces (env wins over config.yaml when set non-empty, so a -provisioned-but-not-populated secret can't shadow a valid config.yaml entry — -same precedence convention as the ``nous`` plugin):: +Configuration (env wins over config.yaml when set non-empty):: - # config.yaml — canonical surface dashboard: oauth: provider: self-hosted @@ -56,30 +33,21 @@ same precedence convention as the ``nous`` plugin):: issuer: https://auth.example.com/application/o/hermes/ # required client_id: hermes-dashboard # required scopes: "openid profile email" # optional - # client_secret: set ONLY for a confidential client. It is a - # credential — prefer the env var / ~/.hermes/.env over config.yaml. + # client_secret: confidential clients only — prefer the env var. - # Environment overrides (Docker/Fly secret injection) HERMES_DASHBOARD_OIDC_ISSUER HERMES_DASHBOARD_OIDC_CLIENT_ID - HERMES_DASHBOARD_OIDC_SCOPES # optional; defaults to "openid profile email" - HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; set for a confidential client - # (the .env file is the canonical home — - # it's a secret, not a behavioural setting) + HERMES_DASHBOARD_OIDC_SCOPES # optional + HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; .env is its canonical home -Skip reasons: when the plugin loads but can't register (missing issuer / -client_id), it writes a human-readable reason to the module-level -:data:`LAST_SKIP_REASON` so the gate's fail-closed branch can surface a useful -operator error instead of the bare "no providers registered". +On skip (missing issuer / client_id) the module-level :data:`LAST_SKIP_REASON` +carries a human-readable reason for the gate's fail-closed error. """ from __future__ import annotations import base64 -import hashlib import logging -import os -import secrets import threading import time import urllib.parse @@ -93,73 +61,49 @@ from hermes_cli.dashboard_auth import ( LoginStart, ProviderError, RefreshExpiredError, - classify_jwks_lookup_error, Session, ) +from plugins.dashboard_auth._shared import ( + JSON_HEADERS, + TOKEN_ENDPOINT_TIMEOUT_SEC as _TOKEN_ENDPOINT_TIMEOUT_SEC, + exchange_token, + load_config_section, + make_jwks_client, + parse_json_body, + pkce_login_start, + refresh_token_from, + resolve_env_or_cfg, + session_from_claims, + validate_redirect_uri, + verify_jwt, +) logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Defaults / constants -# --------------------------------------------------------------------------- - -# OIDC core scopes. ``openid`` is mandatory (without it the IDP won't issue an -# ID token); ``profile``/``email`` populate the Session's display_name/email. +# ``openid`` is mandatory (no ID token without it); profile/email populate +# display_name/email. _DEFAULT_SCOPES = "openid profile email" -# Signing algorithms we accept on the ID token. RS256 is the OIDC default; -# ES256 is common on modern self-hosted IDPs (Zitadel, newer Keycloak realms). -# HS256 is deliberately excluded — it implies a shared secret we don't have in -# the public-client model and is a well-known JWT confusion footgun. +# RS256 is the OIDC default; ES256 is common on modern IDPs (Zitadel, newer +# Keycloak). HS256 is deliberately excluded: it implies a shared secret we +# don't hold in the public-client model and is a JWT algorithm-confusion footgun. _ALLOWED_ID_TOKEN_ALGS = ("RS256", "ES256", "RS384", "RS512", "ES384", "ES512") -# httpx timeouts. _DISCOVERY_TIMEOUT_SEC = 10.0 -_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0 - -# OIDC discovery is low-frequency and the document is effectively static; -# cache it for the process lifetime with a soft TTL so a long-running -# dashboard picks up an IDP endpoint migration within the hour. +# Discovery is effectively static; a soft TTL lets a long-running dashboard +# pick up an IDP endpoint migration within the hour. _DISCOVERY_CACHE_TTL_SEC = 3600 -# JWKS cache (PyJWKClient handles its own caching; this mirrors the nous -# provider's 5-minute lifespan so key rotation is picked up promptly). -_JWKS_CACHE_SECONDS = 300 - - -# --------------------------------------------------------------------------- -# Skip-reason channel (mirrors the nous plugin) -# --------------------------------------------------------------------------- - LAST_SKIP_REASON: str = "" -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _b64url_no_pad(raw: bytes) -> str: - """Base64url-encode without ``=`` padding (RFC 7636 §4).""" - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() - - def _require_https_or_loopback(url: str, *, field: str) -> str: - """Reject an endpoint URL that isn't HTTPS (loopback http is allowed). - - OAuth credentials (codes, tokens) flow over these URLs. We require HTTPS - for everything except an explicit loopback host so a misconfigured issuer - can't ship the authorization code / refresh token in cleartext. Returns - the URL unchanged on success; raises :class:`ProviderError` otherwise. - """ + """Reject non-HTTPS endpoint URLs (loopback http allowed) so a misconfigured + issuer can't ship auth codes / refresh tokens in cleartext.""" parsed = urllib.parse.urlparse(url) - if parsed.scheme == "https": - return url - if parsed.scheme == "http" and (parsed.hostname or "") in ( - "localhost", - "127.0.0.1", - "::1", + if parsed.scheme == "https" or ( + parsed.scheme == "http" + and (parsed.hostname or "") in ("localhost", "127.0.0.1", "::1") ): return url raise ProviderError( @@ -167,11 +111,6 @@ def _require_https_or_loopback(url: str, *, field: str) -> str: ) -# --------------------------------------------------------------------------- -# Provider -# --------------------------------------------------------------------------- - - class SelfHostedOIDCProvider(DashboardAuthProvider): """Generic self-hosted OpenID Connect provider (authorization-code + PKCE).""" @@ -190,23 +129,18 @@ class SelfHostedOIDCProvider(DashboardAuthProvider): raise ValueError("issuer is required") if not client_id: raise ValueError("client_id is required") - # ``issuer`` is the OIDC issuer identifier. Normalise the trailing - # slash for stable string compares (the ``iss`` claim must match the - # issuer the IDP advertises in discovery — we pin against the - # discovered value, not this normalised one, to be tolerant of a - # trailing-slash mismatch between config and the IDP). + # Trailing slash normalised for stable compares; ``iss`` is pinned + # against the *discovered* issuer so a config/IDP slash mismatch is tolerated. self._issuer = issuer.rstrip("/") _require_https_or_loopback(self._issuer, field="issuer") self._client_id = client_id self._scopes = scopes.strip() or _DEFAULT_SCOPES - # An empty/whitespace secret means "public client" — strip so a - # provisioned-but-blank secret can't flip us into a broken confidential - # mode that sends an empty client_secret. Non-empty ⇒ confidential. + # Empty/whitespace secret ⇒ public client, so a provisioned-but-blank + # secret can't flip us into a broken confidential mode. self._client_secret = (client_secret or "").strip() - # Discovery + JWKS are lazily resolved on first use so plugin - # registration never makes a network call (the IDP may be down at - # boot; the gate should still come up and fail per-request). + # Discovery + JWKS resolve lazily so registration never hits the + # network (the IDP may be down at boot; fail per-request instead). self._discovery: Dict[str, Any] | None = None self._discovery_fetched_at: float = 0.0 self._discovery_lock = threading.Lock() @@ -215,112 +149,58 @@ class SelfHostedOIDCProvider(DashboardAuthProvider): # ---- public API (DashboardAuthProvider) ------------------------------- def start_login(self, *, redirect_uri: str) -> LoginStart: - self._validate_redirect_uri(redirect_uri) + # Validate the redirect before discovery so a bad redirect_uri + # surfaces even when the IDP is unreachable. + validate_redirect_uri(redirect_uri) disco = self._get_discovery() - - code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars - code_challenge = _b64url_no_pad( - hashlib.sha256(code_verifier.encode("ascii")).digest() + return pkce_login_start( + disco["authorization_endpoint"], client_id=self._client_id, + scope=self._scopes, 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": self._scopes, - "state": state, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - redirect_url = ( - f"{disco['authorization_endpoint']}?{urllib.parse.urlencode(params)}" - ) - # Same flat ``state=…;verifier=…`` cookie shape every provider uses; - # the auth-route layer prepends ``provider=`` and parses it back out. - 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. - _ = state - disco = self._get_discovery() - - data = { - "grant_type": "authorization_code", - "code": code, - "redirect_uri": redirect_uri, - "client_id": self._client_id, - "code_verifier": code_verifier, - } - # Confidential clients additionally authenticate the client here (basic - # header or post body, per the IDP's advertised methods); public - # clients get ({}, {}) and authenticate with PKCE alone. - extra_data, extra_headers = self._token_endpoint_auth(disco) - data.update(extra_data) return self._exchange( - disco["token_endpoint"], - data, + { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self._client_id, + "code_verifier": code_verifier, + }, bad_request_exc=InvalidCodeError, - extra_headers=extra_headers, ) def refresh_session(self, *, refresh_token: str) -> Session: if not refresh_token: raise RefreshExpiredError("no refresh token present in session") - disco = self._get_discovery() - - data = { - "grant_type": "refresh_token", - "client_id": self._client_id, - "refresh_token": refresh_token, - # Re-request the same scopes so the rotated ID token keeps the - # identity claims (some IDPs narrow scope on refresh otherwise). - "scope": self._scopes, - } - # Same client-authentication treatment as complete_login: confidential - # clients must authenticate on the refresh grant too, or the IDP - # rejects the rotation with invalid_client. - extra_data, extra_headers = self._token_endpoint_auth(disco) - data.update(extra_data) return self._exchange( - disco["token_endpoint"], - data, + { + "grant_type": "refresh_token", + "client_id": self._client_id, + "refresh_token": refresh_token, + # Re-request the same scopes so the rotated ID token keeps its + # identity claims (some IDPs narrow scope on refresh otherwise). + "scope": self._scopes, + }, bad_request_exc=RefreshExpiredError, previous_refresh_token=refresh_token, - extra_headers=extra_headers, ) def verify_session(self, *, access_token: str) -> Optional[Session]: - # The session cookie stores the ID token in the access-token slot (see - # ``_session_from_tokens``) precisely so this per-request check can - # verify a real JWT. Returns None on expiry/invalidity (middleware - # then refreshes or logs out); raises ProviderError if the IDP/JWKS is - # unreachable. + # The session cookie carries the ID token in the access-token slot + # (see _session_from_tokens) so this per-request check verifies a real + # JWT. None on expiry/invalidity; ProviderError if IDP/JWKS unreachable. try: claims = self._verify_id_token(access_token) except InvalidCodeError: - # Expired / invalid token — protocol says return None, not raise. return None - except ProviderError: - raise - # No refresh token available on this path; "" is fine — the middleware - # re-reads the refresh-token cookie separately for refresh_session. - return self._session_from_tokens( - id_token=access_token, refresh_token="", claims=claims - ) + return self._session(access_token, "", claims) def revoke_session(self, *, refresh_token: str) -> None: - # Best-effort RFC 7009 revocation if the IDP advertised an endpoint. + # Best-effort RFC 7009 revocation when the IDP advertises an endpoint. # Must never raise — logout is client-side cookie clearing regardless. if not refresh_token: return None @@ -331,508 +211,212 @@ class SelfHostedOIDCProvider(DashboardAuthProvider): endpoint = str(disco.get("revocation_endpoint") or "").strip() if not endpoint: return None - data = { - "token": refresh_token, - "token_type_hint": "refresh_token", - "client_id": self._client_id, - } - headers = {"Accept": "application/json"} - # A confidential client must authenticate on revocation too (RFC 7009 - # §2.1), or the IDP rejects it with invalid_client. Reuse the same - # method selection as the token endpoint; public clients add nothing. + # Confidential clients must authenticate on revocation too (RFC 7009 §2.1). extra_data, extra_headers = self._token_endpoint_auth(disco) - data.update(extra_data) - headers.update(extra_headers) + data = {"token": refresh_token, "token_type_hint": "refresh_token", "client_id": self._client_id, **extra_data} try: - httpx.post( - endpoint, - data=data, - headers=headers, - timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC, - ) + httpx.post(endpoint, data=data, headers={**JSON_HEADERS, **extra_headers}, timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC) except Exception as exc: # noqa: BLE001 — best-effort logger.debug("self-hosted OIDC: revoke failed (ignored): %s", exc) return None # ---- internals: token exchange ---------------------------------------- - def _token_endpoint_auth( - self, disco: Dict[str, Any] - ) -> tuple[Dict[str, str], Dict[str, str]]: + def _token_endpoint_auth(self, disco: Dict[str, Any]) -> tuple[Dict[str, str], Dict[str, str]]: """Return ``(extra_data, extra_headers)`` for token-endpoint client auth. - Public client (no ``client_secret`` configured): returns ``({}, {})`` — - the exchange authenticates with PKCE alone, exactly as before this - method existed. - - Confidential client (``client_secret`` set): authenticates the client - per RFC 6749 §2.3.1, choosing the method from the IDP's advertised - ``token_endpoint_auth_methods_supported``: - - * ``client_secret_post`` advertised (and ``client_secret_basic`` not) - → secret in the form body. - * otherwise → HTTP Basic ``Authorization`` header (the OIDC default; - also the fallback when the IDP advertises nothing). - - PKCE's ``code_verifier`` is sent regardless by the callers — the secret - is layered on top, never a replacement. + Public client → ``({}, {})`` (PKCE alone). Confidential client → + ``client_secret_post`` when the IDP advertises it *without* + ``client_secret_basic``, else HTTP Basic (the OIDC default and the + fallback when nothing is advertised). RFC 6749 §2.3.1. """ if not self._client_secret: return {}, {} - methods = disco.get("token_endpoint_auth_methods_supported") or [] - prefer_post = ( - "client_secret_post" in methods - and "client_secret_basic" not in methods - ) - if prefer_post: - # Secret travels in the application/x-www-form-urlencoded body. + if "client_secret_post" in methods and "client_secret_basic" not in methods: return {"client_secret": self._client_secret}, {} - - # HTTP Basic: base64(urlencode(client_id) ":" urlencode(secret)). - # Both halves must be form-url-encoded *before* base64 per RFC 6749 - # §2.3.1, or a secret containing ':' / reserved chars corrupts the - # header. - userpass = ( - f"{urllib.parse.quote(self._client_id, safe='')}:" - f"{urllib.parse.quote(self._client_secret, safe='')}" - ) - encoded = base64.b64encode(userpass.encode("utf-8")).decode("ascii") - return {}, {"Authorization": f"Basic {encoded}"} + # Both halves must be form-url-encoded *before* base64 (RFC 6749 + # §2.3.1) or a secret containing ':' / reserved chars corrupts the header. + userpass = f"{urllib.parse.quote(self._client_id, safe='')}:{urllib.parse.quote(self._client_secret, safe='')}" + return {}, {"Authorization": f"Basic {base64.b64encode(userpass.encode('utf-8')).decode('ascii')}"} def _exchange( self, - token_endpoint: str, data: Dict[str, str], *, bad_request_exc: type[Exception], previous_refresh_token: str = "", - extra_headers: Optional[Dict[str, str]] = None, ) -> Session: - """POST the token endpoint and turn the response into a Session. + """POST the discovered token endpoint and turn the response into a Session. - Shared by ``complete_login`` (auth-code grant) and ``refresh_session`` - (refresh grant). ``bad_request_exc`` is raised on a 400 — - ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError`` - for the refresh path — preserving the middleware's distinct handling. - - ``extra_headers`` carries the confidential-client ``Authorization`` - header when one applies (see ``_token_endpoint_auth``); it is empty for - a public client, so the request is byte-identical to the pre- - confidential-client behaviour in that case. + Confidential-client auth (body field or Basic header) is added here + for both grants — the IDP rejects an unauthenticated refresh with + ``invalid_client``. For a public client the request is PKCE-only. """ - headers = {"Accept": "application/json"} - if extra_headers: - headers.update(extra_headers) - try: - response = httpx.post( - token_endpoint, - data=data, - headers=headers, - timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC, - ) - except httpx.RequestError as exc: - raise ProviderError( - f"OIDC token endpoint unreachable: {exc}" - ) from exc - - if response.status_code == 400: - body = self._parse_json_body(response) - error_code = body.get("error", "invalid_request") - raise bad_request_exc( - f"IDP rejected token request: {error_code}" - ) - if response.status_code != 200: - raise ProviderError( - f"OIDC token endpoint returned {response.status_code}: " - f"{response.text[:200]!r}" - ) - - payload = self._parse_json_body(response) - - id_token = payload.get("id_token") - if not id_token or not isinstance(id_token, str): - raise ProviderError( + disco = self._get_discovery() + extra_data, extra_headers = self._token_endpoint_auth(disco) + id_token, payload = exchange_token( + disco["token_endpoint"], + {**data, **extra_data}, + headers=extra_headers, + bad_request_exc=bad_request_exc, + idp="IDP", + endpoint="OIDC token endpoint", + token_key="id_token", + missing_msg=( "OIDC token response missing id_token — ensure the 'openid' " "scope is configured and the client is allowed to receive an " "ID 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_id_token(id_token) - - # Refresh-token rotation: prefer a freshly-issued one, else keep the - # previous (some IDPs don't rotate). Empty string if neither — the - # session then behaves as ID-token-only until expiry. - refresh_token = payload.get("refresh_token") - if not isinstance(refresh_token, str) or not refresh_token: - refresh_token = previous_refresh_token or "" - - return self._session_from_tokens( - id_token=id_token, refresh_token=refresh_token, claims=claims + ), ) + claims = self._verify_id_token(id_token) + # Prefer a freshly-issued RT, else keep the previous (some IDPs don't rotate). + return self._session(id_token, refresh_token_from(payload, previous_refresh_token), claims) # ---- internals: discovery --------------------------------------------- - def _get_discovery(self) -> Dict[str, Any]: - """Return the cached OIDC discovery document, fetching if stale.""" - now = time.time() - if ( - self._discovery is not None - and (now - self._discovery_fetched_at) < _DISCOVERY_CACHE_TTL_SEC - ): + def _fresh_discovery(self) -> Dict[str, Any] | None: + if self._discovery is not None and time.time() - self._discovery_fetched_at < _DISCOVERY_CACHE_TTL_SEC: return self._discovery - with self._discovery_lock: - now = time.time() - if ( - self._discovery is not None - and (now - self._discovery_fetched_at) < _DISCOVERY_CACHE_TTL_SEC - ): - return self._discovery - disco = self._fetch_discovery() - self._discovery = disco - self._discovery_fetched_at = now - # New issuer/keys → drop the JWKS client so it re-binds to the - # freshly-discovered jwks_uri. - self._jwks_client = None - return disco + return None - def _discovery_url(self) -> str: - # RFC 8414 / OIDC Discovery: ``{issuer}/.well-known/openid-configuration``. - return f"{self._issuer}/.well-known/openid-configuration" + def _get_discovery(self) -> Dict[str, Any]: + """Return the cached OIDC discovery document, fetching if stale (double-checked lock).""" + disco = self._fresh_discovery() + if disco is None: + with self._discovery_lock: + disco = self._fresh_discovery() + if disco is None: + disco = self._discovery = self._fetch_discovery() + self._discovery_fetched_at = time.time() + # New issuer/keys → rebind the JWKS client to the fresh jwks_uri. + self._jwks_client = None + return disco def _fetch_discovery(self) -> Dict[str, Any]: - url = self._discovery_url() + url = f"{self._issuer}/.well-known/openid-configuration" try: - # follow_redirects=True: many IDPs answer the discovery GET with a - # 3xx rather than a direct 200 — Authentik canonicalises the - # ``.well-known`` path, and any IDP behind a reverse proxy doing an - # http→https upgrade redirects too. httpx (unlike curl -L or the - # requests library) defaults to follow_redirects=False, so without - # this the redirect comes back as a bare 3xx with an empty body and - # the ``status != 200`` check below raises "discovery returned 302" - # → provider_unreachable → 503. Following the redirect is safe: the - # issuer-pin check and _require_https_or_loopback below still - # validate the *resolved* document and every endpoint in it, so a + # follow_redirects=True: many IDPs answer discovery with a 3xx + # (Authentik canonicalises .well-known; proxies upgrade http→https) + # and httpx defaults to not following. Safe because the issuer pin + # and HTTPS checks below validate the *resolved* document, so a # redirect to a hostile location can't smuggle in a bad issuer or a - # cleartext endpoint. (The token/revocation POSTs deliberately do - # NOT follow redirects — see _exchange — because they carry an auth - # code / refresh token and the endpoint is already the canonical - # absolute URL resolved here.) - response = httpx.get( - url, - headers={"Accept": "application/json"}, - timeout=_DISCOVERY_TIMEOUT_SEC, - follow_redirects=True, - ) + # cleartext endpoint. The token/revocation POSTs deliberately do + # NOT follow redirects (they carry an auth code / refresh token). + response = httpx.get(url, headers=JSON_HEADERS, timeout=_DISCOVERY_TIMEOUT_SEC, follow_redirects=True) except httpx.RequestError as exc: raise ProviderError(f"OIDC discovery unreachable: {exc}") from exc if response.status_code != 200: - raise ProviderError( - f"OIDC discovery returned {response.status_code} for {url!r}" - ) - payload = self._parse_json_body(response) + raise ProviderError(f"OIDC discovery returned {response.status_code} for {url!r}") + payload = parse_json_body(response) if not payload: raise ProviderError("OIDC discovery returned a non-JSON body") - authorization_endpoint = str( - payload.get("authorization_endpoint", "") or "" - ).strip() - token_endpoint = str(payload.get("token_endpoint", "") or "").strip() - jwks_uri = str(payload.get("jwks_uri", "") or "").strip() - if not authorization_endpoint or not token_endpoint or not jwks_uri: + def field(key: str) -> str: + return str(payload.get(key, "") or "").strip() + + endpoints = {k: field(k) for k in ("authorization_endpoint", "token_endpoint", "jwks_uri")} + if not all(endpoints.values()): raise ProviderError( "OIDC discovery missing one of authorization_endpoint / " "token_endpoint / jwks_uri" ) - # Pin the discovered issuer: a mismatch between the configured issuer - # and the ``issuer`` the IDP advertises means the discovery document - # was served from the wrong place (proxy/MITM/misconfig). We tolerate - # only a trailing-slash difference. - advertised_issuer = str(payload.get("issuer", "") or "").strip() + # Issuer pin: a mismatch means the document came from the wrong place + # (proxy/MITM/misconfig). Only a trailing-slash difference is tolerated. + advertised_issuer = field("issuer") if advertised_issuer and advertised_issuer.rstrip("/") != self._issuer: raise ProviderError( f"OIDC discovery issuer mismatch: document advertises " f"{advertised_issuer!r} but configured issuer is " f"{self._issuer!r}" ) + for key, url in endpoints.items(): + _require_https_or_loopback(url, field=key) - _require_https_or_loopback( - authorization_endpoint, field="authorization_endpoint" - ) - _require_https_or_loopback(token_endpoint, field="token_endpoint") - _require_https_or_loopback(jwks_uri, field="jwks_uri") - - revocation_endpoint = str( - payload.get("revocation_endpoint", "") or "" - ).strip() - - # Client-authentication methods the IDP advertises for the token - # endpoint. Used to pick client_secret_basic vs client_secret_post for - # a confidential client (see ``_token_endpoint_auth``). Absent/garbage - # → empty list → we fall back to the OIDC default (basic). + # Absent/garbage auth-methods → [] → OIDC default (basic) applies. auth_methods_raw = payload.get("token_endpoint_auth_methods_supported") - token_endpoint_auth_methods = ( - [str(m) for m in auth_methods_raw] - if isinstance(auth_methods_raw, list) - else [] - ) - return { "issuer": advertised_issuer or self._issuer, - "authorization_endpoint": authorization_endpoint, - "token_endpoint": token_endpoint, - "jwks_uri": jwks_uri, - "revocation_endpoint": revocation_endpoint, - "token_endpoint_auth_methods_supported": token_endpoint_auth_methods, + **endpoints, + "revocation_endpoint": field("revocation_endpoint"), + "token_endpoint_auth_methods_supported": ( + [str(m) for m in auth_methods_raw] if isinstance(auth_methods_raw, list) else [] + ), } - # ---- internals: JWT verification -------------------------------------- + # ---- internals: JWT verification + mapping ---------------------------- def _get_jwks_client(self) -> Any: if self._jwks_client is None: - from jwt import PyJWKClient # lazy import - - disco = self._get_discovery() - self._jwks_client = PyJWKClient( - disco["jwks_uri"], - cache_keys=True, - lifespan=_JWKS_CACHE_SECONDS, - headers={ - "Accept": "application/json", - "User-Agent": "HermesAgent/1.0", - }, - ) + self._jwks_client = make_jwks_client(self._get_discovery()["jwks_uri"]) return self._jwks_client def _verify_id_token(self, id_token: str) -> Dict[str, Any]: - import jwt # lazy import — keeps startup fast for the ungated path + issuer = self._get_discovery()["issuer"] + return verify_jwt( + id_token, self._get_jwks_client(), algorithms=list(_ALLOWED_ID_TOKEN_ALGS), + audience=self._client_id, issuer=issuer, label="ID token", + ) - disco = self._get_discovery() - - try: - signing_key = self._get_jwks_client().get_signing_key_from_jwt( - id_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( - id_token, - signing_key.key, - algorithms=list(_ALLOWED_ID_TOKEN_ALGS), - audience=self._client_id, - issuer=disco["issuer"], - options={"require": ["exp", "iat", "aud", "iss", "sub"]}, - ) - except jwt.ExpiredSignatureError as exc: - # verify_session() catches this and returns None per protocol. - raise InvalidCodeError(f"ID token expired: {exc}") from exc - except jwt.InvalidTokenError as exc: - # Surface the actual iss/aud the token carried so operators can - # debug config drift between the configured issuer/client_id and - # what the IDP emits. Decoding-without-verification is safe here: - # we already failed verification and never trust these values. - details = "" - try: - unverified = jwt.decode( - id_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={disco['issuer']!r} " - f"aud={self._client_id!r}]" - ) - except Exception: - pass - raise ProviderError( - f"ID token verification failed: {exc}{details}" - ) from exc - - return claims - - # ---- internals: mapping + misc ---------------------------------------- - - def _session_from_tokens( - self, - *, - id_token: str, - refresh_token: str, - claims: Dict[str, Any], - ) -> Session: + def _session(self, id_token: str, refresh_token: str, claims: Dict[str, Any]) -> Session: """Map verified OIDC claims onto a Session. The verified ID token is stored in ``Session.access_token`` so the - per-request ``verify_session`` re-verifies a real JWT. The opaque - OAuth access token is intentionally NOT stored — Hermes does not call - any resource API with it; the dashboard only needs identity. + per-request ``verify_session`` re-verifies a real JWT; the opaque OAuth + access token is not kept — the dashboard only needs identity. """ - user_id = str(claims.get("sub", "")) - if not user_id: - raise ProviderError("ID token missing 'sub' (user_id) claim") - email = str(claims.get("email", "") or "") - # Standard OIDC display claims, in preference order. - display_name = str( - claims.get("name") - or claims.get("preferred_username") - or claims.get("nickname") - or email - or "" - ) - # Org/tenant is non-standard; accept the common spellings. Groups, if - # present as a list, are joined so multi-tenant IDPs surface *something* - # rather than dropping the info — org_id is a free-form string. + # Org/tenant is non-standard: accept common spellings, else join + # ``groups`` so multi-tenant IDPs surface *something* (free-form string). org_id = claims.get("org_id") or claims.get("organization") or "" - if not org_id: - groups = claims.get("groups") - if isinstance(groups, list) and groups: - org_id = ",".join(str(g) for g in groups) - org_id = str(org_id or "") - - return Session( - user_id=user_id, - email=email, - display_name=display_name, - org_id=org_id, - provider=self.name, - expires_at=int(claims["exp"]), - access_token=id_token, - refresh_token=refresh_token, + groups = claims.get("groups") + if not org_id and isinstance(groups, list) and groups: + org_id = ",".join(str(g) for g in groups) + return session_from_claims( + self.name, claims, access_token=id_token, refresh_token=refresh_token, + label="ID token", email=email, + display_name=str( + claims.get("name") or claims.get("preferred_username") + or claims.get("nickname") or email or "" + ), + org_id=str(org_id or ""), ) - def _validate_redirect_uri(self, redirect_uri: str) -> None: - """Fast-fail obviously-broken redirect_uris before bouncing to the IDP. - - The IDP's own allowlist is authoritative; this just catches the common - operator-error case with a clear message. 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; the IDP makes the final call on which - redirect_uris are permitted. Mirrors the nous provider. - """ - 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 {} - - -# --------------------------------------------------------------------------- -# Plugin entry point -# --------------------------------------------------------------------------- +# ---- Plugin entry point ---- def _load_config_oauth_section() -> dict: - """Return the ``dashboard.oauth`` block from config.yaml, or ``{}``. - - Robust to load_config() raising, the ``dashboard`` key being absent or - non-dict, and ``oauth`` being present but not a dict — each falls through - to ``{}`` so callers can rely on ``.get(...)``. - """ - 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-self-hosted: 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 _oidc_subsection(oauth_section: dict) -> dict: - """Return the ``dashboard.oauth.self_hosted`` sub-block, or ``{}``.""" - sub = oauth_section.get("self_hosted") - return sub if isinstance(sub, dict) else {} - - -def _resolve_setting(env_var: str, cfg_value: Any) -> str: - """env-wins-config with empty-is-unset precedence. - - 1. ``env_var`` when non-empty after strip (an empty provisioned secret - must not shadow a valid config.yaml entry). - 2. ``cfg_value`` from config.yaml. - 3. Empty string. - """ - env = os.environ.get(env_var, "").strip() - if env: - return env - return str(cfg_value or "").strip() + return load_config_section(logger, "dashboard-auth-self-hosted", "dashboard", "oauth", "self_hosted") def register(ctx) -> None: - """Plugin entry — called by the plugin loader at startup. + """Register :class:`SelfHostedOIDCProvider` when issuer + client_id are set. - Registers :class:`SelfHostedOIDCProvider` only when both an issuer and a - client_id are configured (via ``HERMES_DASHBOARD_OIDC_*`` env vars or the - ``dashboard.oauth.self_hosted`` block in config.yaml). Operator-owned - loopback / ``--insecure`` dashboards leave these unset, so the plugin is a - no-op for them. - - On skip, writes a reason to :data:`LAST_SKIP_REASON` that names BOTH - configuration surfaces so operators don't guess wrong about which to set. + On skip, :data:`LAST_SKIP_REASON` names BOTH configuration surfaces so + operators don't guess wrong about which to set. """ global LAST_SKIP_REASON LAST_SKIP_REASON = "" - oauth_section = _load_config_oauth_section() - oidc_cfg = _oidc_subsection(oauth_section) + oidc_cfg = _load_config_oauth_section() - issuer = _resolve_setting( - "HERMES_DASHBOARD_OIDC_ISSUER", oidc_cfg.get("issuer") - ) - client_id = _resolve_setting( - "HERMES_DASHBOARD_OIDC_CLIENT_ID", oidc_cfg.get("client_id") - ) - scopes = ( - _resolve_setting("HERMES_DASHBOARD_OIDC_SCOPES", oidc_cfg.get("scopes")) - or _DEFAULT_SCOPES - ) - # Optional — set only for a confidential client. A credential, so the - # canonical home is the env var / ~/.hermes/.env; config.yaml is supported - # for precedence symmetry. Empty ⇒ public client (unchanged behaviour). - client_secret = _resolve_setting( - "HERMES_DASHBOARD_OIDC_CLIENT_SECRET", oidc_cfg.get("client_secret") - ) + def setting(env_name: str, cfg_key: str) -> str: + return resolve_env_or_cfg(env_name, oidc_cfg.get(cfg_key)) + + issuer = setting("HERMES_DASHBOARD_OIDC_ISSUER", "issuer") + client_id = setting("HERMES_DASHBOARD_OIDC_CLIENT_ID", "client_id") + scopes = setting("HERMES_DASHBOARD_OIDC_SCOPES", "scopes") or _DEFAULT_SCOPES + # Credential: canonical home is the env var / ~/.hermes/.env. Empty ⇒ public client. + client_secret = setting("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "client_secret") if not issuer or not client_id: LAST_SKIP_REASON = ( - "Self-hosted OIDC dashboard auth is not configured. Set both an " - "issuer and a client_id — either as env vars " - "(HERMES_DASHBOARD_OIDC_ISSUER + HERMES_DASHBOARD_OIDC_CLIENT_ID) " - "or under dashboard.oauth.self_hosted.{issuer,client_id} in " - "config.yaml — or pass --insecure to skip the OAuth gate " - "entirely. (issuer set: %s; client_id set: %s)" + "Self-hosted OIDC dashboard auth is not configured. Set both an issuer and " + "a client_id — either as env vars (HERMES_DASHBOARD_OIDC_ISSUER + " + "HERMES_DASHBOARD_OIDC_CLIENT_ID) or under " + "dashboard.oauth.self_hosted.{issuer,client_id} in config.yaml — or pass " + "--insecure to skip the OAuth gate entirely. (issuer set: %s; client_id set: %s)" % (bool(issuer), bool(client_id)) ) logger.debug("dashboard-auth-self-hosted: %s", LAST_SKIP_REASON) @@ -840,15 +424,10 @@ def register(ctx) -> None: try: provider = SelfHostedOIDCProvider( - issuer=issuer, - client_id=client_id, - scopes=scopes, - client_secret=client_secret, + issuer=issuer, client_id=client_id, scopes=scopes, client_secret=client_secret ) except (ValueError, ProviderError) as exc: - LAST_SKIP_REASON = ( - f"SelfHostedOIDCProvider construction failed: {exc}" - ) + LAST_SKIP_REASON = f"SelfHostedOIDCProvider construction failed: {exc}" logger.warning("dashboard-auth-self-hosted: %s", LAST_SKIP_REASON) return @@ -856,9 +435,5 @@ def register(ctx) -> None: logger.info( "dashboard-auth-self-hosted: registered provider " "(issuer=%s, client_id=%s, scopes=%r, confidential=%s)", - issuer, - client_id, - scopes, - # Log only whether a secret is present, never the secret itself. - bool(client_secret), + issuer, client_id, scopes, bool(client_secret), # never log the secret itself ) diff --git a/tests/plugins/dashboard_auth/test_nous_provider.py b/tests/plugins/dashboard_auth/test_nous_provider.py index 19cb0bf160..09dbef6593 100644 --- a/tests/plugins/dashboard_auth/test_nous_provider.py +++ b/tests/plugins/dashboard_auth/test_nous_provider.py @@ -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") diff --git a/tests/plugins/dashboard_auth/test_self_hosted_provider.py b/tests/plugins/dashboard_auth/test_self_hosted_provider.py index fcd767279e..96601304c3 100644 --- a/tests/plugins/dashboard_auth/test_self_hosted_provider.py +++ b/tests/plugins/dashboard_auth/test_self_hosted_provider.py @@ -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",