Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
+491
View File
@@ -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,
)
+7
View File
@@ -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
+667
View File
@@ -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,
)
+7
View File
@@ -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