Hermes-agent
This commit is contained in:
@@ -0,0 +1,491 @@
|
||||
"""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.
|
||||
|
||||
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 surfaces (env wins over config.yaml when set non-empty),
|
||||
mirroring the Nous provider's precedence convention:
|
||||
|
||||
``config.yaml`` — canonical surface::
|
||||
|
||||
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)
|
||||
|
||||
Environment overrides::
|
||||
|
||||
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).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCredentialsError,
|
||||
LoginStart,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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).
|
||||
_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).
|
||||
_SIG_LEN = hashlib.sha256().digest_size
|
||||
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing (stdlib scrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return a ``scrypt$n$r$p$<salt_b64>$<dk_b64>`` hash string.
|
||||
|
||||
Use this to precompute ``password_hash`` for config.yaml so plaintext
|
||||
never sits at rest. Exposed as a module function so operators can run
|
||||
``python -c "from plugins.dashboard_auth.basic import hash_password;
|
||||
print(hash_password('pw'))"``.
|
||||
"""
|
||||
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()}"
|
||||
)
|
||||
|
||||
|
||||
def _verify_password(password: str, encoded: str) -> bool:
|
||||
"""Constant-time scrypt verify. False on any malformed hash string."""
|
||||
try:
|
||||
scheme, n_s, r_s, p_s, salt_b64, dk_b64 = encoded.split("$")
|
||||
if scheme != "scrypt":
|
||||
return False
|
||||
n, r, p = int(n_s), int(r_s), int(p_s)
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(dk_b64)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
try:
|
||||
actual = hashlib.scrypt(
|
||||
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.
|
||||
_DUMMY_HASH = hash_password("dummy-password-for-constant-time-verify")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token signing (stateless HMAC-signed blobs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _sign(payload: dict, secret: bytes) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
sig = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(raw + sig).decode()
|
||||
|
||||
|
||||
def _unsign(token: str, secret: bytes) -> Optional[dict]:
|
||||
try:
|
||||
blob = base64.urlsafe_b64decode(token.encode())
|
||||
if len(blob) <= _SIG_LEN:
|
||||
return None
|
||||
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
|
||||
expected = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BasicAuthProvider(DashboardAuthProvider):
|
||||
"""Username/password provider with stateless HMAC-signed sessions."""
|
||||
|
||||
name = "basic"
|
||||
display_name = "Username & Password"
|
||||
supports_password = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
username: str,
|
||||
password_hash: str,
|
||||
secret: bytes,
|
||||
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
|
||||
) -> None:
|
||||
if not username:
|
||||
raise ValueError("username must be non-empty")
|
||||
if not password_hash:
|
||||
raise ValueError("password_hash must be non-empty")
|
||||
if len(secret) < 16:
|
||||
raise ValueError("secret must be at least 16 bytes")
|
||||
self._username = username
|
||||
self._password_hash = password_hash
|
||||
self._secret = secret
|
||||
self._ttl = max(60, int(ttl_seconds))
|
||||
|
||||
# ---- OAuth methods: not used (pure-password provider) ------------------
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
|
||||
# ---- 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.
|
||||
username_ok = hmac.compare_digest(
|
||||
username.encode("utf-8"), self._username.encode("utf-8")
|
||||
)
|
||||
target_hash = self._password_hash if username_ok else _DUMMY_HASH
|
||||
password_ok = _verify_password(password, target_hash)
|
||||
if not (username_ok and password_ok):
|
||||
raise InvalidCredentialsError("invalid username or password")
|
||||
return self._mint_session(self._username)
|
||||
|
||||
# ---- 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())
|
||||
):
|
||||
return None
|
||||
return self._session_from_payload(access_token, "", payload)
|
||||
|
||||
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())
|
||||
):
|
||||
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
|
||||
return None
|
||||
|
||||
# ---- internals ---------------------------------------------------------
|
||||
|
||||
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 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,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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()
|
||||
|
||||
|
||||
def _resolve_secret(cfg_section: dict) -> bytes:
|
||||
"""Resolve the token-signing secret.
|
||||
|
||||
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).
|
||||
"""
|
||||
raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section, "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."
|
||||
)
|
||||
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)
|
||||
if len(decoded) >= 16:
|
||||
return decoded
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return raw.encode("utf-8")
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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"
|
||||
)
|
||||
|
||||
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."
|
||||
)
|
||||
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)."
|
||||
)
|
||||
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()
|
||||
if plaintext_from_env:
|
||||
password_hash = hash_password(plaintext_from_env)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed env-supplied password in-memory "
|
||||
"(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. "
|
||||
"For production, precompute dashboard.basic_auth.password_hash "
|
||||
"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:
|
||||
ttl = _DEFAULT_TTL_SECONDS
|
||||
|
||||
try:
|
||||
provider = BasicAuthProvider(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
secret=secret,
|
||||
ttl_seconds=ttl,
|
||||
)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
|
||||
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: registered password provider (username=%s)",
|
||||
username,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
name: basic
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — username/password (no OAuth IDP). A self-hosted 'just put a password on my dashboard' provider. Activates when dashboard.basic_auth.username plus a password (or password_hash) are configured via config.yaml (canonical surface) or the HERMES_DASHBOARD_BASIC_AUTH_* env vars. Sessions are stateless HMAC-signed tokens minted by the provider; password hashing uses stdlib scrypt (no third-party dependency). Set dashboard.basic_auth.secret for restart-surviving / multi-worker sessions."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
@@ -0,0 +1,667 @@
|
||||
"""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.
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty):
|
||||
|
||||
``config.yaml`` — canonical surface::
|
||||
|
||||
dashboard:
|
||||
oauth:
|
||||
client_id: agent:{agent_instance_id} # required
|
||||
portal_url: https://portal.example # optional
|
||||
|
||||
Environment overrides — used by Fly.io's platform-secret injection so
|
||||
per-deploy values don't need to bake into ``config.yaml``:
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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,
|
||||
Session,
|
||||
)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
"""Nous Portal OAuth via authorization-code + PKCE (S256)."""
|
||||
|
||||
name = "nous"
|
||||
display_name = "Nous Research"
|
||||
|
||||
def __init__(self, *, client_id: str, portal_url: str) -> None:
|
||||
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}"
|
||||
)
|
||||
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
|
||||
|
||||
# ---- 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()
|
||||
)
|
||||
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,
|
||||
) -> 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
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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.
|
||||
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)
|
||||
|
||||
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
|
||||
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 _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,
|
||||
)
|
||||
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 jwt.PyJWKClientError as exc:
|
||||
raise ProviderError(f"JWKS lookup failed: {exc}") from exc
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise ProviderError(f"JWKS lookup failed: {exc!r}") 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."""
|
||||
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:
|
||||
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(
|
||||
"Nous Portal token missing oauth_contract_version claim "
|
||||
"(contract says it should be %d); proceeding anyway.",
|
||||
_EXPECTED_CONTRACT_VERSION,
|
||||
)
|
||||
return
|
||||
if contract_version != _EXPECTED_CONTRACT_VERSION:
|
||||
raise ProviderError(
|
||||
f"unsupported oauth_contract_version={contract_version!r}, "
|
||||
f"expected {_EXPECTED_CONTRACT_VERSION}"
|
||||
)
|
||||
|
||||
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="",
|
||||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — called by the plugin loader at startup.
|
||||
|
||||
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.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
client_id = _resolve_client_id()
|
||||
portal_url = _resolve_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."
|
||||
)
|
||||
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."
|
||||
)
|
||||
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
try:
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
name: nous
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — OAuth 2.0 (authorization-code + PKCE) against Nous Portal. Auto-activates when a client_id is configured via either dashboard.oauth.client_id in config.yaml (canonical surface) or HERMES_DASHBOARD_OAUTH_CLIENT_ID env var (operator override; Portal injects this at Fly.io provisioning). dashboard.oauth.portal_url / HERMES_DASHBOARD_PORTAL_URL are optional and default to https://portal.nousresearch.com."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
@@ -0,0 +1,736 @@
|
||||
"""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:
|
||||
|
||||
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``,
|
||||
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`.
|
||||
|
||||
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.)
|
||||
|
||||
Public PKCE clients only. Confidential clients (with a ``client_secret``) are
|
||||
not yet supported — see the ``# TODO(confidential-client)`` seam in
|
||||
``complete_login`` / ``refresh_session``. Self-hosters configuring a CLI/SPA
|
||||
client almost always register a public + PKCE client, which is the smaller,
|
||||
simpler surface.
|
||||
|
||||
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)::
|
||||
|
||||
# config.yaml — canonical surface
|
||||
dashboard:
|
||||
oauth:
|
||||
provider: self-hosted
|
||||
self_hosted:
|
||||
issuer: https://auth.example.com/application/o/hermes/ # required
|
||||
client_id: hermes-dashboard # required
|
||||
scopes: "openid profile email" # optional
|
||||
|
||||
# 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"
|
||||
|
||||
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".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
)
|
||||
|
||||
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.
|
||||
_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.
|
||||
_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_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.
|
||||
"""
|
||||
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",
|
||||
):
|
||||
return url
|
||||
raise ProviderError(
|
||||
f"OIDC {field} must be https:// (or http on localhost), got {url!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
"""Generic self-hosted OpenID Connect provider (authorization-code + PKCE)."""
|
||||
|
||||
name = "self-hosted"
|
||||
display_name = "Self-Hosted OIDC"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
issuer: str,
|
||||
client_id: str,
|
||||
scopes: str = _DEFAULT_SCOPES,
|
||||
) -> None:
|
||||
if not issuer:
|
||||
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).
|
||||
self._issuer = issuer.rstrip("/")
|
||||
_require_https_or_loopback(self._issuer, field="issuer")
|
||||
self._client_id = client_id
|
||||
self._scopes = scopes.strip() or _DEFAULT_SCOPES
|
||||
|
||||
# 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).
|
||||
self._discovery: Dict[str, Any] | None = None
|
||||
self._discovery_fetched_at: float = 0.0
|
||||
self._discovery_lock = threading.Lock()
|
||||
self._jwks_client: Any = None
|
||||
|
||||
# ---- public API (DashboardAuthProvider) -------------------------------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
self._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()
|
||||
)
|
||||
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,
|
||||
) -> 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,
|
||||
}
|
||||
# TODO(confidential-client): when client_secret support lands, add it
|
||||
# here (and switch to HTTP Basic auth if the IDP's
|
||||
# token_endpoint_auth_methods_supported prefers client_secret_basic).
|
||||
return self._exchange(
|
||||
disco["token_endpoint"], data, bad_request_exc=InvalidCodeError
|
||||
)
|
||||
|
||||
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,
|
||||
}
|
||||
# TODO(confidential-client): add client_secret here when supported.
|
||||
return self._exchange(
|
||||
disco["token_endpoint"],
|
||||
data,
|
||||
bad_request_exc=RefreshExpiredError,
|
||||
previous_refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
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.
|
||||
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
|
||||
)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Best-effort RFC 7009 revocation if the IDP advertised an endpoint.
|
||||
# Must never raise — logout is client-side cookie clearing regardless.
|
||||
if not refresh_token:
|
||||
return None
|
||||
try:
|
||||
disco = self._get_discovery()
|
||||
except ProviderError:
|
||||
return None
|
||||
endpoint = str(disco.get("revocation_endpoint") or "").strip()
|
||||
if not endpoint:
|
||||
return None
|
||||
try:
|
||||
httpx.post(
|
||||
endpoint,
|
||||
data={
|
||||
"token": refresh_token,
|
||||
"token_type_hint": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
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 _exchange(
|
||||
self,
|
||||
token_endpoint: str,
|
||||
data: Dict[str, str],
|
||||
*,
|
||||
bad_request_exc: type[Exception],
|
||||
previous_refresh_token: str = "",
|
||||
) -> Session:
|
||||
"""POST the 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.
|
||||
"""
|
||||
try:
|
||||
response = httpx.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers={"Accept": "application/json"},
|
||||
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(
|
||||
"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
|
||||
)
|
||||
|
||||
# ---- 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
|
||||
):
|
||||
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
|
||||
|
||||
def _discovery_url(self) -> str:
|
||||
# RFC 8414 / OIDC Discovery: ``{issuer}/.well-known/openid-configuration``.
|
||||
return f"{self._issuer}/.well-known/openid-configuration"
|
||||
|
||||
def _fetch_discovery(self) -> Dict[str, Any]:
|
||||
url = self._discovery_url()
|
||||
try:
|
||||
response = httpx.get(
|
||||
url,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_DISCOVERY_TIMEOUT_SEC,
|
||||
)
|
||||
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)
|
||||
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:
|
||||
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()
|
||||
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}"
|
||||
)
|
||||
|
||||
_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()
|
||||
|
||||
return {
|
||||
"issuer": advertised_issuer or self._issuer,
|
||||
"authorization_endpoint": authorization_endpoint,
|
||||
"token_endpoint": token_endpoint,
|
||||
"jwks_uri": jwks_uri,
|
||||
"revocation_endpoint": revocation_endpoint,
|
||||
}
|
||||
|
||||
# ---- internals: JWT verification --------------------------------------
|
||||
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
||||
disco = self._get_discovery()
|
||||
|
||||
try:
|
||||
signing_key = self._get_jwks_client().get_signing_key_from_jwt(
|
||||
id_token
|
||||
)
|
||||
except jwt.PyJWKClientError as exc:
|
||||
raise ProviderError(f"JWKS lookup failed: {exc}") from exc
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
raise ProviderError(f"JWKS lookup failed: {exc!r}") 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:
|
||||
"""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.
|
||||
"""
|
||||
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_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,
|
||||
)
|
||||
|
||||
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. 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 parsed.scheme == "http" and parsed.hostname not in (
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
):
|
||||
raise ProviderError(
|
||||
"redirect_uri may only use http:// for localhost/127.0.0.1, "
|
||||
f"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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — called by the plugin loader at startup.
|
||||
|
||||
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.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
oauth_section = _load_config_oauth_section()
|
||||
oidc_cfg = _oidc_subsection(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
|
||||
)
|
||||
|
||||
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)"
|
||||
% (bool(issuer), bool(client_id))
|
||||
)
|
||||
logger.debug("dashboard-auth-self-hosted: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
try:
|
||||
provider = SelfHostedOIDCProvider(
|
||||
issuer=issuer, client_id=client_id, scopes=scopes
|
||||
)
|
||||
except (ValueError, ProviderError) as exc:
|
||||
LAST_SKIP_REASON = (
|
||||
f"SelfHostedOIDCProvider construction failed: {exc}"
|
||||
)
|
||||
logger.warning("dashboard-auth-self-hosted: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-self-hosted: registered provider "
|
||||
"(issuer=%s, client_id=%s, scopes=%r)",
|
||||
issuer,
|
||||
client_id,
|
||||
scopes,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
name: self-hosted
|
||||
version: 1.0.0
|
||||
description: "Dashboard auth provider — generic self-hosted OpenID Connect (authorization-code + PKCE, public client). Works against any conformant OIDC identity provider (Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …) via OIDC discovery. Auto-activates when an issuer + client_id are configured, either under dashboard.oauth.self_hosted.{issuer,client_id} in config.yaml (canonical surface) or via the HERMES_DASHBOARD_OIDC_ISSUER + HERMES_DASHBOARD_OIDC_CLIENT_ID env vars (operator override / secret injection). Scopes default to 'openid profile email'. Verifies the OIDC ID token (RS256/ES256) against the discovered jwks_uri."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
- HERMES_DASHBOARD_OIDC_ISSUER
|
||||
- HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
Reference in New Issue
Block a user