refactor(plugins/dashboard_auth): shared session/JWT/config helpers in _shared; compact basic/drain/nous/self_hosted
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""Helpers shared by the bundled dashboard-auth providers.
|
||||
|
||||
Each provider module keeps its own ``logger`` / ``LAST_SKIP_REASON`` (the
|
||||
gate reads those by module) and its ``register(ctx)``; the config/env
|
||||
resolution, PKCE login start, token-endpoint exchange and JWT verification
|
||||
boilerplate lives here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
ProviderError,
|
||||
Session,
|
||||
classify_jwks_lookup_error,
|
||||
)
|
||||
|
||||
# JWKS Cache-Control max-age (nous contract C7); self-hosted mirrors it.
|
||||
JWKS_CACHE_SECONDS = 300
|
||||
TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0
|
||||
JSON_HEADERS = {"Accept": "application/json"}
|
||||
|
||||
|
||||
# ---- Config / env resolution ----
|
||||
|
||||
def load_config_section(logger: logging.Logger, tag: str, *path: str) -> dict:
|
||||
"""Return the ``config.yaml`` block at ``path`` as a dict, or ``{}``.
|
||||
|
||||
Robust to load_config() raising (fresh install, malformed YAML), keys
|
||||
being absent, or the value not being a dict — every shape yields ``{}``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug("%s: load_config() raised %s; falling back to env-only configuration", tag, exc)
|
||||
return {}
|
||||
section = cfg_get(cfg, *path, default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def resolve_env_or_cfg(env_name: str, cfg_value: Any) -> str:
|
||||
"""Env-wins-over-config; an empty env value is treated as unset so a
|
||||
provisioned-but-blank secret can't shadow a valid config.yaml entry."""
|
||||
return os.environ.get(env_name, "").strip() or str(cfg_value or "").strip()
|
||||
|
||||
|
||||
# ---- OAuth / PKCE ----
|
||||
|
||||
def b64url_no_pad(raw: bytes) -> str:
|
||||
"""Base64url-encode without ``=`` padding (RFC 7636 §4)."""
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def validate_redirect_uri(redirect_uri: str) -> None:
|
||||
"""Fast-fail obviously-broken redirect_uris before bouncing to the IDP.
|
||||
|
||||
The IDP's allowlist is authoritative. Any ``http://`` host is allowed so
|
||||
dashboards behind TLS-terminating proxies / on LAN IPs aren't rejected.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(redirect_uri)
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
raise ProviderError(f"redirect_uri must be http(s), got {redirect_uri!r}")
|
||||
if not parsed.path or not parsed.path.endswith("/auth/callback"):
|
||||
raise ProviderError(f"redirect_uri path must end with '/auth/callback', got {redirect_uri!r}")
|
||||
|
||||
|
||||
def pkce_login_start(
|
||||
authorize_url: str, *, client_id: str, scope: str, redirect_uri: str
|
||||
) -> LoginStart:
|
||||
"""Build the authorization-code + PKCE (S256) redirect and cookie payload.
|
||||
|
||||
Callers validate ``redirect_uri`` first. The auth-route layer expects
|
||||
``cookie_payload["hermes_session_pkce"]`` as a flat ``state=…;verifier=…``
|
||||
string (it prepends ``provider=``).
|
||||
"""
|
||||
code_verifier = b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
|
||||
state = b64url_no_pad(secrets.token_bytes(32))
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": scope,
|
||||
"state": state,
|
||||
"code_challenge": b64url_no_pad(hashlib.sha256(code_verifier.encode("ascii")).digest()),
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
return LoginStart(
|
||||
redirect_url=f"{authorize_url}?{urllib.parse.urlencode(params)}",
|
||||
cookie_payload={"hermes_session_pkce": f"state={state};verifier={code_verifier}"},
|
||||
)
|
||||
|
||||
|
||||
def parse_json_body(response: httpx.Response) -> Dict[str, Any]:
|
||||
"""JSON object body, or ``{}`` for non-JSON content-type / parse error / non-dict."""
|
||||
if not response.headers.get("content-type", "").startswith("application/json"):
|
||||
return {}
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def exchange_token(
|
||||
url: str,
|
||||
data: Dict[str, str],
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
bad_request_exc: type[Exception],
|
||||
idp: str,
|
||||
endpoint: str,
|
||||
token_key: str,
|
||||
missing_msg: str,
|
||||
) -> tuple[str, Dict[str, Any]]:
|
||||
"""POST a token grant and return ``(token, payload)``.
|
||||
|
||||
A 400 (OAuth-shaped error envelope) raises ``bad_request_exc`` —
|
||||
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError`` for
|
||||
refresh — so the middleware's distinct handling is preserved. Any other
|
||||
non-200, transport failure, missing ``token_key`` or non-bearer
|
||||
``token_type`` raises ``ProviderError``. Redirects are deliberately NOT
|
||||
followed: the body carries an auth code / refresh token.
|
||||
"""
|
||||
try:
|
||||
response = httpx.post(
|
||||
url, data=data, headers={**JSON_HEADERS, **(headers or {})}, timeout=TOKEN_ENDPOINT_TIMEOUT_SEC
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(f"{endpoint} unreachable: {exc}") from exc
|
||||
|
||||
if response.status_code == 400:
|
||||
error_code = parse_json_body(response).get("error", "invalid_request")
|
||||
raise bad_request_exc(f"{idp} rejected token request: {error_code}")
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(f"{endpoint} returned {response.status_code}: {response.text[:200]!r}")
|
||||
|
||||
payload = parse_json_body(response)
|
||||
token = payload.get(token_key)
|
||||
if not token or not isinstance(token, str):
|
||||
raise ProviderError(missing_msg)
|
||||
token_type = str(payload.get("token_type", "")).lower()
|
||||
if token_type and token_type != "bearer":
|
||||
raise ProviderError(f"unexpected token_type={token_type!r}")
|
||||
return token, payload
|
||||
|
||||
|
||||
def refresh_token_from(payload: Dict[str, Any], fallback: str = "") -> str:
|
||||
"""The token response's refresh token, or ``fallback`` when absent/non-string
|
||||
(the session then behaves as access-token-only until expiry)."""
|
||||
rt = payload.get("refresh_token")
|
||||
return rt if isinstance(rt, str) and rt else fallback
|
||||
|
||||
|
||||
def session_from_claims(
|
||||
provider: str,
|
||||
claims: Dict[str, Any],
|
||||
*,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
label: str = "token",
|
||||
email: str = "",
|
||||
display_name: str = "",
|
||||
org_id: str = "",
|
||||
) -> Session:
|
||||
"""Map verified JWT claims onto a Session; ``sub`` is mandatory."""
|
||||
user_id = str(claims.get("sub", ""))
|
||||
if not user_id:
|
||||
raise ProviderError(f"{label} missing 'sub' (user_id) claim")
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
org_id=org_id,
|
||||
provider=provider,
|
||||
expires_at=int(claims["exp"]),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
# ---- JWT verification ----
|
||||
|
||||
def make_jwks_client(jwks_url: str) -> Any:
|
||||
"""PyJWKClient with explicit Accept/User-Agent (some WAFs block the
|
||||
library default). Imported lazily so plugin discovery stays cheap."""
|
||||
from jwt import PyJWKClient
|
||||
|
||||
return PyJWKClient(
|
||||
jwks_url, cache_keys=True, lifespan=JWKS_CACHE_SECONDS,
|
||||
headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0"},
|
||||
)
|
||||
|
||||
|
||||
def verify_jwt(
|
||||
token: str,
|
||||
jwks_client: Any,
|
||||
*,
|
||||
algorithms: list[str],
|
||||
audience: str,
|
||||
issuer: str,
|
||||
label: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Verify ``token`` against ``jwks_client`` with pinned ``aud``/``iss``.
|
||||
|
||||
Unreachable JWKS → ``ProviderError`` (503); a bearer that is not one of
|
||||
our JWTs (opaque peer key, foreign kid) → ``InvalidCodeError`` (None /
|
||||
next provider); folding both into 503 broke peer-key bearers. Expiry raises
|
||||
``InvalidCodeError`` (verify_session maps it to None); any other claim
|
||||
failure raises ``ProviderError`` with the unverified iss/aud appended so
|
||||
operators can spot config drift.
|
||||
"""
|
||||
import jwt # lazy — keeps startup fast for the ungated path
|
||||
|
||||
try:
|
||||
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
||||
except Exception as exc:
|
||||
raise classify_jwks_lookup_error(exc) from exc
|
||||
|
||||
try:
|
||||
return jwt.decode(
|
||||
token, signing_key.key, algorithms=algorithms, audience=audience, issuer=issuer,
|
||||
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
|
||||
)
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise InvalidCodeError(f"{label} expired: {exc}") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
# Decoding without verification is safe here: verification already
|
||||
# failed and these values are surfaced for diagnostics only, never trusted.
|
||||
details = ""
|
||||
try:
|
||||
unverified = jwt.decode(token, options={"verify_signature": False, "verify_exp": False})
|
||||
details = (
|
||||
f" [token iss={unverified.get('iss')!r} aud={unverified.get('aud')!r}; "
|
||||
f"expected iss={issuer!r} aud={audience!r}]"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ProviderError(f"{label} verification failed: {exc}{details}") from exc
|
||||
@@ -1,58 +1,36 @@
|
||||
"""BasicAuthProvider — username/password dashboard auth (no OAuth IDP).
|
||||
|
||||
A self-hosted "just put a password on my dashboard" provider. It plugs
|
||||
into the same ``DashboardAuthProvider`` framework as the Nous OAuth
|
||||
provider, but authenticates with a username + password instead of an
|
||||
OAuth redirect: it sets ``supports_password = True`` and implements
|
||||
``complete_password_login``. The login page renders a credential form for
|
||||
it; everything downstream of login (session cookies, verify, refresh,
|
||||
ws-tickets, logout) is identical to the OAuth path because a password
|
||||
session is just a :class:`Session` with provider-minted opaque tokens.
|
||||
Same ``DashboardAuthProvider`` framework as the OAuth providers, but login is
|
||||
a credential form (``supports_password = True`` + ``complete_password_login``);
|
||||
everything downstream (cookies, verify, refresh, ws-tickets, logout) is shared.
|
||||
No IDP and no database: sessions are stateless HMAC-signed tokens this provider
|
||||
mints and verifies — zero infrastructure for a single-box dashboard.
|
||||
|
||||
This provider has **no external IDP and no database**. Credentials are
|
||||
configured up front; sessions are stateless HMAC-signed tokens this
|
||||
provider mints and verifies itself. That keeps it zero-infrastructure —
|
||||
appropriate for a single-box self-hosted dashboard.
|
||||
Configuration (env wins over config.yaml when set non-empty)::
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty),
|
||||
mirroring the Nous provider's precedence convention:
|
||||
dashboard:
|
||||
basic_auth:
|
||||
username: admin # required
|
||||
password_hash: "scrypt$..." # preferred — see hash_password()
|
||||
password: "s3cret" # OR plaintext, hashed in-memory at load
|
||||
secret: "<32+ random bytes, base64 or hex>" # optional signing key
|
||||
session_ttl_seconds: 43200 # optional access-token lifetime (12h)
|
||||
|
||||
``config.yaml`` — canonical surface::
|
||||
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback
|
||||
HERMES_DASHBOARD_BASIC_AUTH_SECRET
|
||||
HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS
|
||||
|
||||
dashboard:
|
||||
basic_auth:
|
||||
username: admin # required
|
||||
# Provide EITHER a precomputed scrypt hash (preferred — no
|
||||
# plaintext at rest) ...
|
||||
password_hash: "scrypt$..." # see hash_password()
|
||||
# ... OR a plaintext password (hashed in-memory at load).
|
||||
password: "s3cret"
|
||||
secret: "<32+ random bytes, base64 or hex>" # optional; token-signing key
|
||||
session_ttl_seconds: 43200 # optional; access-token lifetime (default 12h)
|
||||
Without ``secret`` a random per-process key is generated: sessions then don't
|
||||
survive a restart or span multiple worker processes.
|
||||
|
||||
Environment overrides::
|
||||
Passwords use stdlib :func:`hashlib.scrypt` (no third-party dependency).
|
||||
``complete_password_login`` is constant-time and always performs a hash even
|
||||
for an unknown username, so the endpoint is not a username-enumeration timing oracle.
|
||||
|
||||
HERMES_DASHBOARD_BASIC_AUTH_USERNAME
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH # preferred
|
||||
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD # plaintext fallback
|
||||
HERMES_DASHBOARD_BASIC_AUTH_SECRET
|
||||
HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS
|
||||
|
||||
If ``secret`` is not configured, a random per-process secret is generated
|
||||
at startup. That's fine for a single-process dashboard, but means all
|
||||
sessions are invalidated on restart and sessions don't survive across
|
||||
multiple worker processes — set an explicit ``secret`` for stable
|
||||
multi-worker / restart-surviving sessions.
|
||||
|
||||
Password hashing uses stdlib :func:`hashlib.scrypt` (memory-hard, no
|
||||
third-party dependency). ``complete_password_login`` runs a constant-time
|
||||
comparison and always performs a hash even for an unknown username, so
|
||||
the endpoint is not a username-enumeration timing oracle.
|
||||
|
||||
Skip reasons:
|
||||
Like the Nous provider, this exposes a module-level ``LAST_SKIP_REASON``
|
||||
the gate's fail-closed branch can surface when the plugin loads but
|
||||
declines to register (no username/password configured).
|
||||
``LAST_SKIP_REASON`` is read by the gate's fail-closed branch when the plugin
|
||||
loads but declines to register.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -65,7 +43,7 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
@@ -74,66 +52,44 @@ from hermes_cli.dashboard_auth import (
|
||||
RefreshExpiredError,
|
||||
Session,
|
||||
)
|
||||
from plugins.dashboard_auth._shared import load_config_section, resolve_env_or_cfg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# The middleware transparently refreshes via the 30-day refresh token when the
|
||||
# access token lapses, so the TTL controls refresh frequency, not login length.
|
||||
_DEFAULT_TTL_SECONDS = 12 * 60 * 60
|
||||
_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Access-token lifetime. The middleware transparently refreshes via the
|
||||
# refresh token (30-day) when the access token lapses, so this controls
|
||||
# how often a refresh round trip happens, not how long the user stays
|
||||
# logged in.
|
||||
_DEFAULT_TTL_SECONDS = 12 * 60 * 60 # 12h
|
||||
_REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60 # 30d
|
||||
|
||||
# scrypt parameters (RFC 7914 / stdlib hashlib.scrypt). n must be a power
|
||||
# of two; these are the widely-recommended interactive-login parameters
|
||||
# (~16 MiB, a few ms on commodity hardware).
|
||||
# Interactive-login scrypt parameters (~16 MiB, a few ms); n must be a power of two.
|
||||
_SCRYPT_N = 2**14
|
||||
_SCRYPT_R = 8
|
||||
_SCRYPT_P = 1
|
||||
_SCRYPT_DKLEN = 32
|
||||
_SCRYPT_SALT_BYTES = 16
|
||||
|
||||
# Length of the HMAC-SHA256 digest appended as a fixed-length suffix to
|
||||
# signed tokens (no separator — binary HMAC bytes can't be confused with
|
||||
# a delimiter).
|
||||
# HMAC-SHA256 digest is appended to signed tokens as a fixed-length suffix
|
||||
# (no separator — binary HMAC bytes can't be confused with a delimiter).
|
||||
_SIG_LEN = hashlib.sha256().digest_size
|
||||
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Password hashing (stdlib scrypt)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---- Password hashing (stdlib scrypt) ----
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Return a ``scrypt$n$r$p$<salt_b64>$<dk_b64>`` hash string.
|
||||
|
||||
Use this to precompute ``password_hash`` for config.yaml so plaintext
|
||||
never sits at rest. Exposed as a module function so operators can run
|
||||
``python -c "from plugins.dashboard_auth.basic import hash_password;
|
||||
print(hash_password('pw'))"``.
|
||||
Public so operators can precompute ``password_hash`` for config.yaml (the
|
||||
plaintext then never sits at rest):
|
||||
``python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('pw'))"``.
|
||||
"""
|
||||
salt = secrets.token_bytes(_SCRYPT_SALT_BYTES)
|
||||
dk = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=_SCRYPT_N,
|
||||
r=_SCRYPT_R,
|
||||
p=_SCRYPT_P,
|
||||
dklen=_SCRYPT_DKLEN,
|
||||
maxmem=0,
|
||||
)
|
||||
return (
|
||||
f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
|
||||
f"{base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
|
||||
password.encode("utf-8"), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P,
|
||||
dklen=_SCRYPT_DKLEN, maxmem=0,
|
||||
)
|
||||
return f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}${base64.b64encode(salt).decode()}${base64.b64encode(dk).decode()}"
|
||||
|
||||
|
||||
def _verify_password(password: str, encoded: str) -> bool:
|
||||
@@ -149,29 +105,20 @@ def _verify_password(password: str, encoded: str) -> bool:
|
||||
return False
|
||||
try:
|
||||
actual = hashlib.scrypt(
|
||||
password.encode("utf-8"),
|
||||
salt=salt,
|
||||
n=n,
|
||||
r=r,
|
||||
p=p,
|
||||
dklen=len(expected),
|
||||
maxmem=0,
|
||||
password.encode("utf-8"), salt=salt, n=n, r=r, p=p,
|
||||
dklen=len(expected), maxmem=0,
|
||||
)
|
||||
except (ValueError, MemoryError):
|
||||
return False
|
||||
return hmac.compare_digest(actual, expected)
|
||||
|
||||
|
||||
# A fixed dummy hash used to spend ~equal time when the username is
|
||||
# unknown, so an attacker can't distinguish "no such user" (fast) from
|
||||
# "wrong password" (slow scrypt) by timing. Computed once at import.
|
||||
# Verified against when the username is unknown so "no such user" and "wrong
|
||||
# password" take comparable time.
|
||||
_DUMMY_HASH = hash_password("dummy-password-for-constant-time-verify")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token signing (stateless HMAC-signed blobs)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---- Token signing (stateless HMAC-signed blobs) ----
|
||||
|
||||
def _sign(payload: dict, secret: bytes) -> str:
|
||||
raw = json.dumps(payload, separators=(",", ":")).encode()
|
||||
@@ -179,7 +126,9 @@ def _sign(payload: dict, secret: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw + sig).decode()
|
||||
|
||||
|
||||
def _unsign(token: str, secret: bytes) -> Optional[dict]:
|
||||
def _unsign(token: str, secret: bytes, kind: str) -> Optional[dict]:
|
||||
"""Return the payload if the signature is valid, ``kind`` matches and it
|
||||
is unexpired; ``None`` otherwise (including on any decode error)."""
|
||||
try:
|
||||
blob = base64.urlsafe_b64decode(token.encode())
|
||||
if len(blob) <= _SIG_LEN:
|
||||
@@ -188,15 +137,15 @@ def _unsign(token: str, secret: bytes) -> Optional[dict]:
|
||||
expected = hmac.new(secret, raw, hashlib.sha256).digest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
return json.loads(raw)
|
||||
payload = json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if payload.get("kind") != kind or payload.get("exp", 0) <= int(time.time()):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---- Provider ----
|
||||
|
||||
class BasicAuthProvider(DashboardAuthProvider):
|
||||
"""Username/password provider with stateless HMAC-signed sessions."""
|
||||
@@ -228,27 +177,21 @@ class BasicAuthProvider(DashboardAuthProvider):
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; there is no OAuth redirect "
|
||||
"flow. The login page POSTs to /auth/password-login instead."
|
||||
"BasicAuthProvider is password-only; there is no OAuth redirect flow. "
|
||||
"The login page POSTs to /auth/password-login instead."
|
||||
)
|
||||
|
||||
def complete_login(
|
||||
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
||||
) -> Session:
|
||||
raise NotImplementedError(
|
||||
"BasicAuthProvider is password-only; use complete_password_login."
|
||||
)
|
||||
raise NotImplementedError("BasicAuthProvider is password-only; use complete_password_login.")
|
||||
|
||||
# ---- password login ----------------------------------------------------
|
||||
|
||||
def complete_password_login(
|
||||
self, *, username: str, password: str
|
||||
) -> Session:
|
||||
# Constant-time-ish: always run a scrypt verify (against the real
|
||||
# hash if the username matches, else a dummy hash) so an unknown
|
||||
# username and a wrong password take comparable time. Compare the
|
||||
# username with compare_digest too, to avoid a length/byte timing
|
||||
# leak on the username itself.
|
||||
def complete_password_login(self, *, username: str, password: str) -> Session:
|
||||
# Always run a scrypt verify (real hash if the username matches, else
|
||||
# the dummy) and compare the username with compare_digest too, so
|
||||
# neither the username nor its length leaks via timing.
|
||||
username_ok = hmac.compare_digest(
|
||||
username.encode("utf-8"), self._username.encode("utf-8")
|
||||
)
|
||||
@@ -261,31 +204,23 @@ class BasicAuthProvider(DashboardAuthProvider):
|
||||
# ---- session lifecycle -------------------------------------------------
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
payload = _unsign(access_token, self._secret)
|
||||
if (
|
||||
payload is None
|
||||
or payload.get("kind") != "access"
|
||||
or payload.get("exp", 0) <= int(time.time())
|
||||
):
|
||||
payload = _unsign(access_token, self._secret, "access")
|
||||
if payload is None:
|
||||
return None
|
||||
return self._session_from_payload(access_token, "", payload)
|
||||
user_id = str(payload.get("sub", ""))
|
||||
return self._session(user_id, int(payload["exp"]), access_token, "")
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
if not refresh_token:
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
payload = _unsign(refresh_token, self._secret)
|
||||
if (
|
||||
payload is None
|
||||
or payload.get("kind") != "refresh"
|
||||
or payload.get("exp", 0) <= int(time.time())
|
||||
):
|
||||
payload = _unsign(refresh_token, self._secret, "refresh")
|
||||
if payload is None:
|
||||
raise RefreshExpiredError("refresh token expired or invalid")
|
||||
return self._mint_session(str(payload.get("sub", self._username)))
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Stateless tokens — nothing to revoke server-side. The session
|
||||
# expires within its TTL. Best-effort no-op, must not raise.
|
||||
_ = refresh_token
|
||||
# Stateless tokens — nothing to revoke server-side; the session
|
||||
# expires within its TTL. Must not raise.
|
||||
return None
|
||||
|
||||
# ---- internals ---------------------------------------------------------
|
||||
@@ -293,94 +228,40 @@ class BasicAuthProvider(DashboardAuthProvider):
|
||||
def _mint_session(self, user_id: str) -> Session:
|
||||
now = int(time.time())
|
||||
exp = now + self._ttl
|
||||
access_token = _sign(
|
||||
{"sub": user_id, "kind": "access", "exp": exp}, self._secret
|
||||
)
|
||||
refresh_token = _sign(
|
||||
{"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS},
|
||||
self._secret,
|
||||
return self._session(
|
||||
user_id, exp,
|
||||
_sign({"sub": user_id, "kind": "access", "exp": exp}, self._secret),
|
||||
_sign({"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS}, self._secret),
|
||||
)
|
||||
|
||||
def _session(self, user_id: str, exp: int, access_token: str, refresh_token: str) -> Session:
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name=user_id,
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=exp,
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
def _session_from_payload(
|
||||
self, access_token: str, refresh_token: str, payload: dict
|
||||
) -> Session:
|
||||
user_id = str(payload.get("sub", ""))
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name=user_id,
|
||||
org_id="",
|
||||
provider=self.name,
|
||||
expires_at=int(payload["exp"]),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
user_id=user_id, email="", display_name=user_id, org_id="", provider=self.name,
|
||||
expires_at=exp, access_token=access_token, refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---- Plugin entry point ----
|
||||
|
||||
def _load_config_basic_auth_section() -> dict:
|
||||
"""Return ``dashboard.basic_auth`` from config.yaml, or ``{}``.
|
||||
|
||||
Robust to load_config() raising, the keys being absent, or the value
|
||||
not being a dict — every shape falls through to ``{}``.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-basic: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "basic_auth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def _resolve(env_name: str, cfg_section: dict, cfg_key: str) -> str:
|
||||
"""Env-wins-over-config resolution; empty env treated as unset."""
|
||||
env = os.environ.get(env_name, "").strip()
|
||||
if env:
|
||||
return env
|
||||
return str(cfg_section.get(cfg_key, "") or "").strip()
|
||||
return load_config_section(logger, "dashboard-auth-basic", "dashboard", "basic_auth")
|
||||
|
||||
|
||||
def _resolve_secret(cfg_section: dict) -> bytes:
|
||||
"""Resolve the token-signing secret.
|
||||
"""Resolve the token-signing secret (base64, hex, or raw text).
|
||||
|
||||
Accepts base64 or hex or raw text from config/env. When unset,
|
||||
generates a random per-process secret (sessions then don't survive a
|
||||
restart or span multiple workers — logged at INFO).
|
||||
When unset, generates a random per-process secret (sessions then don't
|
||||
survive a restart or span multiple workers — logged at INFO).
|
||||
"""
|
||||
raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section, "secret"
|
||||
)
|
||||
raw = resolve_env_or_cfg("HERMES_DASHBOARD_BASIC_AUTH_SECRET", cfg_section.get("secret"))
|
||||
if not raw:
|
||||
logger.info(
|
||||
"dashboard-auth-basic: no 'secret' configured; generating a "
|
||||
"random per-process signing key. Sessions will not survive a "
|
||||
"restart or span multiple workers. Set dashboard.basic_auth."
|
||||
"secret (or HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable "
|
||||
"sessions."
|
||||
"dashboard-auth-basic: no 'secret' configured; generating a random "
|
||||
"per-process signing key. Sessions will not survive a restart or span "
|
||||
"multiple workers. Set dashboard.basic_auth.secret (or "
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_SECRET) for stable sessions."
|
||||
)
|
||||
return secrets.token_bytes(32)
|
||||
# Try base64, then hex, then fall back to the raw UTF-8 bytes.
|
||||
for decoder in (base64.b64decode, bytes.fromhex):
|
||||
try:
|
||||
decoded = decoder(raw)
|
||||
@@ -392,64 +273,44 @@ def _resolve_secret(cfg_section: dict) -> bytes:
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — registers BasicAuthProvider when credentials exist.
|
||||
|
||||
Loopback / ``--insecure`` operators and anyone using the OAuth
|
||||
provider leave ``dashboard.basic_auth`` unset, so this plugin is a
|
||||
no-op for them. When username + (password or password_hash) are
|
||||
configured, it registers a password provider that the login page
|
||||
renders as a credential form.
|
||||
"""
|
||||
"""Register ``BasicAuthProvider`` when username + (password or
|
||||
password_hash) are configured; a no-op for OAuth / ``--insecure`` setups."""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
section = _load_config_basic_auth_section()
|
||||
username = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME", section, "username"
|
||||
)
|
||||
password_hash = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", section, "password_hash"
|
||||
)
|
||||
plaintext = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", section, "password"
|
||||
)
|
||||
ttl_raw = _resolve(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", section, "session_ttl_seconds"
|
||||
)
|
||||
|
||||
def setting(env_name: str, cfg_key: str) -> str:
|
||||
return resolve_env_or_cfg(env_name, section.get(cfg_key, ""))
|
||||
|
||||
username = setting("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "username")
|
||||
password_hash = setting("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH", "password_hash")
|
||||
plaintext = setting("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "password")
|
||||
ttl_raw = setting("HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS", "session_ttl_seconds")
|
||||
|
||||
if not username:
|
||||
LAST_SKIP_REASON = (
|
||||
"dashboard.basic_auth.username is not set (and "
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME is empty). Set a username "
|
||||
"and a password (or password_hash) under dashboard.basic_auth in "
|
||||
"config.yaml to enable username/password dashboard login, or use "
|
||||
"the OAuth provider, or pass --insecure to skip the auth gate."
|
||||
"dashboard.basic_auth.username is not set (and HERMES_DASHBOARD_BASIC_AUTH_USERNAME "
|
||||
"is empty). Set a username and a password (or password_hash) under "
|
||||
"dashboard.basic_auth in config.yaml to enable username/password dashboard "
|
||||
"login, or use the OAuth provider, or pass --insecure to skip the auth gate."
|
||||
)
|
||||
logger.debug("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
if not password_hash and not plaintext:
|
||||
LAST_SKIP_REASON = (
|
||||
"dashboard.basic_auth.username is set but neither password_hash "
|
||||
"nor password is configured. Provide one of them (password_hash "
|
||||
"is preferred — compute it with "
|
||||
"plugins.dashboard_auth.basic.hash_password)."
|
||||
"dashboard.basic_auth.username is set but neither password_hash nor password "
|
||||
"is configured. Provide one of them (password_hash is preferred — compute it "
|
||||
"with plugins.dashboard_auth.basic.hash_password)."
|
||||
)
|
||||
logger.warning("dashboard-auth-basic: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
# Precedence (env-wins convention): a password supplied via the
|
||||
# HERMES_DASHBOARD_BASIC_AUTH_PASSWORD env var overrides a config.yaml
|
||||
# password_hash, so an operator can rotate the password by setting an
|
||||
# env var without editing config. A password_hash (precomputed) wins
|
||||
# over a config-only plaintext password at the same tier — it's the
|
||||
# preferred at-rest form. Concretely:
|
||||
# * env password set → hash it (overrides any config hash)
|
||||
# * else config password_hash set → use it
|
||||
# * else config plaintext password → hash it in-memory
|
||||
plaintext_from_env = os.environ.get(
|
||||
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", ""
|
||||
).strip()
|
||||
# Precedence: env password (hashed in-memory) overrides any config
|
||||
# password_hash so operators can rotate without editing config; a config
|
||||
# password_hash wins over a config-only plaintext password (preferred at-rest form).
|
||||
plaintext_from_env = os.environ.get("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "").strip()
|
||||
if plaintext_from_env:
|
||||
password_hash = hash_password(plaintext_from_env)
|
||||
logger.info(
|
||||
@@ -457,7 +318,6 @@ def register(ctx) -> None:
|
||||
"(overrides any config password_hash)."
|
||||
)
|
||||
elif not password_hash:
|
||||
# config-only plaintext password.
|
||||
password_hash = hash_password(plaintext)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: hashed plaintext password in-memory. "
|
||||
@@ -465,8 +325,6 @@ def register(ctx) -> None:
|
||||
"and remove the plaintext password from config."
|
||||
)
|
||||
|
||||
secret = _resolve_secret(section)
|
||||
|
||||
try:
|
||||
ttl = int(ttl_raw) if ttl_raw else _DEFAULT_TTL_SECONDS
|
||||
except ValueError:
|
||||
@@ -474,10 +332,8 @@ def register(ctx) -> None:
|
||||
|
||||
try:
|
||||
provider = BasicAuthProvider(
|
||||
username=username,
|
||||
password_hash=password_hash,
|
||||
secret=secret,
|
||||
ttl_seconds=ttl,
|
||||
username=username, password_hash=password_hash,
|
||||
secret=_resolve_secret(section), ttl_seconds=ttl,
|
||||
)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
|
||||
@@ -485,7 +341,4 @@ def register(ctx) -> None:
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-basic: registered password provider (username=%s)",
|
||||
username,
|
||||
)
|
||||
logger.info("dashboard-auth-basic: registered password provider (username=%s)", username)
|
||||
|
||||
@@ -1,55 +1,33 @@
|
||||
"""DrainSecretProvider — shared-bearer-secret auth for the drain-control endpoint.
|
||||
|
||||
Task 2.0b of the safe-shutdown plan, and the FIRST consumer of the generic
|
||||
non-interactive token-auth capability added in Task 2.0a
|
||||
(``supports_token`` / ``verify_token`` on the ``DashboardAuthProvider`` ABC +
|
||||
the route-agnostic ``token_auth`` middleware seam).
|
||||
Uses the non-interactive token capability of the ``DashboardAuthProvider``
|
||||
ABC (``supports_token`` / ``verify_token`` + the route-agnostic ``token_auth``
|
||||
middleware seam). ``nous-account-service`` (NAS) provisions a **per-agent
|
||||
unique** shared secret into each deployed agent's environment; this provider
|
||||
verifies an inbound ``Authorization`` bearer against it with a constant-time
|
||||
compare and vouches for the caller as the ``drain-control`` principal. No
|
||||
login, cookie, session or refresh — the interactive ABC methods raise
|
||||
``NotImplementedError``. A real auth plugin (not an ad-hoc header check on the
|
||||
route) so the credential lives inside the dashboard auth framework.
|
||||
|
||||
What it is
|
||||
----------
|
||||
A service-to-service auth provider. ``nous-account-service`` (NAS) provisions a
|
||||
**per-agent unique** shared secret into each deployed agent's environment; this
|
||||
provider verifies an inbound ``Authorization`` bearer token against that secret
|
||||
with a constant-time compare and, on a match, vouches for the caller as the
|
||||
``drain-control`` principal. It is NOT an interactive identity provider — there
|
||||
is no login, cookie, session, or refresh. It implements ONLY the token
|
||||
capability (``supports_token = True`` + ``verify_token``); the five interactive
|
||||
ABC methods raise ``NotImplementedError``.
|
||||
Security properties:
|
||||
* Per-agent unique secret — a leak's blast radius is one agent.
|
||||
* Fail-CLOSED entropy gate at registration: a weak/short/low-entropy secret is
|
||||
never silently accepted (>= 43 url-safe-base64 chars ~= 256 bits, enough
|
||||
distinct characters, Shannon entropy floor).
|
||||
* ``hmac.compare_digest`` on the request path — not a timing oracle.
|
||||
|
||||
Why a plugin (not an ad-hoc header check on the drain route)
|
||||
------------------------------------------------------------
|
||||
Decisions.md Q-A: the drain credential MUST be a real auth plugin in the
|
||||
dashboard auth framework, not a bolt-on. Q-C: the framework widening that
|
||||
hosts it is generic (Task 2.0a) and this plugin is merely its first consumer.
|
||||
Configuration — the secret is a CREDENTIAL, so it is env-only (provisioned by
|
||||
NAS at deploy time); behavioural knobs live in config.yaml::
|
||||
|
||||
Security properties (decisions.md Q-A)
|
||||
--------------------------------------
|
||||
* **Per-agent unique secret** — each agent gets a distinct secret; a leak's
|
||||
blast radius is one agent.
|
||||
* **Entropy gate at registration** — a weak/short/low-entropy secret fails
|
||||
CLOSED at load (the plugin declines to register and records a skip reason);
|
||||
it is never silently accepted. Bar: >= 256 bits of entropy / >= 43
|
||||
url-safe-base64 chars, and the value must not be obviously structured
|
||||
(all-one-character, too few distinct characters).
|
||||
* **Constant-time compare** — ``hmac.compare_digest`` on the request path, so
|
||||
the endpoint is not a timing oracle.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
The secret is a CREDENTIAL, so it is carried via an env var (the ``.env``-is-
|
||||
for-secrets-only rule), provisioned by NAS at deploy time (Phase 3):
|
||||
|
||||
HERMES_DASHBOARD_DRAIN_SECRET # the per-agent shared secret (>=43 url-safe-b64 chars)
|
||||
|
||||
Behavioural knobs live in config.yaml (canonical surface):
|
||||
HERMES_DASHBOARD_DRAIN_SECRET # per-agent shared secret (>=43 url-safe-b64 chars)
|
||||
|
||||
dashboard:
|
||||
drain_auth:
|
||||
scope: drain # capability label attached to the principal
|
||||
min_secret_chars: 43 # entropy bar (optional; default 43 ~= 256 bits)
|
||||
min_secret_chars: 43 # entropy bar (default 43 ~= 256 bits)
|
||||
|
||||
When ``HERMES_DASHBOARD_DRAIN_SECRET`` is unset, the plugin is a no-op (records
|
||||
a skip reason) — agents that don't want NAS-driven drain just don't set it.
|
||||
When the env var is unset the plugin is a no-op (records a skip reason).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -66,53 +44,40 @@ from hermes_cli.dashboard_auth import (
|
||||
Session,
|
||||
TokenPrincipal,
|
||||
)
|
||||
from plugins.dashboard_auth._shared import load_config_section
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default entropy bar: 43 url-safe-base64 chars ~= 256 bits. token_urlsafe(32)
|
||||
# produces 43 chars, so a correctly-provisioned secret clears this exactly.
|
||||
# token_urlsafe(32) produces exactly 43 chars, so a correctly-provisioned
|
||||
# secret clears the default bar exactly.
|
||||
_DEFAULT_MIN_SECRET_CHARS = 43
|
||||
# A secret must contain at least this many DISTINCT characters — rejects
|
||||
# degenerate values like "aaaa..." that are long but trivially low-entropy.
|
||||
# Rejects degenerate values like "aaaa..." that are long but trivially low-entropy.
|
||||
_MIN_DISTINCT_CHARS = 16
|
||||
# Shannon entropy floor (bits) over the secret's characters — a second,
|
||||
# distribution-aware guard on top of the length + distinct-count checks.
|
||||
# Distribution-aware second guard on top of length + distinct-count.
|
||||
_MIN_SHANNON_BITS = 128.0
|
||||
|
||||
# The path the begin/cancel-drain endpoint lives on. Registered as a
|
||||
# token-authable route by ``register()`` so the generic seam guards it. Kept
|
||||
# here (not imported from web_server) to avoid a heavy import at plugin load.
|
||||
# Kept here (not imported from web_server) to avoid a heavy import at plugin load.
|
||||
DRAIN_ROUTE_PATH = "/api/gateway/drain"
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
def _shannon_bits(value: str) -> float:
|
||||
"""Total Shannon entropy (bits) of ``value`` over its character distribution.
|
||||
|
||||
H = len * sum(-p_i * log2(p_i)). A long string drawn from a wide alphabet
|
||||
scores high; a long run of one character scores ~0.
|
||||
"""
|
||||
"""Total Shannon entropy (bits) of ``value`` over its character distribution."""
|
||||
if not value:
|
||||
return 0.0
|
||||
counts = Counter(value)
|
||||
n = len(value)
|
||||
per_char = -sum((c / n) * math.log2(c / n) for c in counts.values())
|
||||
per_char = -sum((c / n) * math.log2(c / n) for c in Counter(value).values())
|
||||
return per_char * n
|
||||
|
||||
|
||||
def assess_secret_strength(
|
||||
secret: str, *, min_chars: int = _DEFAULT_MIN_SECRET_CHARS
|
||||
) -> Optional[str]:
|
||||
"""Return a rejection reason if ``secret`` is too weak, else ``None``.
|
||||
"""Return a human-readable rejection reason if ``secret`` is too weak, else ``None``.
|
||||
|
||||
Fail-closed entropy gate (decisions.md Q-A). Checks, in order:
|
||||
* length >= ``min_chars`` (default 43 url-safe-b64 chars ~= 256 bits),
|
||||
* at least ``_MIN_DISTINCT_CHARS`` distinct characters,
|
||||
* Shannon entropy >= ``_MIN_SHANNON_BITS`` bits.
|
||||
|
||||
A ``None`` return means the secret passes. Any string return is a
|
||||
human-readable reason the caller logs + records as the skip reason.
|
||||
Checks, in order: length >= ``min_chars``, distinct chars >=
|
||||
``_MIN_DISTINCT_CHARS``, Shannon entropy >= ``_MIN_SHANNON_BITS``.
|
||||
"""
|
||||
if not secret:
|
||||
return "secret is empty"
|
||||
@@ -146,9 +111,8 @@ class DrainSecretProvider(DashboardAuthProvider):
|
||||
supports_session = False
|
||||
|
||||
def __init__(self, *, secret: str, scope: str = "drain") -> None:
|
||||
# Defence in depth: construction also enforces the entropy bar, so a
|
||||
# caller that bypasses register()'s check still can't build a weak
|
||||
# provider. register() does the friendly skip-reason path; this raises.
|
||||
# Defence in depth: construction enforces the entropy bar too, so a
|
||||
# caller bypassing register() still can't build a weak provider.
|
||||
reason = assess_secret_strength(secret)
|
||||
if reason is not None:
|
||||
raise ValueError(f"drain secret rejected: {reason}")
|
||||
@@ -158,80 +122,48 @@ class DrainSecretProvider(DashboardAuthProvider):
|
||||
# ---- token capability (the only thing this provider implements) --------
|
||||
|
||||
def verify_token(self, *, token: str) -> Optional[TokenPrincipal]:
|
||||
"""Constant-time compare against the per-agent shared secret.
|
||||
|
||||
Returns a ``drain-control`` principal on an exact match, else ``None``
|
||||
(the generic seam falls through / fails closed). Uses
|
||||
``hmac.compare_digest`` so a wrong token can't be recovered by timing.
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
if hmac.compare_digest(token.encode("utf-8"), self._secret.encode("utf-8")):
|
||||
return TokenPrincipal(
|
||||
principal="drain-control",
|
||||
provider=self.name,
|
||||
scopes=(self._scope,),
|
||||
)
|
||||
"""Constant-time compare; ``drain-control`` principal on match, else
|
||||
``None`` so the generic seam falls through / fails closed."""
|
||||
if token and hmac.compare_digest(token.encode("utf-8"), self._secret.encode("utf-8")):
|
||||
return TokenPrincipal(principal="drain-control", provider=self.name, scopes=(self._scope,))
|
||||
return None
|
||||
|
||||
# ---- interactive methods: unsupported (service credential only) --------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
raise NotImplementedError(
|
||||
"DrainSecretProvider is a non-interactive service credential; "
|
||||
"there is no login flow."
|
||||
"DrainSecretProvider is a non-interactive service credential; there is no login flow."
|
||||
)
|
||||
|
||||
def complete_login(
|
||||
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
||||
) -> Session:
|
||||
raise NotImplementedError(
|
||||
"DrainSecretProvider is a non-interactive service credential."
|
||||
)
|
||||
raise NotImplementedError("DrainSecretProvider is a non-interactive service credential.")
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# Not a cookie-session provider — it never mints a Session, so it can
|
||||
# never recognise a session cookie. Return None (don't raise) so it
|
||||
# stacks harmlessly in the cookie-verify loop.
|
||||
# Never mints a Session, so never recognises a cookie. Return None
|
||||
# (don't raise) so it stacks harmlessly in the cookie-verify loop.
|
||||
return None
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
raise NotImplementedError(
|
||||
"DrainSecretProvider is a non-interactive service credential."
|
||||
)
|
||||
raise NotImplementedError("DrainSecretProvider is a non-interactive service credential.")
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---- Plugin entry point ----
|
||||
|
||||
def _load_config_drain_auth_section() -> dict:
|
||||
"""Return ``dashboard.drain_auth`` from config.yaml, or ``{}``."""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-drain: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "drain_auth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
return load_config_section(logger, "dashboard-auth-drain", "dashboard", "drain_auth")
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — registers DrainSecretProvider when a strong secret is set.
|
||||
"""Register ``DrainSecretProvider`` when a strong secret is set.
|
||||
|
||||
No-op (records a skip reason) when ``HERMES_DASHBOARD_DRAIN_SECRET`` is
|
||||
unset or fails the entropy gate. On success, also registers the
|
||||
begin/cancel-drain route as token-authable via the generic seam.
|
||||
unset or fails the entropy gate. On success also registers the drain
|
||||
route as token-authable via the generic seam.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
@@ -239,10 +171,9 @@ def register(ctx) -> None:
|
||||
secret = os.environ.get("HERMES_DASHBOARD_DRAIN_SECRET", "").strip()
|
||||
if not secret:
|
||||
LAST_SKIP_REASON = (
|
||||
"HERMES_DASHBOARD_DRAIN_SECRET is not set. Set a per-agent "
|
||||
">=256-bit secret (e.g. `python -c \"import secrets; "
|
||||
"print(secrets.token_urlsafe(32))\"`) to enable NAS-driven drain "
|
||||
"coordination; leave it unset to disable the drain endpoint."
|
||||
"HERMES_DASHBOARD_DRAIN_SECRET is not set. Set a per-agent >=256-bit secret "
|
||||
"(e.g. `python -c \"import secrets; print(secrets.token_urlsafe(32))\"`) to enable "
|
||||
"NAS-driven drain coordination; leave it unset to disable the drain endpoint."
|
||||
)
|
||||
logger.debug("dashboard-auth-drain: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
@@ -272,20 +203,16 @@ def register(ctx) -> None:
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
|
||||
# Opt the begin/cancel-drain endpoint into the generic token-auth seam so
|
||||
# the dashboard's interactive cookie gate doesn't bounce NAS's bearer call.
|
||||
# Opt the drain endpoint into the token-auth seam so the interactive
|
||||
# cookie gate doesn't bounce NAS's bearer call.
|
||||
try:
|
||||
from hermes_cli.dashboard_auth.token_auth import register_token_route
|
||||
|
||||
register_token_route(DRAIN_ROUTE_PATH)
|
||||
except Exception as exc: # noqa: BLE001 — seam import must not crash plugin load
|
||||
logger.warning(
|
||||
"dashboard-auth-drain: could not register token route %s: %s",
|
||||
DRAIN_ROUTE_PATH, exc,
|
||||
)
|
||||
logger.warning("dashboard-auth-drain: could not register token route %s: %s", DRAIN_ROUTE_PATH, exc)
|
||||
|
||||
logger.info(
|
||||
"dashboard-auth-drain: registered drain service-credential provider "
|
||||
"(scope=%s, route=%s)",
|
||||
"dashboard-auth-drain: registered drain service-credential provider (scope=%s, route=%s)",
|
||||
scope, DRAIN_ROUTE_PATH,
|
||||
)
|
||||
|
||||
@@ -1,154 +1,70 @@
|
||||
"""NousDashboardAuthProvider — Nous Portal OAuth (authorization-code + PKCE).
|
||||
|
||||
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``
|
||||
(PR #180). The plugin auto-loads (bundled, kind=backend) but only registers
|
||||
its provider when a client_id is configured — either via ``config.yaml`` or
|
||||
via the Portal-injected env var — so loopback / ``--insecure`` operators
|
||||
are unaffected.
|
||||
Implements ``nous-account-service/docs/agent-dashboard-oauth-contract.md``.
|
||||
Bundled and auto-loaded, but only registers when a client_id is configured, so
|
||||
loopback / ``--insecure`` operators are unaffected.
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty):
|
||||
Configuration (env wins over config.yaml when set non-empty; an empty env value
|
||||
is treated as unset so a provisioned-but-blank Fly secret can't shadow config)::
|
||||
|
||||
``config.yaml`` — canonical surface::
|
||||
dashboard:
|
||||
oauth:
|
||||
client_id: agent:{agent_instance_id} # required
|
||||
portal_url: https://portal.example # optional
|
||||
|
||||
dashboard:
|
||||
oauth:
|
||||
client_id: agent:{agent_instance_id} # required
|
||||
portal_url: https://portal.example # optional
|
||||
HERMES_DASHBOARD_OAUTH_CLIENT_ID — shape ``agent:{agent_instance_id}``
|
||||
HERMES_DASHBOARD_PORTAL_URL — default https://portal.nousresearch.com
|
||||
|
||||
Environment overrides — used by Fly.io's platform-secret injection so
|
||||
per-deploy values don't need to bake into ``config.yaml``:
|
||||
Contract points encoded here:
|
||||
- client_id is per-instance; its suffix is cross-checked against the token's
|
||||
``agent_instance_id`` claim (defense-in-depth).
|
||||
- scope is ``agent_dashboard:access`` only; audience is the bare client_id.
|
||||
- access tokens are RS256 JWTs verified against ``/.well-known/jwks.json``
|
||||
(JWKS cached 5 minutes).
|
||||
- Portal issues a 24h *rotating* refresh token with reuse detection: the
|
||||
middleware MUST persist ``Session.refresh_token`` back to the cookie on
|
||||
every refresh, or the next refresh replays a rotated token and (outside a
|
||||
60s grace) trips reuse-detection, revoking the whole session. A 400 on
|
||||
refresh (expired / revoked / reuse-detected) → ``RefreshExpiredError`` →
|
||||
re-login.
|
||||
- ``oauth_contract_version``: missing → warn + proceed; ``!= 1`` → refuse.
|
||||
|
||||
HERMES_DASHBOARD_OAUTH_CLIENT_ID — shape ``agent:{agent_instance_id}``
|
||||
HERMES_DASHBOARD_PORTAL_URL — defaults to
|
||||
``https://portal.nousresearch.com``
|
||||
(production Portal). Override only
|
||||
for staging (``portal.rewbs.uk``)
|
||||
or a custom deployment.
|
||||
|
||||
Empty env var values are treated as unset so a provisioned-but-not-populated
|
||||
Fly secret can't shadow a valid config.yaml entry.
|
||||
|
||||
Key contract points encoded here:
|
||||
|
||||
- client_id is per-instance (``agent:{instance_id}``); the suffix is also
|
||||
cross-checked against the token's ``agent_instance_id`` claim as
|
||||
defense-in-depth.
|
||||
- scope is ``agent_dashboard:access`` only (no OIDC scopes).
|
||||
- tokens are RS256 JWTs verified against ``/.well-known/jwks.json``;
|
||||
JWKS is cached for 5 minutes.
|
||||
- the dashboard auth-code grant issues a 24h rotating refresh token
|
||||
(Portal NAS PR #293). ``refresh_session`` posts ``grant_type=refresh_token``
|
||||
to rotate the access token; ``complete_login`` and ``refresh_session``
|
||||
both populate ``Session.refresh_token`` with the (rotating) value the
|
||||
middleware persists back to the HttpOnly cookie. On a dead/expired/
|
||||
reuse-detected refresh token Portal returns 400 → ``RefreshExpiredError``
|
||||
→ middleware redirects to ``/auth/login``.
|
||||
- audience claim is the bare ``client_id`` (no ``hermes-cli:`` prefix).
|
||||
- tolerant ``oauth_contract_version`` check: missing → warn + proceed;
|
||||
present and ``!= 1`` → refuse.
|
||||
|
||||
The cookie payload returned by ``start_login`` stashes the PKCE
|
||||
``code_verifier`` and the OAuth ``state`` parameter for the
|
||||
``/auth/callback`` handler to retrieve. The auth-route layer is the owner
|
||||
of cookie names; this provider just hands back ``{"code_verifier": …,
|
||||
"state": …}`` and the route serializes those into the ``hermes_session_pkce``
|
||||
cookie.
|
||||
|
||||
Refresh-token rotation: Portal rotates the refresh token on every
|
||||
successful refresh and runs reuse-detection (replaying a rotated token
|
||||
outside Portal's 60s grace revokes the whole session). The host
|
||||
middleware therefore MUST persist the rotated ``Session.refresh_token``
|
||||
back to the cookie on every refresh.
|
||||
|
||||
Skip reasons:
|
||||
The plugin exposes a module-level ``LAST_SKIP_REASON`` that the gate's
|
||||
fail-closed branch reads to surface a useful operator error message
|
||||
("Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …") instead of the bare "no
|
||||
providers registered" the gate would otherwise emit.
|
||||
``LAST_SKIP_REASON`` is read by the gate's fail-closed branch to surface a
|
||||
specific operator message when the plugin loads but declines to register.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import urllib.parse
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from hermes_cli.dashboard_auth import (
|
||||
DashboardAuthProvider,
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
ProviderError,
|
||||
RefreshExpiredError,
|
||||
classify_jwks_lookup_error,
|
||||
Session,
|
||||
)
|
||||
from plugins.dashboard_auth._shared import (
|
||||
exchange_token,
|
||||
load_config_section,
|
||||
make_jwks_client,
|
||||
pkce_login_start,
|
||||
refresh_token_from,
|
||||
resolve_env_or_cfg,
|
||||
session_from_claims,
|
||||
validate_redirect_uri,
|
||||
verify_jwt,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Production Portal URL. Override via HERMES_DASHBOARD_PORTAL_URL for
|
||||
# staging (portal.rewbs.uk) or a custom deployment. Contract docs name
|
||||
# this as the production issuer.
|
||||
_DEFAULT_PORTAL_URL = "https://portal.nousresearch.com"
|
||||
_SCOPE = "agent_dashboard:access" # contract C3
|
||||
_EXPECTED_CONTRACT_VERSION = 1 # contract C11
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skip-reason channel for operator-friendly error messages
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# When the plugin loads but refuses to register (missing / malformed
|
||||
# env vars), the auth gate downstream just sees "zero providers" and
|
||||
# emits a generic "install a provider" error. That's misleading for the
|
||||
# common case where the provider IS installed but mis-configured. The
|
||||
# plugin writes the *specific* reason to this module-level slot; the
|
||||
# gate reads it back when building its fail-closed SystemExit message.
|
||||
#
|
||||
# Cleared on every register() call so repeated dashboard starts in the
|
||||
# same process (tests, hot-reload) don't leak stale reasons.
|
||||
|
||||
LAST_SKIP_REASON: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contract constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Contract C3: scope name for the dashboard flow.
|
||||
_SCOPE = "agent_dashboard:access"
|
||||
|
||||
# Contract C11: emitted claim should equal 1; tolerant (warn) if missing.
|
||||
_EXPECTED_CONTRACT_VERSION = 1
|
||||
|
||||
# Contract C7: JWKS Cache-Control max-age=300.
|
||||
_JWKS_CACHE_SECONDS = 300
|
||||
|
||||
# httpx timeout for the token endpoint POST.
|
||||
_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _b64url_no_pad(raw: bytes) -> str:
|
||||
"""Base64url-encode without ``=`` padding (RFC 7636 §4)."""
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
LAST_SKIP_REASON: str = "" # cleared on every register() so restarts don't leak stale reasons
|
||||
|
||||
|
||||
class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
@@ -158,350 +74,105 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
display_name = "Nous Research"
|
||||
|
||||
def __init__(self, *, client_id: str, portal_url: str) -> None:
|
||||
# Defense-in-depth: register() filters too, but a malformed id must
|
||||
# never construct a provider.
|
||||
if not client_id.startswith("agent:"):
|
||||
# Defense-in-depth. The plugin entry point already filters, but
|
||||
# the provider should never be constructible with a malformed id.
|
||||
raise ValueError(
|
||||
"client_id must match contract shape 'agent:{instance_id}', "
|
||||
f"got {client_id!r}"
|
||||
)
|
||||
raise ValueError(f"client_id must match contract shape 'agent:{{instance_id}}', got {client_id!r}")
|
||||
self._client_id = client_id
|
||||
self._agent_instance_id = client_id[len("agent:") :]
|
||||
self._portal_url = portal_url.rstrip("/")
|
||||
self._jwks_url = f"{self._portal_url}/.well-known/jwks.json"
|
||||
self._authorize_url = f"{self._portal_url}/oauth/authorize"
|
||||
self._token_url = f"{self._portal_url}/api/oauth/token"
|
||||
# PyJWKClient is lazily imported so plugin discovery doesn't pay the
|
||||
# crypto-import cost when the provider isn't activated.
|
||||
self._jwks_client: Any = None
|
||||
self._jwks_client: Any = None # lazily built (crypto import cost)
|
||||
|
||||
# ---- public API (DashboardAuthProvider) -------------------------------
|
||||
|
||||
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
||||
self._validate_redirect_uri(redirect_uri)
|
||||
|
||||
code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
|
||||
code_challenge = _b64url_no_pad(
|
||||
hashlib.sha256(code_verifier.encode("ascii")).digest()
|
||||
validate_redirect_uri(redirect_uri)
|
||||
return pkce_login_start(
|
||||
self._authorize_url, client_id=self._client_id, scope=_SCOPE, redirect_uri=redirect_uri
|
||||
)
|
||||
state = _b64url_no_pad(secrets.token_bytes(32))
|
||||
|
||||
params = {
|
||||
"response_type": "code",
|
||||
"client_id": self._client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"scope": _SCOPE,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
redirect_url = f"{self._authorize_url}?{urllib.parse.urlencode(params)}"
|
||||
# The auth-route layer expects ``cookie_payload[\"hermes_session_pkce\"]``
|
||||
# as a single semicolon-delimited string of ``key=value`` segments,
|
||||
# matching the stub provider's shape. The route handler prepends
|
||||
# ``provider=`` so the callback knows which plugin to dispatch to.
|
||||
cookie_payload = {
|
||||
"hermes_session_pkce": f"state={state};verifier={code_verifier}",
|
||||
}
|
||||
return LoginStart(redirect_url=redirect_url, cookie_payload=cookie_payload)
|
||||
|
||||
def complete_login(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
state: str,
|
||||
code_verifier: str,
|
||||
redirect_uri: str,
|
||||
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
||||
) -> Session:
|
||||
# ``state`` is verified by the auth-route layer before this call
|
||||
# (it checks the cookie-stashed state matches the query-param state);
|
||||
# we just receive it for symmetry with the protocol. Nous Portal
|
||||
# doesn't re-check state at the token endpoint, so we ignore it here.
|
||||
_ = state
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
self._token_url,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": self._client_id,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(f"Portal token endpoint unreachable: {exc}") from exc
|
||||
|
||||
# The dashboard auth-code grant now issues a rotating refresh token
|
||||
# (24h session, reuse-detected) — Portal NAS PR #293. A 400 here means
|
||||
# the code/PKCE/redirect_uri failed, surfaced as InvalidCodeError.
|
||||
return self._token_response_to_session(
|
||||
response, bad_request_exc=InvalidCodeError
|
||||
# ``state`` is verified by the auth-route layer; Portal doesn't
|
||||
# re-check it at the token endpoint.
|
||||
return self._token_grant(
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"client_id": self._client_id,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
bad_request_exc=InvalidCodeError,
|
||||
)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
"""Rotate the access token using the refresh token.
|
||||
|
||||
Posts ``grant_type=refresh_token`` to Portal's token endpoint. The
|
||||
refresh token is sent in the ``X-Refresh-Token`` header (not the body)
|
||||
so it never lands in Portal's request-body access logs — mirroring the
|
||||
device-flow CLI convention; Portal reconciles header vs. body and
|
||||
rejects conflicts.
|
||||
|
||||
Portal rotates the refresh token on every successful refresh, so the
|
||||
returned ``Session.refresh_token`` is a NEW value the caller MUST
|
||||
persist (replacing the old cookie). Failing to persist it means the
|
||||
next refresh replays a rotated token and — outside Portal's 60s grace
|
||||
— trips reuse-detection and revokes the whole session.
|
||||
|
||||
Raises ``RefreshExpiredError`` on a 400 (expired / revoked / reuse-
|
||||
detected), so the middleware clears cookies and forces re-login.
|
||||
Raises ``ProviderError`` if Portal is unreachable.
|
||||
"""
|
||||
if not refresh_token:
|
||||
# No RT to present — treat as a dead session so middleware
|
||||
# forces a clean re-login rather than emitting a malformed POST.
|
||||
raise RefreshExpiredError("no refresh token present in session")
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
self._token_url,
|
||||
# The refresh token goes in BOTH the body and the
|
||||
# ``x-nous-refresh-token`` header. Portal's token endpoint
|
||||
# requires ``refresh_token`` in the body (its request schema
|
||||
# rejects a header-only request as ``invalid_request``), and
|
||||
# additionally reconciles the header against the body — sending
|
||||
# both lets Portal keep the value out of body-access-logs while
|
||||
# still satisfying the schema. The header name must match
|
||||
# Portal's ``REFRESH_TOKEN_HEADER`` exactly (``x-nous-refresh-
|
||||
# token``); any other name is silently ignored. (Verified
|
||||
# against the NAS #293 preview deploy: header-only → 400
|
||||
# invalid_request; body → accepted.)
|
||||
data={
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"x-nous-refresh-token": refresh_token,
|
||||
},
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
raise ProviderError(
|
||||
f"Portal token endpoint unreachable: {exc}"
|
||||
) from exc
|
||||
|
||||
# A 400 on refresh means the RT is expired / revoked / reuse-detected;
|
||||
# surface as RefreshExpiredError so middleware forces re-login.
|
||||
return self._token_response_to_session(
|
||||
response, bad_request_exc=RefreshExpiredError
|
||||
# The RT goes in BOTH the body (Portal's request schema requires it)
|
||||
# and the ``x-nous-refresh-token`` header (Portal reconciles the two
|
||||
# and keeps the value out of body access logs). Header-only → 400.
|
||||
return self._token_grant(
|
||||
{
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
"refresh_token": refresh_token,
|
||||
},
|
||||
headers={"x-nous-refresh-token": refresh_token},
|
||||
bad_request_exc=RefreshExpiredError,
|
||||
)
|
||||
|
||||
def _token_response_to_session(
|
||||
self,
|
||||
response: httpx.Response,
|
||||
*,
|
||||
bad_request_exc: type[Exception],
|
||||
) -> Session:
|
||||
"""Translate a Portal ``/api/oauth/token`` response into a Session.
|
||||
|
||||
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
|
||||
(refresh grant). ``bad_request_exc`` is the exception type raised on a
|
||||
400 — ``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
||||
for the refresh path — so the middleware's distinct handling
|
||||
(400-on-callback vs. force-relogin) is preserved.
|
||||
"""
|
||||
if response.status_code == 400:
|
||||
# Contract: invalid_code / invalid_grant / redirect_uri_mismatch
|
||||
# (auth-code) and expired / revoked / reuse-detected (refresh) all
|
||||
# surface as 400 with an OAuth-shaped JSON error envelope.
|
||||
body = self._parse_json_body(response)
|
||||
error_code = body.get("error", "invalid_request")
|
||||
raise bad_request_exc(f"Portal rejected token request: {error_code}")
|
||||
if response.status_code != 200:
|
||||
raise ProviderError(
|
||||
f"Portal token endpoint returned {response.status_code}: "
|
||||
f"{response.text[:200]!r}"
|
||||
)
|
||||
|
||||
payload = self._parse_json_body(response)
|
||||
access_token = payload.get("access_token")
|
||||
if not access_token or not isinstance(access_token, str):
|
||||
raise ProviderError("Portal token response missing access_token")
|
||||
|
||||
token_type = str(payload.get("token_type", "")).lower()
|
||||
if token_type and token_type != "bearer":
|
||||
raise ProviderError(f"unexpected token_type={token_type!r}")
|
||||
|
||||
claims = self._verify_jwt(access_token)
|
||||
# The dashboard grant issues a rotating refresh token; capture it so
|
||||
# the caller can persist it. Empty string if Portal omitted it (the
|
||||
# session then behaves as access-token-only until expiry).
|
||||
refresh_token = payload.get("refresh_token") or ""
|
||||
if not isinstance(refresh_token, str):
|
||||
refresh_token = ""
|
||||
return self._session_from_claims(access_token, refresh_token, claims)
|
||||
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
# Contract: returns None on expiry/invalidity (the middleware then
|
||||
# tries refresh_session with the RT cookie, falling back to
|
||||
# redirect-to-login if that also fails); raises ProviderError if the
|
||||
# IDP is unreachable.
|
||||
# None on expiry/invalidity (middleware then tries refresh); a
|
||||
# ProviderError (JWKS unreachable) bubbles up so middleware emits 503.
|
||||
try:
|
||||
claims = self._verify_jwt(access_token)
|
||||
except InvalidCodeError:
|
||||
# Expired/invalid token — middleware contract is None, not raise.
|
||||
return None
|
||||
except ProviderError:
|
||||
# JWKS unreachable, etc. Bubble up so middleware emits 503.
|
||||
raise
|
||||
# verify_session validates the AT in isolation and has no access to the
|
||||
# refresh token (it lives in a separate cookie the middleware reads);
|
||||
# pass "" here — the RT-driven rotation path is middleware's job.
|
||||
return self._session_from_claims(access_token, "", claims)
|
||||
return self._session(access_token, "", claims)
|
||||
|
||||
def revoke_session(self, *, refresh_token: str) -> None:
|
||||
# Portal exposes no public refresh-token revocation grant on its token
|
||||
# endpoint (revocation is driven from the authenticated /sessions UI,
|
||||
# keyed by sessionId + userId, not by the RT value). So logout is
|
||||
# client-side cookie clearing; the server-side refresh session simply
|
||||
# expires within its 24h TTL. Best-effort no-op, must not raise.
|
||||
#
|
||||
# If Portal later adds a token-endpoint revoke grant (e.g.
|
||||
# grant_type=... + X-Refresh-Token), implement it here so logout
|
||||
# invalidates the RT server-side immediately rather than waiting out
|
||||
# the TTL.
|
||||
_ = refresh_token
|
||||
# Portal exposes no token-endpoint revocation grant; logout is
|
||||
# client-side cookie clearing and the RT expires within its 24h TTL.
|
||||
return None
|
||||
|
||||
# ---- internals --------------------------------------------------------
|
||||
|
||||
def _validate_redirect_uri(self, redirect_uri: str) -> None:
|
||||
"""Surface obviously-broken redirect_uris before bouncing to Portal.
|
||||
|
||||
The Portal-side check (``agent-redirect-uri.ts``) is authoritative;
|
||||
this is a fast-fail for the common operator-error case. We allow any
|
||||
``http://`` host (not just localhost) so self-hosted dashboards reached
|
||||
over plain HTTP — LAN IPs, internal hostnames, reverse proxies that
|
||||
terminate TLS upstream — are not rejected here; Portal makes the final
|
||||
call on which redirect_uris are permitted.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(redirect_uri)
|
||||
if parsed.scheme not in ("https", "http"):
|
||||
raise ProviderError(
|
||||
f"redirect_uri must be http(s), got {redirect_uri!r}"
|
||||
)
|
||||
if not parsed.path or not parsed.path.endswith("/auth/callback"):
|
||||
raise ProviderError(
|
||||
"redirect_uri path must end with '/auth/callback', "
|
||||
f"got {redirect_uri!r}"
|
||||
)
|
||||
|
||||
def _parse_json_body(self, response: httpx.Response) -> Dict[str, Any]:
|
||||
ctype = response.headers.get("content-type", "")
|
||||
if not ctype.startswith("application/json"):
|
||||
return {}
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
def _token_grant(
|
||||
self, data: Dict[str, str], *, bad_request_exc: type[Exception],
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> Session:
|
||||
access_token, payload = exchange_token(
|
||||
self._token_url, data, headers=headers, bad_request_exc=bad_request_exc,
|
||||
idp="Portal", endpoint="Portal token endpoint", token_key="access_token",
|
||||
missing_msg="Portal token response missing access_token",
|
||||
)
|
||||
# Rotating RT the caller MUST persist back to the cookie.
|
||||
return self._session(access_token, refresh_token_from(payload), self._verify_jwt(access_token))
|
||||
|
||||
def _get_jwks_client(self) -> Any:
|
||||
if self._jwks_client is None:
|
||||
from jwt import PyJWKClient # lazy import
|
||||
|
||||
self._jwks_client = PyJWKClient(
|
||||
self._jwks_url,
|
||||
cache_keys=True,
|
||||
lifespan=_JWKS_CACHE_SECONDS,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
},
|
||||
)
|
||||
self._jwks_client = make_jwks_client(self._jwks_url)
|
||||
return self._jwks_client
|
||||
|
||||
def _verify_jwt(self, access_token: str) -> Dict[str, Any]:
|
||||
# Lazy import — keeps startup fast for operators who never trigger
|
||||
# the gated path.
|
||||
import jwt
|
||||
|
||||
try:
|
||||
signing_key = self._get_jwks_client().get_signing_key_from_jwt(
|
||||
access_token
|
||||
)
|
||||
except Exception as exc:
|
||||
# Unreachable JWKS -> ProviderError (503); a bearer that is not
|
||||
# one of our JWTs (opaque peer key, foreign kid) -> InvalidCodeError
|
||||
# (None / next provider). Folding both into 503 produced #94558.
|
||||
raise classify_jwks_lookup_error(exc) from exc
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
access_token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
# Contract C2: aud is the bare client_id.
|
||||
audience=self._client_id,
|
||||
# Contract: issuer is the Portal base URL.
|
||||
issuer=self._portal_url,
|
||||
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
|
||||
)
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
# verify_session() catches this and returns None per protocol.
|
||||
raise InvalidCodeError(f"access token expired: {exc}") from exc
|
||||
except jwt.InvalidTokenError as exc:
|
||||
# Surface the actual claim values that failed verification so
|
||||
# operators don't have to dig into the JWT to debug config drift
|
||||
# between HERMES_DASHBOARD_PORTAL_URL / HERMES_DASHBOARD_OAUTH_CLIENT_ID
|
||||
# and what Portal is actually emitting. Decoding without verification
|
||||
# is safe here: we've already failed to verify, and we never trust
|
||||
# these values — they're surfaced for diagnostics only.
|
||||
details = ""
|
||||
try:
|
||||
unverified = jwt.decode(
|
||||
access_token,
|
||||
options={"verify_signature": False, "verify_exp": False},
|
||||
)
|
||||
details = (
|
||||
f" [token iss={unverified.get('iss')!r} "
|
||||
f"aud={unverified.get('aud')!r}; "
|
||||
f"expected iss={self._portal_url!r} "
|
||||
f"aud={self._client_id!r}]"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ProviderError(
|
||||
f"access token verification failed: {exc}{details}"
|
||||
) from exc
|
||||
|
||||
self._check_agent_instance_id(claims)
|
||||
self._check_contract_version(claims)
|
||||
return claims
|
||||
|
||||
def _check_agent_instance_id(self, claims: Dict[str, Any]) -> None:
|
||||
"""Contract C9: cross-check agent_instance_id against our config."""
|
||||
claims = verify_jwt(
|
||||
access_token, self._get_jwks_client(), algorithms=["RS256"],
|
||||
audience=self._client_id, # contract C2: bare client_id
|
||||
issuer=self._portal_url, label="access token",
|
||||
)
|
||||
# Contract C9: agent_instance_id is "should" not "must" — tolerated
|
||||
# when absent (the aud check already binds the token to this instance).
|
||||
token_instance_id = claims.get("agent_instance_id")
|
||||
if token_instance_id is None:
|
||||
# Tolerated — the claim is documented as "should" not "must".
|
||||
# Our audience check on the bare client_id already binds the
|
||||
# token to this instance; agent_instance_id is defense-in-depth.
|
||||
return
|
||||
if token_instance_id != self._agent_instance_id:
|
||||
if token_instance_id is not None and token_instance_id != self._agent_instance_id:
|
||||
raise ProviderError(
|
||||
f"agent_instance_id mismatch: token={token_instance_id!r} "
|
||||
f"vs configured={self._agent_instance_id!r}"
|
||||
)
|
||||
|
||||
def _check_contract_version(self, claims: Dict[str, Any]) -> None:
|
||||
"""Contract C11 — tolerant treatment per OQ-C2."""
|
||||
contract_version = claims.get("oauth_contract_version")
|
||||
if contract_version is None:
|
||||
logger.warning(
|
||||
@@ -509,165 +180,70 @@ class NousDashboardAuthProvider(DashboardAuthProvider):
|
||||
"(contract says it should be %d); proceeding anyway.",
|
||||
_EXPECTED_CONTRACT_VERSION,
|
||||
)
|
||||
return
|
||||
if contract_version != _EXPECTED_CONTRACT_VERSION:
|
||||
elif contract_version != _EXPECTED_CONTRACT_VERSION:
|
||||
raise ProviderError(
|
||||
f"unsupported oauth_contract_version={contract_version!r}, "
|
||||
f"expected {_EXPECTED_CONTRACT_VERSION}"
|
||||
)
|
||||
return claims
|
||||
|
||||
def _session_from_claims(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
claims: Dict[str, Any],
|
||||
) -> Session:
|
||||
# Contract C4: no email / display_name in tokens. AuthWidget will
|
||||
# show user_id (truncated). Session fields kept for forward-compat.
|
||||
user_id = str(claims.get("sub", ""))
|
||||
if not user_id:
|
||||
raise ProviderError("token missing 'sub' (user_id) claim")
|
||||
return Session(
|
||||
user_id=user_id,
|
||||
email="",
|
||||
display_name="",
|
||||
def _session(self, access_token: str, refresh_token: str, claims: Dict[str, Any]) -> Session:
|
||||
# Contract C4: no email / display_name in tokens.
|
||||
return session_from_claims(
|
||||
self.name, claims, access_token=access_token, refresh_token=refresh_token,
|
||||
org_id=str(claims.get("org_id") or ""),
|
||||
provider=self.name,
|
||||
expires_at=int(claims["exp"]),
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---- Plugin entry point ----
|
||||
|
||||
def _load_config_oauth_section() -> dict:
|
||||
"""Return the ``dashboard.oauth`` block from ``config.yaml`` if it
|
||||
exists and is a dict; otherwise an empty dict.
|
||||
|
||||
Robust to (a) load_config() raising (malformed YAML, IO error,
|
||||
config.yaml absent — common in fresh installs), (b) the
|
||||
``dashboard`` key being absent or non-dict, and (c) the ``oauth``
|
||||
sub-key being present but not a dict (user typo). Each shape falls
|
||||
through to ``{}`` so register() can rely on `.get(...)` access.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
cfg = load_config()
|
||||
except Exception as exc: # noqa: BLE001 — broad catch is intentional
|
||||
logger.debug(
|
||||
"dashboard-auth-nous: load_config() raised %s; "
|
||||
"falling back to env-only configuration",
|
||||
exc,
|
||||
)
|
||||
return {}
|
||||
section = cfg_get(cfg, "dashboard", "oauth", default=None)
|
||||
return section if isinstance(section, dict) else {}
|
||||
|
||||
|
||||
def _resolve_client_id() -> str:
|
||||
"""Resolve the OAuth client_id with env-overrides-config precedence.
|
||||
|
||||
Order:
|
||||
1. ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var (when non-empty
|
||||
after strip — empty values are treated as unset so a
|
||||
provisioned-but-not-populated Fly secret can't shadow a valid
|
||||
config.yaml entry).
|
||||
2. ``dashboard.oauth.client_id`` in ``config.yaml``.
|
||||
3. Empty string — signals "no client_id configured" to the caller.
|
||||
"""
|
||||
env = os.environ.get("HERMES_DASHBOARD_OAUTH_CLIENT_ID", "").strip()
|
||||
if env:
|
||||
return env
|
||||
cfg_value = _load_config_oauth_section().get("client_id", "")
|
||||
return str(cfg_value).strip()
|
||||
|
||||
|
||||
def _resolve_portal_url() -> str:
|
||||
"""Resolve the Portal URL with env-overrides-config precedence.
|
||||
|
||||
Order:
|
||||
1. ``HERMES_DASHBOARD_PORTAL_URL`` env var (non-empty after strip).
|
||||
2. ``dashboard.oauth.portal_url`` in ``config.yaml``.
|
||||
3. :data:`_DEFAULT_PORTAL_URL` (production Portal).
|
||||
"""
|
||||
env = os.environ.get("HERMES_DASHBOARD_PORTAL_URL", "").strip()
|
||||
if env:
|
||||
return env
|
||||
cfg_value = str(
|
||||
_load_config_oauth_section().get("portal_url", "")
|
||||
).strip()
|
||||
return cfg_value or _DEFAULT_PORTAL_URL
|
||||
return load_config_section(logger, "dashboard-auth-nous", "dashboard", "oauth")
|
||||
|
||||
|
||||
def register(ctx) -> None:
|
||||
"""Plugin entry — called by the plugin loader at startup.
|
||||
"""Register ``NousDashboardAuthProvider`` when a client_id is configured.
|
||||
|
||||
Registers ``NousDashboardAuthProvider`` only when a client_id is
|
||||
configured (either via ``HERMES_DASHBOARD_OAUTH_CLIENT_ID`` env var
|
||||
or via ``dashboard.oauth.client_id`` in ``config.yaml``). The env
|
||||
var wins when set non-empty — Fly.io's platform-secret injection
|
||||
pushes the per-deploy value through this path.
|
||||
|
||||
When skipping, writes a short human-readable reason to the module-
|
||||
level :data:`LAST_SKIP_REASON` so the dashboard's fail-closed branch
|
||||
can surface "Set HERMES_DASHBOARD_OAUTH_CLIENT_ID …" instead of the
|
||||
bare "no providers registered" the gate would otherwise emit. The
|
||||
reason mentions BOTH configuration surfaces so operators don't
|
||||
guess wrong about which one to populate.
|
||||
|
||||
Operator-owned dashboards (loopback / ``--insecure``) leave both
|
||||
surfaces unset, so this plugin is a no-op for them. The gate-
|
||||
engagement layer (``hermes_cli.web_server.should_require_auth`` +
|
||||
the fail-closed check in ``start_server``) handles the "public bind
|
||||
with zero providers" case independently.
|
||||
On skip, writes a reason naming BOTH configuration surfaces to
|
||||
:data:`LAST_SKIP_REASON` for the gate's fail-closed error message.
|
||||
"""
|
||||
global LAST_SKIP_REASON
|
||||
LAST_SKIP_REASON = ""
|
||||
|
||||
client_id = _resolve_client_id()
|
||||
portal_url = _resolve_portal_url()
|
||||
section = _load_config_oauth_section()
|
||||
client_id = resolve_env_or_cfg("HERMES_DASHBOARD_OAUTH_CLIENT_ID", section.get("client_id", ""))
|
||||
portal_url = (
|
||||
resolve_env_or_cfg("HERMES_DASHBOARD_PORTAL_URL", section.get("portal_url", ""))
|
||||
or _DEFAULT_PORTAL_URL
|
||||
)
|
||||
|
||||
if not client_id:
|
||||
LAST_SKIP_REASON = (
|
||||
"HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and "
|
||||
"dashboard.oauth.client_id in config.yaml is empty). The "
|
||||
"Nous Portal provisions this env var (shape "
|
||||
"'agent:{instance_id}') when it deploys a Hermes Agent "
|
||||
"instance — set it to your provisioned client id (either "
|
||||
"as an env var or under dashboard.oauth.client_id in "
|
||||
"config.yaml), or pass --insecure to skip the OAuth gate "
|
||||
"entirely."
|
||||
"HERMES_DASHBOARD_OAUTH_CLIENT_ID is not set (and dashboard.oauth.client_id "
|
||||
"in config.yaml is empty). The Nous Portal provisions this env var (shape "
|
||||
"'agent:{instance_id}') when it deploys a Hermes Agent instance — set it to "
|
||||
"your provisioned client id (either as an env var or under "
|
||||
"dashboard.oauth.client_id in config.yaml), or pass --insecure to skip the "
|
||||
"OAuth gate entirely."
|
||||
)
|
||||
logger.debug("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
if not client_id.startswith("agent:"):
|
||||
LAST_SKIP_REASON = (
|
||||
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match "
|
||||
f"the contract shape 'agent:{{instance_id}}'. The Nous Portal "
|
||||
f"provisions this value at deploy time; check your Fly app's "
|
||||
f"secrets or override with the value from the Portal admin UI."
|
||||
f"HERMES_DASHBOARD_OAUTH_CLIENT_ID={client_id!r} doesn't match the contract "
|
||||
f"shape 'agent:{{instance_id}}'. The Nous Portal provisions this value at deploy "
|
||||
f"time; check your Fly app's secrets or override with the value from the Portal admin UI."
|
||||
)
|
||||
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
try:
|
||||
provider = NousDashboardAuthProvider(
|
||||
client_id=client_id, portal_url=portal_url
|
||||
)
|
||||
provider = NousDashboardAuthProvider(client_id=client_id, portal_url=portal_url)
|
||||
except ValueError as exc:
|
||||
LAST_SKIP_REASON = f"NousDashboardAuthProvider construction failed: {exc}"
|
||||
logger.warning("dashboard-auth-nous: %s", LAST_SKIP_REASON)
|
||||
return
|
||||
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-nous: registered provider (client_id=%s, portal=%s)",
|
||||
client_id,
|
||||
portal_url,
|
||||
)
|
||||
logger.info("dashboard-auth-nous: registered provider (client_id=%s, portal=%s)", client_id, portal_url)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
import plugins.dashboard_auth.nous as nous_plugin
|
||||
from plugins.dashboard_auth._shared import JWKS_CACHE_SECONDS
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
@@ -422,7 +423,7 @@ class TestCompleteLogin:
|
||||
"refresh_token": "rt_initial_value",
|
||||
},
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
|
||||
session = provider.complete_login(
|
||||
code="abc",
|
||||
state="state-val",
|
||||
@@ -443,7 +444,7 @@ class TestCompleteLogin:
|
||||
|
||||
def test_400_raises_invalid_code(self, provider):
|
||||
mock_resp = self._mock_post(400, {"error": "invalid_grant"})
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(InvalidCodeError, match="invalid_grant"):
|
||||
provider.complete_login(
|
||||
code="bad", state="s", code_verifier="v",
|
||||
@@ -453,7 +454,7 @@ class TestCompleteLogin:
|
||||
def test_500_raises_provider_error(self, provider):
|
||||
mock_resp = self._mock_post(500, "internal server error", ctype="text/plain")
|
||||
mock_resp.text = "internal server error"
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="500"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
@@ -462,7 +463,7 @@ class TestCompleteLogin:
|
||||
|
||||
def test_missing_access_token_raises(self, provider):
|
||||
mock_resp = self._mock_post(200, {"token_type": "Bearer"})
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="access_token"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
@@ -474,7 +475,7 @@ class TestCompleteLogin:
|
||||
mock_resp = self._mock_post(
|
||||
200, {"access_token": access_token, "token_type": "DPoP"}
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
|
||||
with pytest.raises(ProviderError, match="token_type"):
|
||||
provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
@@ -483,7 +484,7 @@ class TestCompleteLogin:
|
||||
|
||||
def test_network_error_raises_provider_error(self, provider):
|
||||
with patch(
|
||||
"plugins.dashboard_auth.nous.httpx.post",
|
||||
"plugins.dashboard_auth._shared.httpx.post",
|
||||
side_effect=httpx.ConnectError("conn refused"),
|
||||
):
|
||||
with pytest.raises(ProviderError, match="unreachable"):
|
||||
@@ -506,7 +507,7 @@ class TestCompleteLogin:
|
||||
"refresh_token": "rt-opaque",
|
||||
},
|
||||
)
|
||||
with patch("plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp):
|
||||
with patch("plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp):
|
||||
session = provider.complete_login(
|
||||
code="x", state="s", code_verifier="v",
|
||||
redirect_uri="https://hermes.fly.dev/auth/callback",
|
||||
@@ -538,7 +539,7 @@ class TestVerifySession:
|
||||
client_cls.assert_called_once_with(
|
||||
provider._jwks_url,
|
||||
cache_keys=True,
|
||||
lifespan=nous_plugin._JWKS_CACHE_SECONDS,
|
||||
lifespan=JWKS_CACHE_SECONDS,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
@@ -638,7 +639,7 @@ class TestRefreshAndRevoke:
|
||||
},
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.nous.httpx.post", return_value=mock_resp
|
||||
"plugins.dashboard_auth._shared.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
session = provider.refresh_session(refresh_token="rt_old_value")
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
|
||||
import plugins.dashboard_auth.self_hosted as oidc_plugin
|
||||
from plugins.dashboard_auth._shared import JWKS_CACHE_SECONDS
|
||||
from hermes_cli.dashboard_auth import (
|
||||
InvalidCodeError,
|
||||
LoginStart,
|
||||
@@ -584,7 +585,7 @@ class TestVerifySession:
|
||||
client_cls.assert_called_once_with(
|
||||
_DISCOVERY_DOC["jwks_uri"],
|
||||
cache_keys=True,
|
||||
lifespan=oidc_plugin._JWKS_CACHE_SECONDS,
|
||||
lifespan=JWKS_CACHE_SECONDS,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "HermesAgent/1.0",
|
||||
|
||||
Reference in New Issue
Block a user