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:
@@ -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})
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user