252 lines
9.0 KiB
Python
252 lines
9.0 KiB
Python
"""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
|