refactor(plugins/kanban,dashboard_auth): split update_task/bulk_update into phase helpers, dict-driven orchestration writes; unify provider register() via _shared.register_provider/SkipRegistration

This commit is contained in:
Teknium
2026-09-02 21:09:26 -07:00
parent 113f04616b
commit e986577dca
6 changed files with 575 additions and 1013 deletions
+72 -91
View File
@@ -1,9 +1,8 @@
"""Helpers shared by the bundled dashboard-auth providers.
Each provider module keeps its own ``logger`` / ``LAST_SKIP_REASON`` (the
gate reads those by module) and its ``register(ctx)``; the config/env
resolution, PKCE login start, token-endpoint exchange and JWT verification
boilerplate lives here.
Each provider module keeps its own ``logger`` / ``LAST_SKIP_REASON`` (the gate reads
those by module) and its ``register(ctx)``; the config/env resolution, skip/register
bookkeeping, PKCE login start, token-endpoint exchange and JWT verification live here.
"""
from __future__ import annotations
@@ -14,17 +13,11 @@ import logging
import os
import secrets
import urllib.parse
from typing import Any, Dict, Optional
from typing import Any, Callable, Dict, Optional
import httpx
from hermes_cli.dashboard_auth import (
InvalidCodeError,
LoginStart,
ProviderError,
Session,
classify_jwks_lookup_error,
)
from hermes_cli.dashboard_auth import InvalidCodeError, LoginStart, ProviderError, Session, classify_jwks_lookup_error
# JWKS Cache-Control max-age (nous contract C7); self-hosted mirrors it.
JWKS_CACHE_SECONDS = 300
@@ -35,11 +28,8 @@ JSON_HEADERS = {"Accept": "application/json"}
# ---- Config / env resolution ----
def load_config_section(logger: logging.Logger, tag: str, *path: str) -> dict:
"""Return the ``config.yaml`` block at ``path`` as a dict, or ``{}``.
Robust to load_config() raising (fresh install, malformed YAML), keys
being absent, or the value not being a dict — every shape yields ``{}``.
"""
"""The ``config.yaml`` block at ``path`` as a dict, or ``{}`` — robust to load_config()
raising (fresh install, malformed YAML), absent keys, or a non-dict value."""
try:
from hermes_cli.config import cfg_get, load_config
@@ -57,6 +47,40 @@ def resolve_env_or_cfg(env_name: str, cfg_value: Any) -> str:
return os.environ.get(env_name, "").strip() or str(cfg_value or "").strip()
# ---- register() bookkeeping ----
class SkipRegistration(Exception):
"""Raised by a provider's settings resolver to decline registration; ``reason`` is
the operator-facing text stored in the module's ``LAST_SKIP_REASON``."""
def __init__(self, reason: str, level: str = "debug") -> None:
super().__init__(reason)
self.reason, self.level = reason, level
def register_provider(
ctx, logger: logging.Logger, tag: str, provider_cls: type, settings: Callable[[], dict],
) -> tuple[Optional[dict], str]:
"""Build ``provider_cls(**settings())`` and register it on ``ctx``.
Returns ``(kwargs, "")`` on success, ``(None, skip_reason)`` when ``settings`` raised
``SkipRegistration`` (logged at its level) or construction raised ``ValueError`` /
``ProviderError`` (logged as a warning). Callers store the reason in ``LAST_SKIP_REASON``.
"""
try:
kwargs = settings()
provider = provider_cls(**kwargs)
except SkipRegistration as skip:
getattr(logger, skip.level)("%s: %s", tag, skip.reason)
return None, skip.reason
except (ValueError, ProviderError) as exc:
reason = f"{provider_cls.__name__} construction failed: {exc}"
logger.warning("%s: %s", tag, reason)
return None, reason
ctx.register_dashboard_auth_provider(provider)
return kwargs, ""
# ---- OAuth / PKCE ----
def b64url_no_pad(raw: bytes) -> str:
@@ -65,11 +89,9 @@ def b64url_no_pad(raw: bytes) -> str:
def validate_redirect_uri(redirect_uri: str) -> None:
"""Fast-fail obviously-broken redirect_uris before bouncing to the IDP.
The IDP's allowlist is authoritative. Any ``http://`` host is allowed so
dashboards behind TLS-terminating proxies / on LAN IPs aren't rejected.
"""
"""Fast-fail obviously-broken redirect_uris before bouncing to the IDP (whose allowlist
is authoritative). Any ``http://`` host is allowed so dashboards behind TLS-terminating
proxies / on LAN IPs aren't rejected."""
parsed = urllib.parse.urlparse(redirect_uri)
if parsed.scheme not in ("https", "http"):
raise ProviderError(f"redirect_uri must be http(s), got {redirect_uri!r}")
@@ -77,23 +99,15 @@ def validate_redirect_uri(redirect_uri: str) -> None:
raise ProviderError(f"redirect_uri path must end with '/auth/callback', got {redirect_uri!r}")
def pkce_login_start(
authorize_url: str, *, client_id: str, scope: str, redirect_uri: str
) -> LoginStart:
"""Build the authorization-code + PKCE (S256) redirect and cookie payload.
Callers validate ``redirect_uri`` first. The auth-route layer expects
``cookie_payload["hermes_session_pkce"]`` as a flat ``state=…;verifier=…``
string (it prepends ``provider=``).
"""
def pkce_login_start(authorize_url: str, *, client_id: str, scope: str, redirect_uri: str) -> LoginStart:
"""Build the authorization-code + PKCE (S256) redirect and cookie payload. Callers
validate ``redirect_uri`` first. The auth-route layer expects
``cookie_payload["hermes_session_pkce"]`` as a flat ``state=…;verifier=…`` string
(it prepends ``provider=``)."""
code_verifier = b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
state = b64url_no_pad(secrets.token_bytes(32))
params = {
"response_type": "code",
"client_id": client_id,
"redirect_uri": redirect_uri,
"scope": scope,
"state": state,
"response_type": "code", "client_id": client_id, "redirect_uri": redirect_uri, "scope": scope, "state": state,
"code_challenge": b64url_no_pad(hashlib.sha256(code_verifier.encode("ascii")).digest()),
"code_challenge_method": "S256",
}
@@ -115,38 +129,26 @@ def parse_json_body(response: httpx.Response) -> Dict[str, Any]:
def exchange_token(
url: str,
data: Dict[str, str],
*,
headers: Optional[Dict[str, str]] = None,
bad_request_exc: type[Exception],
idp: str,
endpoint: str,
token_key: str,
missing_msg: str,
url: str, data: Dict[str, str], *, headers: Optional[Dict[str, str]] = None, bad_request_exc: type[Exception],
idp: str, endpoint: str, token_key: str, missing_msg: str,
) -> tuple[str, Dict[str, Any]]:
"""POST a token grant and return ``(token, payload)``.
A 400 (OAuth-shaped error envelope) raises ``bad_request_exc`` —
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError`` for
refresh — so the middleware's distinct handling is preserved. Any other
non-200, transport failure, missing ``token_key`` or non-bearer
``token_type`` raises ``ProviderError``. Redirects are deliberately NOT
followed: the body carries an auth code / refresh token.
A 400 (OAuth-shaped error envelope) raises ``bad_request_exc`` — ``InvalidCodeError``
for the auth-code path, ``RefreshExpiredError`` for refresh — so the middleware's
distinct handling is preserved. Any other non-200, transport failure, missing
``token_key`` or non-bearer ``token_type`` raises ``ProviderError``. Redirects are
deliberately NOT followed: the body carries an auth code / refresh token.
"""
try:
response = httpx.post(
url, data=data, headers={**JSON_HEADERS, **(headers or {})}, timeout=TOKEN_ENDPOINT_TIMEOUT_SEC
)
response = httpx.post(url, data=data, headers={**JSON_HEADERS, **(headers or {})}, timeout=TOKEN_ENDPOINT_TIMEOUT_SEC)
except httpx.RequestError as exc:
raise ProviderError(f"{endpoint} unreachable: {exc}") from exc
if response.status_code == 400:
error_code = parse_json_body(response).get("error", "invalid_request")
raise bad_request_exc(f"{idp} rejected token request: {error_code}")
if response.status_code != 200:
raise ProviderError(f"{endpoint} returned {response.status_code}: {response.text[:200]!r}")
payload = parse_json_body(response)
token = payload.get(token_key)
if not token or not isinstance(token, str):
@@ -165,37 +167,24 @@ def refresh_token_from(payload: Dict[str, Any], fallback: str = "") -> str:
def session_from_claims(
provider: str,
claims: Dict[str, Any],
*,
access_token: str,
refresh_token: str,
label: str = "token",
email: str = "",
display_name: str = "",
org_id: str = "",
provider: str, claims: Dict[str, Any], *, access_token: str, refresh_token: str,
label: str = "token", email: str = "", display_name: str = "", org_id: str = "",
) -> Session:
"""Map verified JWT claims onto a Session; ``sub`` is mandatory."""
user_id = str(claims.get("sub", ""))
if not user_id:
raise ProviderError(f"{label} missing 'sub' (user_id) claim")
return Session(
user_id=user_id,
email=email,
display_name=display_name,
org_id=org_id,
provider=provider,
expires_at=int(claims["exp"]),
access_token=access_token,
refresh_token=refresh_token,
user_id=user_id, email=email, display_name=display_name, org_id=org_id, provider=provider,
expires_at=int(claims["exp"]), access_token=access_token, refresh_token=refresh_token,
)
# ---- JWT verification ----
def make_jwks_client(jwks_url: str) -> Any:
"""PyJWKClient with explicit Accept/User-Agent (some WAFs block the
library default). Imported lazily so plugin discovery stays cheap."""
"""PyJWKClient with explicit Accept/User-Agent (some WAFs block the library default).
Imported lazily so plugin discovery stays cheap."""
from jwt import PyJWKClient
return PyJWKClient(
@@ -205,22 +194,15 @@ def make_jwks_client(jwks_url: str) -> Any:
def verify_jwt(
token: str,
jwks_client: Any,
*,
algorithms: list[str],
audience: str,
issuer: str,
label: str,
token: str, jwks_client: Any, *, algorithms: list[str], audience: str, issuer: str, label: str,
) -> Dict[str, Any]:
"""Verify ``token`` against ``jwks_client`` with pinned ``aud``/``iss``.
Unreachable JWKS → ``ProviderError`` (503); a bearer that is not one of
our JWTs (opaque peer key, foreign kid) → ``InvalidCodeError`` (None /
next provider); folding both into 503 broke peer-key bearers. Expiry raises
``InvalidCodeError`` (verify_session maps it to None); any other claim
failure raises ``ProviderError`` with the unverified iss/aud appended so
operators can spot config drift.
Unreachable JWKS → ``ProviderError`` (503); a bearer that is not one of our JWTs
(opaque peer key, foreign kid) → ``InvalidCodeError`` (None / next provider); folding
both into 503 broke peer-key bearers. Expiry raises ``InvalidCodeError`` (verify_session
maps it to None); any other claim failure raises ``ProviderError`` with the unverified
iss/aud appended so operators can spot config drift.
"""
import jwt # lazy — keeps startup fast for the ungated path
@@ -228,7 +210,6 @@ def verify_jwt(
signing_key = jwks_client.get_signing_key_from_jwt(token)
except Exception as exc:
raise classify_jwks_lookup_error(exc) from exc
try:
return jwt.decode(
token, signing_key.key, algorithms=algorithms, audience=audience, issuer=issuer,
@@ -237,8 +218,8 @@ def verify_jwt(
except jwt.ExpiredSignatureError as exc:
raise InvalidCodeError(f"{label} expired: {exc}") from exc
except jwt.InvalidTokenError as exc:
# Decoding without verification is safe here: verification already
# failed and these values are surfaced for diagnostics only, never trusted.
# Decoding without verification is safe here: verification already failed and
# these values are surfaced for diagnostics only, never trusted.
details = ""
try:
unverified = jwt.decode(token, options={"verify_signature": False, "verify_exp": False})
+49 -103
View File
@@ -1,10 +1,12 @@
"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP).
Same ``DashboardAuthProvider`` framework as the OAuth providers, but login is
a credential form (``supports_password = True`` + ``complete_password_login``);
everything downstream (cookies, verify, refresh, ws-tickets, logout) is shared.
No IDP and no database: sessions are stateless HMAC-signed tokens this provider
mints and verifies — zero infrastructure for a single-box dashboard.
Same ``DashboardAuthProvider`` framework as the OAuth providers, but login is a credential
form (``supports_password = True`` + ``complete_password_login``); cookies, verify, refresh,
ws-tickets and logout are shared. No IDP and no database: sessions are stateless
HMAC-signed tokens this provider mints and verifies. Passwords use stdlib scrypt;
``complete_password_login`` always performs a hash even for an unknown username, so the
endpoint is not a username-enumeration timing oracle. ``LAST_SKIP_REASON`` is read by
the gate's fail-closed branch when the plugin loads but declines to register.
Configuration (env wins over config.yaml when set non-empty)::
@@ -16,21 +18,11 @@ Configuration (env wins over config.yaml when set non-empty)::
secret: "<32+ random bytes, base64 or hex>" # optional signing key
session_ttl_seconds: 43200 # optional access-token lifetime (12h)
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
HERMES_DASHBOARD_BASIC_AUTH_USERNAME / _PASSWORD_HASH (preferred) / _PASSWORD (plaintext)
HERMES_DASHBOARD_BASIC_AUTH_SECRET / _TTL_SECONDS
Without ``secret`` a random per-process key is generated: sessions then don't
survive a restart or span multiple worker processes.
Passwords use stdlib :func:`hashlib.scrypt` (no third-party dependency).
``complete_password_login`` is constant-time and always performs a hash even
for an unknown username, so the endpoint is not a username-enumeration timing oracle.
``LAST_SKIP_REASON`` is read by the gate's fail-closed branch when the plugin
loads but declines to register.
"""
from __future__ import annotations
@@ -45,16 +37,11 @@ import secrets
import time
from typing import Optional
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
InvalidCredentialsError,
LoginStart,
RefreshExpiredError,
Session,
)
from plugins.dashboard_auth._shared import load_config_section, resolve_env_or_cfg
from hermes_cli.dashboard_auth import DashboardAuthProvider, InvalidCredentialsError, LoginStart, RefreshExpiredError, Session
from plugins.dashboard_auth._shared import SkipRegistration, load_config_section, register_provider, resolve_env_or_cfg
logger = logging.getLogger(__name__)
_TAG = "dashboard-auth-basic"
# The middleware transparently refreshes via the 30-day refresh token when the
# access token lapses, so the TTL controls refresh frequency, not login length.
@@ -78,17 +65,11 @@ 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.
Public so operators can precompute ``password_hash`` for config.yaml (the
plaintext then never sits at rest):
``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('pw'))"``.
"""
"""Return a ``scrypt$n$r$p$<salt_b64>$<dk_b64>`` hash string. Public so operators can
precompute ``password_hash`` for config.yaml (the plaintext then never sits at rest):
``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('pw'))"``."""
salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
dk = hashlib.scrypt(
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
dklen=_SCRYPT_DKLEN, maxmem=0,
)
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}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
@@ -104,10 +85,7 @@ def _verify_password(password: str, encoded: str) -> bool:
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,
)
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)
@@ -154,14 +132,7 @@ class BasicAuthProvider(DashboardAuthProvider):
display_name = "Username & Password"
supports_password = True
def __init__(
self,
*,
username: str,
password_hash: str,
secret: bytes,
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
) -> None:
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:
@@ -181,22 +152,17 @@ class BasicAuthProvider(DashboardAuthProvider):
"The login page POSTs to /auth/password-login instead."
)
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
) -> Session:
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:
# Always run a scrypt verify (real hash if the username matches, else
# the dummy) and compare the username with compare_digest too, so
# neither the username nor its length leaks via timing.
username_ok = hmac.compare_digest(
username.encode("utf-8"), self._username.encode("utf-8")
)
target_hash = self._password_hash if username_ok else _DUMMY_HASH
password_ok = _verify_password(password, target_hash)
# Always run a scrypt verify (real hash if the username matches, else the dummy)
# and compare the username with compare_digest too, so neither the username nor
# its length leaks via timing.
username_ok = hmac.compare_digest(username.encode("utf-8"), self._username.encode("utf-8"))
password_ok = _verify_password(password, self._password_hash if username_ok else _DUMMY_HASH)
if not (username_ok and password_ok):
raise InvalidCredentialsError("invalid username or password")
return self._mint_session(self._username)
@@ -207,8 +173,7 @@ class BasicAuthProvider(DashboardAuthProvider):
payload = _unsign(access_token, self._secret, "access")
if payload is None:
return None
user_id = str(payload.get("sub", ""))
return self._session(user_id, int(payload["exp"]), access_token, "")
return self._session(str(payload.get("sub", "")), int(payload["exp"]), access_token, "")
def refresh_session(self, *, refresh_token: str) -> Session:
if not refresh_token:
@@ -219,8 +184,7 @@ class BasicAuthProvider(DashboardAuthProvider):
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. Must not raise.
# Stateless tokens — nothing to revoke server-side; the session expires within its TTL. Must not raise.
return None
# ---- internals ---------------------------------------------------------
@@ -244,15 +208,13 @@ class BasicAuthProvider(DashboardAuthProvider):
# ---- Plugin entry point ----
def _load_config_basic_auth_section() -> dict:
return load_config_section(logger, "dashboard-auth-basic", "dashboard", "basic_auth")
return load_config_section(logger, _TAG, "dashboard", "basic_auth")
def _resolve_secret(cfg_section: dict) -> bytes:
"""Resolve the token-signing secret (base64, hex, or raw text).
When unset, generates a random per-process secret (sessions then don't
survive a restart or span multiple workers — logged at INFO).
"""
"""Resolve the token-signing secret (base64, hex, or raw text). When unset, generates
a random per-process secret (sessions then don't survive a restart or span multiple
workers — logged at INFO)."""
raw = resolve_env_or_cfg("HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section.get("secret"))
if not raw:
logger.info(
@@ -272,12 +234,8 @@ def _resolve_secret(cfg_section: dict) -> bytes:
return raw.encode("utf-8")
def register(ctx) -> None:
"""Register ``BasicAuthProvider`` when username + (password or
password_hash) are configured; a no-op for OAuth / ``--insecure`` setups."""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
def _settings() -> dict:
"""Resolve BasicAuthProvider kwargs from env/config; raises ``SkipRegistration``."""
section = _load_config_basic_auth_section()
def setting(env_name: str, cfg_key: str) -> str:
@@ -287,36 +245,27 @@ def register(ctx) -> None:
password_hash = setting("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", "password_hash")
plaintext = setting("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "password")
ttl_raw = setting("HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", "session_ttl_seconds")
if not username:
LAST_SKIP_REASON = (
raise SkipRegistration(
"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 = (
raise SkipRegistration(
"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)."
"with plugins.dashboard_auth.basic.hash_password).",
level="warning",
)
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
return
# Precedence: env password (hashed in-memory) overrides any config
# password_hash so operators can rotate without editing config; a config
# password_hash wins over a config-only plaintext password (preferred at-rest form).
# Precedence: env password (hashed in-memory) overrides any config password_hash so
# operators can rotate without editing config; a config password_hash wins over a
# config-only plaintext password (preferred at-rest form).
plaintext_from_env = os.environ.get("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "").strip()
if plaintext_from_env:
password_hash = hash_password(plaintext_from_env)
logger.info(
"dashboard-auth-basic: hashed env-supplied password in-memory "
"(overrides any config password_hash)."
)
logger.info("dashboard-auth-basic: hashed env-supplied password in-memory (overrides any config password_hash).")
elif not password_hash:
password_hash = hash_password(plaintext)
logger.info(
@@ -324,21 +273,18 @@ def register(ctx) -> None:
"For production, precompute dashboard.basic_auth.password_hash "
"and remove the plaintext password from config."
)
try:
ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
except ValueError:
ttl = _DEFAULT_TTL_SECONDS
return {"username": username, "password_hash": password_hash, "secret": _resolve_secret(section), "ttl_seconds": ttl}
try:
provider = BasicAuthProvider(
username=username, password_hash=password_hash,
secret=_resolve_secret(section), ttl_seconds=ttl,
)
except ValueError as exc:
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
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)
def register(ctx) -> None:
"""Register ``BasicAuthProvider`` when username + (password or
password_hash) are configured; a no-op for OAuth / ``--insecure`` setups."""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
kwargs, LAST_SKIP_REASON = register_provider(ctx, logger, _TAG, BasicAuthProvider, _settings)
if kwargs is not None:
logger.info("dashboard-auth-basic: registered password provider (username=%s)", kwargs["username"])
+45 -93
View File
@@ -1,33 +1,16 @@
"""DrainSecretProvider — shared-bearer-secret auth for the drain-control endpoint.
Uses the non-interactive token capability of the ``DashboardAuthProvider``
ABC (``supports_token`` / ``verify_token`` + the route-agnostic ``token_auth``
middleware seam). ``nous-account-service`` (NAS) provisions a **per-agent
unique** shared secret into each deployed agent's environment; this provider
verifies an inbound ``Authorization`` bearer against it with a constant-time
compare and vouches for the caller as the ``drain-control`` principal. No
login, cookie, session or refresh — the interactive ABC methods raise
``NotImplementedError``. A real auth plugin (not an ad-hoc header check on the
route) so the credential lives inside the dashboard auth framework.
Security properties:
* Per-agent unique secret — a leak's blast radius is one agent.
* Fail-CLOSED entropy gate at registration: a weak/short/low-entropy secret is
never silently accepted (>= 43 url-safe-base64 chars ~= 256 bits, enough
distinct characters, Shannon entropy floor).
* ``hmac.compare_digest`` on the request path — not a timing oracle.
Configuration — the secret is a CREDENTIAL, so it is env-only (provisioned by
NAS at deploy time); behavioural knobs live in config.yaml::
HERMES_DASHBOARD_DRAIN_SECRET # per-agent shared secret (>=43 url-safe-b64 chars)
dashboard:
drain_auth:
scope: drain # capability label attached to the principal
min_secret_chars: 43 # entropy bar (default 43 ~= 256 bits)
When the env var is unset the plugin is a no-op (records a skip reason).
Uses the non-interactive token capability of the ``DashboardAuthProvider`` ABC
(``supports_token`` / ``verify_token`` + the route-agnostic ``token_auth`` middleware
seam). ``nous-account-service`` (NAS) provisions a **per-agent unique** shared secret into
each agent's environment; this provider verifies an inbound bearer against it with a
constant-time compare and vouches for the caller as the ``drain-control`` principal. No
login/cookie/session/refresh — the interactive ABC methods raise ``NotImplementedError``.
Fail-CLOSED entropy gate at registration: a weak/short/low-entropy secret is never
silently accepted (>= 43 url-safe-base64 chars ~= 256 bits, enough distinct characters,
Shannon entropy floor). The secret is a CREDENTIAL, so it is env-only
(``HERMES_DASHBOARD_DRAIN_SECRET``); knobs ``scope`` / ``min_secret_chars`` live under
``dashboard.drain_auth`` in config.yaml. Unset env var → no-op (records a skip reason).
"""
from __future__ import annotations
@@ -38,15 +21,11 @@ import os
from collections import Counter
from typing import Optional
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
LoginStart,
Session,
TokenPrincipal,
)
from plugins.dashboard_auth._shared import load_config_section
from hermes_cli.dashboard_auth import DashboardAuthProvider, LoginStart, Session, TokenPrincipal
from plugins.dashboard_auth._shared import SkipRegistration, load_config_section, register_provider
logger = logging.getLogger(__name__)
_TAG = "dashboard-auth-drain"
# token_urlsafe(32) produces exactly 43 chars, so a correctly-provisioned
# secret clears the default bar exactly.
@@ -71,14 +50,10 @@ def _shannon_bits(value: str) -> float:
return per_char * n
def assess_secret_strength(
secret: str, *, min_chars: int = _DEFAULT_MIN_SECRET_CHARS
) -> Optional[str]:
"""Return a human-readable rejection reason if ``secret`` is too weak, else ``None``.
Checks, in order: length >= ``min_chars``, distinct chars >=
``_MIN_DISTINCT_CHARS``, Shannon entropy >= ``_MIN_SHANNON_BITS``.
"""
def assess_secret_strength(secret: str, *, min_chars: int = _DEFAULT_MIN_SECRET_CHARS) -> Optional[str]:
"""Human-readable rejection reason if ``secret`` is too weak, else ``None``. Checks, in
order: length >= ``min_chars``, distinct chars >= ``_MIN_DISTINCT_CHARS``, Shannon
entropy >= ``_MIN_SHANNON_BITS``."""
if not secret:
return "secret is empty"
if len(secret) < min_chars:
@@ -89,16 +64,10 @@ def assess_secret_strength(
)
distinct = len(set(secret))
if distinct < _MIN_DISTINCT_CHARS:
return (
f"secret has only {distinct} distinct characters (need >= "
f"{_MIN_DISTINCT_CHARS}); looks structured/low-entropy"
)
return f"secret has only {distinct} distinct characters (need >= {_MIN_DISTINCT_CHARS}); looks structured/low-entropy"
bits = _shannon_bits(secret)
if bits < _MIN_SHANNON_BITS:
return (
f"secret entropy too low: {bits:.0f} bits (need >= "
f"{_MIN_SHANNON_BITS:.0f}); looks structured/repeated"
)
return f"secret entropy too low: {bits:.0f} bits (need >= {_MIN_SHANNON_BITS:.0f}); looks structured/repeated"
return None
@@ -131,18 +100,14 @@ class DrainSecretProvider(DashboardAuthProvider):
# ---- interactive methods: unsupported (service credential only) --------
def start_login(self, *, redirect_uri: str) -> LoginStart:
raise NotImplementedError(
"DrainSecretProvider is a non-interactive service credential; there is no login flow."
)
raise NotImplementedError("DrainSecretProvider is a non-interactive service credential; there is no login flow.")
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
) -> Session:
def complete_login(self, *, code: str, state: str, code_verifier: str, redirect_uri: str) -> Session:
raise NotImplementedError("DrainSecretProvider is a non-interactive service credential.")
def verify_session(self, *, access_token: str) -> Optional[Session]:
# Never mints a Session, so never recognises a cookie. Return None
# (don't raise) so it stacks harmlessly in the cookie-verify loop.
# Never mints a Session, so never recognises a cookie. Return None (don't raise)
# so it stacks harmlessly in the cookie-verify loop.
return None
def refresh_session(self, *, refresh_token: str) -> Session:
@@ -155,64 +120,51 @@ class DrainSecretProvider(DashboardAuthProvider):
# ---- Plugin entry point ----
def _load_config_drain_auth_section() -> dict:
return load_config_section(logger, "dashboard-auth-drain", "dashboard", "drain_auth")
return load_config_section(logger, _TAG, "dashboard", "drain_auth")
def register(ctx) -> None:
"""Register ``DrainSecretProvider`` when a strong secret is set.
No-op (records a skip reason) when ``HERMES_DASHBOARD_DRAIN_SECRET`` is
unset or fails the entropy gate. On success also registers the drain
route as token-authable via the generic seam.
"""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
def _settings() -> dict:
"""Resolve DrainSecretProvider kwargs from env/config; raises ``SkipRegistration``."""
secret = os.environ.get("HERMES_DASHBOARD_DRAIN_SECRET", "").strip()
if not secret:
LAST_SKIP_REASON = (
raise SkipRegistration(
"HERMES_DASHBOARD_DRAIN_SECRET is not set. Set a per-agent >=256-bit secret "
"(e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`) to enable "
"NAS-driven drain coordination; leave it unset to disable the drain endpoint."
)
logger.debug("dashboard-auth-drain: %s", LAST_SKIP_REASON)
return
section = _load_config_drain_auth_section()
scope = str(section.get("scope", "drain") or "drain").strip() or "drain"
try:
min_chars = int(section.get("min_secret_chars", _DEFAULT_MIN_SECRET_CHARS))
except (TypeError, ValueError):
min_chars = _DEFAULT_MIN_SECRET_CHARS
reason = assess_secret_strength(secret, min_chars=min_chars)
if reason is not None:
LAST_SKIP_REASON = (
f"HERMES_DASHBOARD_DRAIN_SECRET rejected — {reason}. "
"The drain endpoint stays disabled (fail-closed)."
raise SkipRegistration(
f"HERMES_DASHBOARD_DRAIN_SECRET rejected — {reason}. The drain endpoint stays disabled (fail-closed).",
level="warning",
)
logger.warning("dashboard-auth-drain: %s", LAST_SKIP_REASON)
return {"secret": secret, "scope": scope}
def register(ctx) -> None:
"""Register ``DrainSecretProvider`` when a strong secret is set; no-op (records a skip
reason) when ``HERMES_DASHBOARD_DRAIN_SECRET`` is unset or fails the entropy gate. On
success also registers the drain route as token-authable via the generic seam."""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
kwargs, LAST_SKIP_REASON = register_provider(ctx, logger, _TAG, DrainSecretProvider, _settings)
if kwargs is None:
return
try:
provider = DrainSecretProvider(secret=secret, scope=scope)
except ValueError as exc:
LAST_SKIP_REASON = f"DrainSecretProvider construction failed: {exc}"
logger.warning("dashboard-auth-drain: %s", LAST_SKIP_REASON)
return
ctx.register_dashboard_auth_provider(provider)
# Opt the drain endpoint into the token-auth seam so the interactive
# cookie gate doesn't bounce NAS's bearer call.
# Opt the drain endpoint into the token-auth seam so the interactive cookie gate
# doesn't bounce NAS's bearer call.
try:
from hermes_cli.dashboard_auth.token_auth import register_token_route
register_token_route(DRAIN_ROUTE_PATH)
except Exception as exc: # noqa: BLE001 — seam import must not crash plugin load
logger.warning("dashboard-auth-drain: could not register token route %s: %s", DRAIN_ROUTE_PATH, exc)
logger.info(
"dashboard-auth-drain: registered drain service-credential provider (scope=%s, route=%s)",
scope, DRAIN_ROUTE_PATH,
kwargs["scope"], DRAIN_ROUTE_PATH,
)
+53 -108
View File
@@ -1,36 +1,18 @@
"""NousDashboardAuthProvider — Nous Portal OAuth (authorization-code + PKCE).
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``.
Bundled and auto-loaded, but only registers when a client_id is configured, so
loopback / ``--insecure`` operators are unaffected.
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``. Bundled and
auto-loaded, but only registers when a client_id is configured (``dashboard.oauth.client_id``
or ``HERMES_DASHBOARD_OAUTH_CLIENT_ID``, shape ``agent:{agent_instance_id}``; optional
``portal_url`` / ``HERMES_DASHBOARD_PORTAL_URL``). Env wins over config when set non-empty.
Configuration (env wins over config.yaml when set non-empty; an empty env value
is treated as unset so a provisioned-but-blank Fly secret can't shadow config)::
dashboard:
oauth:
client_id: agent:{agent_instance_id} # required
portal_url: https://portal.example # optional
HERMES_DASHBOARD_OAUTH_CLIENT_ID — shape ``agent:{agent_instance_id}``
HERMES_DASHBOARD_PORTAL_URL — default https://portal.nousresearch.com
Contract points encoded here:
- client_id is per-instance; its suffix is cross-checked against the token's
``agent_instance_id`` claim (defense-in-depth).
- scope is ``agent_dashboard:access`` only; audience is the bare client_id.
- access tokens are RS256 JWTs verified against ``/.well-known/jwks.json``
(JWKS cached 5 minutes).
- Portal issues a 24h *rotating* refresh token with reuse detection: the
middleware MUST persist ``Session.refresh_token`` back to the cookie on
every refresh, or the next refresh replays a rotated token and (outside a
60s grace) trips reuse-detection, revoking the whole session. A 400 on
refresh (expired / revoked / reuse-detected) → ``RefreshExpiredError`` →
re-login.
- ``oauth_contract_version``: missing → warn + proceed; ``!= 1`` → refuse.
``LAST_SKIP_REASON`` is read by the gate's fail-closed branch to surface a
specific operator message when the plugin loads but declines to register.
Contract points: the client_id suffix is cross-checked against the token's
``agent_instance_id`` claim; scope is ``agent_dashboard:access`` only, audience is the bare
client_id; access tokens are RS256 JWTs verified against ``/.well-known/jwks.json`` (cached
5 min). Portal issues a 24h *rotating* refresh token with reuse detection: the middleware
MUST persist ``Session.refresh_token`` back to the cookie on every refresh, or the next
refresh replays a rotated token and (outside a 60s grace) revokes the whole session; a 400
on refresh → ``RefreshExpiredError`` → re-login. ``oauth_contract_version``: missing →
warn + proceed; ``!= 1`` → refuse. ``LAST_SKIP_REASON`` is read by the gate's fail-closed branch.
"""
from __future__ import annotations
@@ -38,20 +20,15 @@ from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
InvalidCodeError,
LoginStart,
ProviderError,
RefreshExpiredError,
Session,
)
from hermes_cli.dashboard_auth import DashboardAuthProvider, InvalidCodeError, LoginStart, ProviderError, RefreshExpiredError, Session
from plugins.dashboard_auth._shared import (
SkipRegistration,
exchange_token,
load_config_section,
make_jwks_client,
pkce_login_start,
refresh_token_from,
register_provider,
resolve_env_or_cfg,
session_from_claims,
validate_redirect_uri,
@@ -59,6 +36,7 @@ from plugins.dashboard_auth._shared import (
)
logger = logging.getLogger(__name__)
_TAG = "dashboard-auth-nous"
_DEFAULT_PORTAL_URL = "https://portal.nousresearch.com"
_SCOPE = "agent_dashboard:access" # contract C3
@@ -74,8 +52,7 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
display_name = "Nous Research"
def __init__(self, *, client_id: str, portal_url: str) -> None:
# Defense-in-depth: register() filters too, but a malformed id must
# never construct a provider.
# Defense-in-depth: register() filters too, but a malformed id must never construct a provider.
if not client_id.startswith("agent:"):
raise ValueError(f"client_id must match contract shape 'agent:{{instance_id}}', got {client_id!r}")
self._client_id = client_id
@@ -90,22 +67,14 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
def start_login(self, *, redirect_uri: str) -> LoginStart:
validate_redirect_uri(redirect_uri)
return pkce_login_start(
self._authorize_url, client_id=self._client_id, scope=_SCOPE, redirect_uri=redirect_uri
)
return pkce_login_start(self._authorize_url, client_id=self._client_id, scope=_SCOPE, redirect_uri=redirect_uri)
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
) -> Session:
# ``state`` is verified by the auth-route layer; Portal doesn't
# re-check it at the token endpoint.
def complete_login(self, *, code: str, state: str, code_verifier: str, redirect_uri: str) -> Session:
# ``state`` is verified by the auth-route layer; Portal doesn't re-check it at the token endpoint.
return self._token_grant(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._client_id,
"code_verifier": code_verifier,
"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
"client_id": self._client_id, "code_verifier": code_verifier,
},
bad_request_exc=InvalidCodeError,
)
@@ -113,22 +82,18 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
def refresh_session(self, *, refresh_token: str) -> Session:
if not refresh_token:
raise RefreshExpiredError("no refresh token present in session")
# The RT goes in BOTH the body (Portal's request schema requires it)
# and the ``x-nous-refresh-token`` header (Portal reconciles the two
# and keeps the value out of body access logs). Header-only → 400.
# The RT goes in BOTH the body (Portal's request schema requires it) and the
# ``x-nous-refresh-token`` header (Portal reconciles the two and keeps the value
# out of body access logs). Header-only → 400.
return self._token_grant(
{
"grant_type": "refresh_token",
"client_id": self._client_id,
"refresh_token": refresh_token,
},
{"grant_type": "refresh_token", "client_id": self._client_id, "refresh_token": refresh_token},
headers={"x-nous-refresh-token": refresh_token},
bad_request_exc=RefreshExpiredError,
)
def verify_session(self, *, access_token: str) -> Optional[Session]:
# None on expiry/invalidity (middleware then tries refresh); a
# ProviderError (JWKS unreachable) bubbles up so middleware emits 503.
# None on expiry/invalidity (middleware then tries refresh); a ProviderError
# (JWKS unreachable) bubbles up so middleware emits 503.
try:
claims = self._verify_jwt(access_token)
except InvalidCodeError:
@@ -136,15 +101,14 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
return self._session(access_token, "", claims)
def revoke_session(self, *, refresh_token: str) -> None:
# Portal exposes no token-endpoint revocation grant; logout is
# client-side cookie clearing and the RT expires within its 24h TTL.
# Portal exposes no token-endpoint revocation grant; logout is client-side cookie
# clearing and the RT expires within its 24h TTL.
return None
# ---- internals --------------------------------------------------------
def _token_grant(
self, data: Dict[str, str], *, bad_request_exc: type[Exception],
headers: Optional[Dict[str, str]] = None,
self, data: Dict[str, str], *, bad_request_exc: type[Exception], headers: Optional[Dict[str, str]] = None,
) -> Session:
access_token, payload = exchange_token(
self._token_url, data, headers=headers, bad_request_exc=bad_request_exc,
@@ -165,60 +129,45 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
audience=self._client_id, # contract C2: bare client_id
issuer=self._portal_url, label="access token",
)
# Contract C9: agent_instance_id is "should" not "must" — tolerated
# when absent (the aud check already binds the token to this instance).
# Contract C9: agent_instance_id is "should" not "must" — tolerated when absent
# (the aud check already binds the token to this instance).
token_instance_id = claims.get("agent_instance_id")
if token_instance_id is not None and token_instance_id != self._agent_instance_id:
raise ProviderError(
f"agent_instance_id mismatch: token={token_instance_id!r} "
f"vs configured={self._agent_instance_id!r}"
f"agent_instance_id mismatch: token={token_instance_id!r} vs configured={self._agent_instance_id!r}"
)
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.",
"Nous Portal token missing oauth_contract_version claim (contract says it should be %d); proceeding anyway.",
_EXPECTED_CONTRACT_VERSION,
)
elif contract_version != _EXPECTED_CONTRACT_VERSION:
raise ProviderError(
f"unsupported oauth_contract_version={contract_version!r}, "
f"expected {_EXPECTED_CONTRACT_VERSION}"
f"unsupported oauth_contract_version={contract_version!r}, expected {_EXPECTED_CONTRACT_VERSION}"
)
return claims
def _session(self, access_token: str, refresh_token: str, claims: Dict[str, Any]) -> Session:
# Contract C4: no email / display_name in tokens.
return session_from_claims(
self.name, claims, access_token=access_token, refresh_token=refresh_token,
org_id=str(claims.get("org_id") or ""),
self.name, claims, access_token=access_token, refresh_token=refresh_token, org_id=str(claims.get("org_id") or ""),
)
# ---- Plugin entry point ----
def _load_config_oauth_section() -> dict:
return load_config_section(logger, "dashboard-auth-nous", "dashboard", "oauth")
return load_config_section(logger, _TAG, "dashboard", "oauth")
def register(ctx) -> None:
"""Register ``NousDashboardAuthProvider`` when a client_id is configured.
On skip, writes a reason naming BOTH configuration surfaces to
:data:`LAST_SKIP_REASON` for the gate's fail-closed error message.
"""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
def _settings() -> dict:
"""Resolve NousDashboardAuthProvider kwargs; the skip reason names BOTH configuration surfaces."""
section = _load_config_oauth_section()
client_id = resolve_env_or_cfg("HERMES_DASHBOARD_OAUTH_CLIENT_ID", section.get("client_id", ""))
portal_url = (
resolve_env_or_cfg("HERMES_DASHBOARD_PORTAL_URL", section.get("portal_url", ""))
or _DEFAULT_PORTAL_URL
)
portal_url = resolve_env_or_cfg("HERMES_DASHBOARD_PORTAL_URL", section.get("portal_url", "")) or _DEFAULT_PORTAL_URL
if not client_id:
LAST_SKIP_REASON = (
raise SkipRegistration(
"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 "
@@ -226,24 +175,20 @@ def register(ctx) -> None:
"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 = (
raise SkipRegistration(
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match the contract "
f"shape 'agent:{{instance_id}}'. The Nous Portal provisions this value at deploy "
f"time; check your Fly app's secrets or override with the value from the Portal admin UI."
f"time; check your Fly app's secrets or override with the value from the Portal admin UI.",
level="warning",
)
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
return
return {"client_id": client_id, "portal_url": portal_url}
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)
def register(ctx) -> None:
"""Register ``NousDashboardAuthProvider`` when a client_id is configured."""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
kwargs, LAST_SKIP_REASON = register_provider(ctx, logger, _TAG, NousDashboardAuthProvider, _settings)
if kwargs is not None:
logger.info("dashboard-auth-nous: registered provider (client_id=%s, portal=%s)", kwargs["client_id"], kwargs["portal_url"])
+87 -174
View File
@@ -1,28 +1,16 @@
"""SelfHostedOIDCProvider — generic self-hosted OpenID Connect dashboard auth.
A standards-compliant OIDC Relying Party for the ``hermes dashboard`` gate.
Unlike the ``nous`` provider (Nous Portal's bespoke contract), this speaks
plain OIDC so it works against Authentik, Keycloak, Zitadel, Authelia, Auth0,
Okta, Google, … The HTTP round trip, cookies, CSRF ``state`` check and
``redirect_uri`` reconstruction are owned by ``hermes_cli/dashboard_auth/
routes.py``; this provider only:
1. discovers endpoints from ``{issuer}/.well-known/openid-configuration``,
2. builds the ``/authorize`` URL with PKCE (S256),
3. exchanges the code at the discovered ``token_endpoint``,
4. verifies the **ID token** against the discovered ``jwks_uri`` with
``iss``/``aud`` pinned, mapping ``sub``/``email``/``name`` onto a Session.
Why the ID token, not the access token: OIDC guarantees the ID token is a
signed JWT carrying identity claims; the access token's format is opaque per
spec (many IDPs issue random strings). The ``nous`` provider verifies its
access token only because Portal mints a custom JWT there.
Public (PKCE-only) and confidential (PKCE + ``client_secret``) clients are
both supported. With a secret, the client additionally authenticates at the
token endpoint via ``client_secret_basic`` or ``client_secret_post`` chosen
from ``token_endpoint_auth_methods_supported``. PKCE is sent in both modes —
the secret is layered on top, never a replacement (OAuth 2.1 / RFC 9700).
A standards-compliant OIDC Relying Party for the ``hermes dashboard`` gate (Authentik,
Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). The HTTP round trip, cookies, CSRF
``state`` check and ``redirect_uri`` reconstruction are owned by
``hermes_cli/dashboard_auth/routes.py``; this provider discovers endpoints from
``{issuer}/.well-known/openid-configuration``, builds the PKCE (S256) ``/authorize`` URL,
exchanges the code at the discovered ``token_endpoint``, and verifies the **ID token**
against the discovered ``jwks_uri`` with ``iss``/``aud`` pinned (the access token's format
is opaque per spec; the ``nous`` provider verifies its access token only because Portal
mints a custom JWT). Public (PKCE-only) and confidential (PKCE + ``client_secret`` via
``client_secret_basic`` / ``client_secret_post``) clients are both supported — the secret is
layered on top of PKCE, never a replacement (OAuth 2.1 / RFC 9700).
Configuration (env wins over config.yaml when set non-empty)::
@@ -35,13 +23,10 @@ Configuration (env wins over config.yaml when set non-empty)::
scopes: "openid profile email" # optional
# client_secret: confidential clients only — prefer the env var.
HERMES_DASHBOARD_OIDC_ISSUER
HERMES_DASHBOARD_OIDC_CLIENT_ID
HERMES_DASHBOARD_OIDC_SCOPES # optional
HERMES_DASHBOARD_OIDC_ISSUER / _CLIENT_ID / _SCOPES (optional)
HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; .env is its canonical home
On skip (missing issuer / client_id) the module-level :data:`LAST_SKIP_REASON`
carries a human-readable reason for the gate's fail-closed error.
On skip (missing issuer / client_id) ``LAST_SKIP_REASON`` carries the reason for the gate.
"""
from __future__ import annotations
@@ -55,23 +40,18 @@ from typing import Any, Dict, Optional
import httpx
from hermes_cli.dashboard_auth import (
DashboardAuthProvider,
InvalidCodeError,
LoginStart,
ProviderError,
RefreshExpiredError,
Session,
)
from hermes_cli.dashboard_auth import DashboardAuthProvider, InvalidCodeError, LoginStart, ProviderError, RefreshExpiredError, Session
from plugins.dashboard_auth._shared import (
JSON_HEADERS,
TOKEN_ENDPOINT_TIMEOUT_SEC as _TOKEN_ENDPOINT_TIMEOUT_SEC,
SkipRegistration,
exchange_token,
load_config_section,
make_jwks_client,
parse_json_body,
pkce_login_start,
refresh_token_from,
register_provider,
resolve_env_or_cfg,
session_from_claims,
validate_redirect_uri,
@@ -79,14 +59,14 @@ from plugins.dashboard_auth._shared import (
)
logger = logging.getLogger(__name__)
_TAG = "dashboard-auth-self-hosted"
# ``openid`` is mandatory (no ID token without it); profile/email populate
# display_name/email.
# ``openid`` is mandatory (no ID token without it); profile/email populate display_name/email.
_DEFAULT_SCOPES = "openid profile email"
# RS256 is the OIDC default; ES256 is common on modern IDPs (Zitadel, newer
# Keycloak). HS256 is deliberately excluded: it implies a shared secret we
# don't hold in the public-client model and is a JWT algorithm-confusion footgun.
# RS256 is the OIDC default; ES256 is common on modern IDPs (Zitadel, newer Keycloak).
# HS256 is deliberately excluded: it implies a shared secret we don't hold in the
# public-client model and is a JWT algorithm-confusion footgun.
_ALLOWED_ID_TOKEN_ALGS = ("RS256", "ES256", "RS384", "RS512", "ES384", "ES512")
_DISCOVERY_TIMEOUT_SEC = 10.0
@@ -101,14 +81,9 @@ def _require_https_or_loopback(url: str, *, field: str) -> str:
"""Reject non-HTTPS endpoint URLs (loopback http allowed) so a misconfigured
issuer can't ship auth codes / refresh tokens in cleartext."""
parsed = urllib.parse.urlparse(url)
if parsed.scheme == "https" or (
parsed.scheme == "http"
and (parsed.hostname or "") in ("localhost", "127.0.0.1", "::1")
):
if parsed.scheme == "https" or (parsed.scheme == "http" and (parsed.hostname or "") in ("localhost", "127.0.0.1", "::1")):
return url
raise ProviderError(
f"OIDC {field} must be https:// (or http on localhost), got {url!r}"
)
raise ProviderError(f"OIDC {field} must be https:// (or http on localhost), got {url!r}")
class SelfHostedOIDCProvider(DashboardAuthProvider):
@@ -117,30 +92,22 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
name = "self-hosted"
display_name = "Self-Hosted OIDC"
def __init__(
self,
*,
issuer: str,
client_id: str,
scopes: str = _DEFAULT_SCOPES,
client_secret: str = "",
) -> None:
def __init__(self, *, issuer: str, client_id: str, scopes: str = _DEFAULT_SCOPES, client_secret: str = "") -> None:
if not issuer:
raise ValueError("issuer is required")
if not client_id:
raise ValueError("client_id is required")
# Trailing slash normalised for stable compares; ``iss`` is pinned
# against the *discovered* issuer so a config/IDP slash mismatch is tolerated.
# Trailing slash normalised for stable compares; ``iss`` is pinned against the
# *discovered* issuer so a config/IDP slash mismatch is tolerated.
self._issuer = issuer.rstrip("/")
_require_https_or_loopback(self._issuer, field="issuer")
self._client_id = client_id
self._scopes = scopes.strip() or _DEFAULT_SCOPES
# Empty/whitespace secret ⇒ public client, so a provisioned-but-blank
# secret can't flip us into a broken confidential mode.
# Empty/whitespace secret ⇒ public client, so a provisioned-but-blank secret
# can't flip us into a broken confidential mode.
self._client_secret = (client_secret or "").strip()
# Discovery + JWKS resolve lazily so registration never hits the
# network (the IDP may be down at boot; fail per-request instead).
# Discovery + JWKS resolve lazily so registration never hits the network
# (the IDP may be down at boot; fail per-request instead).
self._discovery: Dict[str, Any] | None = None
self._discovery_fetched_at: float = 0.0
self._discovery_lock = threading.Lock()
@@ -149,26 +116,19 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
# ---- public API (DashboardAuthProvider) -------------------------------
def start_login(self, *, redirect_uri: str) -> LoginStart:
# Validate the redirect before discovery so a bad redirect_uri
# surfaces even when the IDP is unreachable.
# Validate the redirect before discovery so a bad redirect_uri surfaces even when the IDP is unreachable.
validate_redirect_uri(redirect_uri)
disco = self._get_discovery()
return pkce_login_start(
disco["authorization_endpoint"], client_id=self._client_id,
scope=self._scopes, redirect_uri=redirect_uri,
disco["authorization_endpoint"], client_id=self._client_id, scope=self._scopes, redirect_uri=redirect_uri,
)
def complete_login(
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
) -> Session:
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.
return self._exchange(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"client_id": self._client_id,
"code_verifier": code_verifier,
"grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri,
"client_id": self._client_id, "code_verifier": code_verifier,
},
bad_request_exc=InvalidCodeError,
)
@@ -178,11 +138,9 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
raise RefreshExpiredError("no refresh token present in session")
return self._exchange(
{
"grant_type": "refresh_token",
"client_id": self._client_id,
"refresh_token": refresh_token,
# Re-request the same scopes so the rotated ID token keeps its
# identity claims (some IDPs narrow scope on refresh otherwise).
"grant_type": "refresh_token", "client_id": self._client_id, "refresh_token": refresh_token,
# Re-request the same scopes so the rotated ID token keeps its identity
# claims (some IDPs narrow scope on refresh otherwise).
"scope": self._scopes,
},
bad_request_exc=RefreshExpiredError,
@@ -190,9 +148,9 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
)
def verify_session(self, *, access_token: str) -> Optional[Session]:
# The session cookie carries the ID token in the access-token slot
# (see _session_from_tokens) so this per-request check verifies a real
# JWT. None on expiry/invalidity; ProviderError if IDP/JWKS unreachable.
# The session cookie carries the ID token in the access-token slot (see _session)
# so this per-request check verifies a real JWT. None on expiry/invalidity;
# ProviderError if IDP/JWKS unreachable.
try:
claims = self._verify_id_token(access_token)
except InvalidCodeError:
@@ -223,46 +181,29 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
# ---- internals: token exchange ----------------------------------------
def _token_endpoint_auth(self, disco: Dict[str, Any]) -> tuple[Dict[str, str], Dict[str, str]]:
"""Return ``(extra_data, extra_headers)`` for token-endpoint client auth.
Public client → ``({}, {})`` (PKCE alone). Confidential client →
``client_secret_post`` when the IDP advertises it *without*
``client_secret_basic``, else HTTP Basic (the OIDC default and the
fallback when nothing is advertised). RFC 6749 §2.3.1.
"""
"""``(extra_data, extra_headers)`` for token-endpoint client auth. Public client →
``({}, {})`` (PKCE alone). Confidential client → ``client_secret_post`` when the IDP
advertises it *without* ``client_secret_basic``, else HTTP Basic (the OIDC default
and the fallback when nothing is advertised). RFC 6749 §2.3.1."""
if not self._client_secret:
return {}, {}
methods = disco.get("token_endpoint_auth_methods_supported") or []
if "client_secret_post" in methods and "client_secret_basic" not in methods:
return {"client_secret": self._client_secret}, {}
# Both halves must be form-url-encoded *before* base64 (RFC 6749
# §2.3.1) or a secret containing ':' / reserved chars corrupts the header.
# Both halves must be form-url-encoded *before* base64 (RFC 6749 §2.3.1) or a
# secret containing ':' / reserved chars corrupts the header.
userpass = f"{urllib.parse.quote(self._client_id, safe='')}:{urllib.parse.quote(self._client_secret, safe='')}"
return {}, {"Authorization": f"Basic {base64.b64encode(userpass.encode('utf-8')).decode('ascii')}"}
def _exchange(
self,
data: Dict[str, str],
*,
bad_request_exc: type[Exception],
previous_refresh_token: str = "",
) -> Session:
def _exchange(self, data: Dict[str, str], *, bad_request_exc: type[Exception], previous_refresh_token: str = "") -> Session:
"""POST the discovered token endpoint and turn the response into a Session.
Confidential-client auth (body field or Basic header) is added here
for both grants — the IDP rejects an unauthenticated refresh with
``invalid_client``. For a public client the request is PKCE-only.
"""
Confidential-client auth (body field or Basic header) is added for both grants —
the IDP rejects an unauthenticated refresh with ``invalid_client``."""
disco = self._get_discovery()
extra_data, extra_headers = self._token_endpoint_auth(disco)
id_token, payload = exchange_token(
disco["token_endpoint"],
{**data, **extra_data},
headers=extra_headers,
bad_request_exc=bad_request_exc,
idp="IDP",
endpoint="OIDC token endpoint",
token_key="id_token",
disco["token_endpoint"], {**data, **extra_data}, headers=extra_headers, bad_request_exc=bad_request_exc,
idp="IDP", endpoint="OIDC token endpoint", token_key="id_token",
missing_msg=(
"OIDC token response missing id_token — ensure the 'openid' "
"scope is configured and the client is allowed to receive an "
@@ -289,20 +230,17 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
if disco is None:
disco = self._discovery = self._fetch_discovery()
self._discovery_fetched_at = time.time()
# New issuer/keys → rebind the JWKS client to the fresh jwks_uri.
self._jwks_client = None
self._jwks_client = None # new issuer/keys → rebind the JWKS client to the fresh jwks_uri
return disco
def _fetch_discovery(self) -> Dict[str, Any]:
url = f"{self._issuer}/.well-known/openid-configuration"
try:
# follow_redirects=True: many IDPs answer discovery with a 3xx
# (Authentik canonicalises .well-known; proxies upgrade http→https)
# and httpx defaults to not following. Safe because the issuer pin
# and HTTPS checks below validate the *resolved* document, so a
# redirect to a hostile location can't smuggle in a bad issuer or a
# cleartext endpoint. The token/revocation POSTs deliberately do
# NOT follow redirects (they carry an auth code / refresh token).
# follow_redirects=True: many IDPs answer discovery with a 3xx (Authentik
# canonicalises .well-known; proxies upgrade http→https) and httpx defaults to
# not following. Safe because the issuer pin and HTTPS checks below validate the
# *resolved* document. The token/revocation POSTs deliberately do NOT follow
# redirects (they carry an auth code / refresh token).
response = httpx.get(url, headers=JSON_HEADERS, timeout=_DISCOVERY_TIMEOUT_SEC, follow_redirects=True)
except httpx.RequestError as exc:
raise ProviderError(f"OIDC discovery unreachable: {exc}") from exc
@@ -317,23 +255,16 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
endpoints = {k: field(k) for k in ("authorization_endpoint", "token_endpoint", "jwks_uri")}
if not all(endpoints.values()):
raise ProviderError(
"OIDC discovery missing one of authorization_endpoint / "
"token_endpoint / jwks_uri"
)
raise ProviderError("OIDC discovery missing one of authorization_endpoint / token_endpoint / jwks_uri")
# Issuer pin: a mismatch means the document came from the wrong place
# (proxy/MITM/misconfig). Only a trailing-slash difference is tolerated.
advertised_issuer = field("issuer")
if advertised_issuer and advertised_issuer.rstrip("/") != self._issuer:
raise ProviderError(
f"OIDC discovery issuer mismatch: document advertises "
f"{advertised_issuer!r} but configured issuer is "
f"{self._issuer!r}"
f"OIDC discovery issuer mismatch: document advertises {advertised_issuer!r} but configured issuer is {self._issuer!r}"
)
for key, url in endpoints.items():
_require_https_or_loopback(url, field=key)
# Absent/garbage auth-methods → [] → OIDC default (basic) applies.
auth_methods_raw = payload.get("token_endpoint_auth_methods_supported")
return {
@@ -360,26 +291,19 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
)
def _session(self, id_token: str, refresh_token: str, claims: Dict[str, Any]) -> Session:
"""Map verified OIDC claims onto a Session.
The verified ID token is stored in ``Session.access_token`` so the
per-request ``verify_session`` re-verifies a real JWT; the opaque OAuth
access token is not kept — the dashboard only needs identity.
"""
"""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 not kept — the dashboard only needs identity."""
email = str(claims.get("email", "") or "")
# Org/tenant is non-standard: accept common spellings, else join
# ``groups`` so multi-tenant IDPs surface *something* (free-form string).
# Org/tenant is non-standard: accept common spellings, else join ``groups`` so
# multi-tenant IDPs surface *something* (free-form string).
org_id = claims.get("org_id") or claims.get("organization") or ""
groups = claims.get("groups")
if not org_id and isinstance(groups, list) and groups:
org_id = ",".join(str(g) for g in groups)
return session_from_claims(
self.name, claims, access_token=id_token, refresh_token=refresh_token,
label="ID token", email=email,
display_name=str(
claims.get("name") or claims.get("preferred_username")
or claims.get("nickname") or email or ""
),
self.name, claims, access_token=id_token, refresh_token=refresh_token, label="ID token", email=email,
display_name=str(claims.get("name") or claims.get("preferred_username") or claims.get("nickname") or email or ""),
org_id=str(org_id or ""),
)
@@ -387,18 +311,11 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
# ---- Plugin entry point ----
def _load_config_oauth_section() -> dict:
return load_config_section(logger, "dashboard-auth-self-hosted", "dashboard", "oauth", "self_hosted")
return load_config_section(logger, _TAG, "dashboard", "oauth", "self_hosted")
def register(ctx) -> None:
"""Register :class:`SelfHostedOIDCProvider` when issuer + client_id are set.
On skip, :data:`LAST_SKIP_REASON` names BOTH configuration surfaces so
operators don't guess wrong about which to set.
"""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
def _settings() -> dict:
"""Resolve SelfHostedOIDCProvider kwargs; the skip reason names BOTH configuration surfaces."""
oidc_cfg = _load_config_oauth_section()
def setting(env_name: str, cfg_key: str) -> str:
@@ -406,12 +323,8 @@ def register(ctx) -> None:
issuer = setting("HERMES_DASHBOARD_OIDC_ISSUER", "issuer")
client_id = setting("HERMES_DASHBOARD_OIDC_CLIENT_ID", "client_id")
scopes = setting("HERMES_DASHBOARD_OIDC_SCOPES", "scopes") or _DEFAULT_SCOPES
# Credential: canonical home is the env var / ~/.hermes/.env. Empty ⇒ public client.
client_secret = setting("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "client_secret")
if not issuer or not client_id:
LAST_SKIP_REASON = (
raise SkipRegistration(
"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 "
@@ -419,21 +332,21 @@ def register(ctx) -> None:
"--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
return {
"issuer": issuer, "client_id": client_id,
"scopes": setting("HERMES_DASHBOARD_OIDC_SCOPES", "scopes") or _DEFAULT_SCOPES,
# Credential: canonical home is the env var / ~/.hermes/.env. Empty ⇒ public client.
"client_secret": setting("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "client_secret"),
}
try:
provider = SelfHostedOIDCProvider(
issuer=issuer, client_id=client_id, scopes=scopes, client_secret=client_secret
def register(ctx) -> None:
"""Register :class:`SelfHostedOIDCProvider` when issuer + client_id are set."""
global LAST_SKIP_REASON
LAST_SKIP_REASON = ""
kw, LAST_SKIP_REASON = register_provider(ctx, logger, _TAG, SelfHostedOIDCProvider, _settings)
if kw is not None:
logger.info(
"dashboard-auth-self-hosted: registered provider (issuer=%s, client_id=%s, scopes=%r, confidential=%s)",
kw["issuer"], kw["client_id"], kw["scopes"], bool(kw["client_secret"]), # never log the secret itself
)
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, confidential=%s)",
issuer, client_id, scopes, bool(client_secret), # never log the secret itself
)
File diff suppressed because it is too large Load Diff