440 lines
19 KiB
Python
440 lines
19 KiB
Python
"""SelfHostedOIDCProvider — generic self-hosted OpenID Connect dashboard auth.
|
|
|
|
A standards-compliant OIDC Relying Party for the ``hermes dashboard`` gate.
|
|
Unlike the ``nous`` provider (Nous Portal's bespoke contract), this speaks
|
|
plain OIDC so it works against Authentik, Keycloak, Zitadel, Authelia, Auth0,
|
|
Okta, Google, … The HTTP round trip, cookies, CSRF ``state`` check and
|
|
``redirect_uri`` reconstruction are owned by ``hermes_cli/dashboard_auth/
|
|
routes.py``; this provider only:
|
|
|
|
1. discovers endpoints from ``{issuer}/.well-known/openid-configuration``,
|
|
2. builds the ``/authorize`` URL with PKCE (S256),
|
|
3. exchanges the code at the discovered ``token_endpoint``,
|
|
4. verifies the **ID token** against the discovered ``jwks_uri`` with
|
|
``iss``/``aud`` pinned, mapping ``sub``/``email``/``name`` onto a Session.
|
|
|
|
Why the ID token, not the access token: OIDC guarantees the ID token is a
|
|
signed JWT carrying identity claims; the access token's format is opaque per
|
|
spec (many IDPs issue random strings). The ``nous`` provider verifies its
|
|
access token only because Portal mints a custom JWT there.
|
|
|
|
Public (PKCE-only) and confidential (PKCE + ``client_secret``) clients are
|
|
both supported. With a secret, the client additionally authenticates at the
|
|
token endpoint via ``client_secret_basic`` or ``client_secret_post`` chosen
|
|
from ``token_endpoint_auth_methods_supported``. PKCE is sent in both modes —
|
|
the secret is layered on top, never a replacement (OAuth 2.1 / RFC 9700).
|
|
|
|
Configuration (env wins over config.yaml when set non-empty)::
|
|
|
|
dashboard:
|
|
oauth:
|
|
provider: self-hosted
|
|
self_hosted:
|
|
issuer: https://auth.example.com/application/o/hermes/ # required
|
|
client_id: hermes-dashboard # required
|
|
scopes: "openid profile email" # optional
|
|
# client_secret: confidential clients only — prefer the env var.
|
|
|
|
HERMES_DASHBOARD_OIDC_ISSUER
|
|
HERMES_DASHBOARD_OIDC_CLIENT_ID
|
|
HERMES_DASHBOARD_OIDC_SCOPES # optional
|
|
HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; .env is its canonical home
|
|
|
|
On skip (missing issuer / client_id) the module-level :data:`LAST_SKIP_REASON`
|
|
carries a human-readable reason for the gate's fail-closed error.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import logging
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
from typing import Any, Dict, Optional
|
|
|
|
import httpx
|
|
|
|
from hermes_cli.dashboard_auth import (
|
|
DashboardAuthProvider,
|
|
InvalidCodeError,
|
|
LoginStart,
|
|
ProviderError,
|
|
RefreshExpiredError,
|
|
Session,
|
|
)
|
|
from plugins.dashboard_auth._shared import (
|
|
JSON_HEADERS,
|
|
TOKEN_ENDPOINT_TIMEOUT_SEC as _TOKEN_ENDPOINT_TIMEOUT_SEC,
|
|
exchange_token,
|
|
load_config_section,
|
|
make_jwks_client,
|
|
parse_json_body,
|
|
pkce_login_start,
|
|
refresh_token_from,
|
|
resolve_env_or_cfg,
|
|
session_from_claims,
|
|
validate_redirect_uri,
|
|
verify_jwt,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ``openid`` is mandatory (no ID token without it); profile/email populate
|
|
# display_name/email.
|
|
_DEFAULT_SCOPES = "openid profile email"
|
|
|
|
# RS256 is the OIDC default; ES256 is common on modern IDPs (Zitadel, newer
|
|
# Keycloak). HS256 is deliberately excluded: it implies a shared secret we
|
|
# don't hold in the public-client model and is a JWT algorithm-confusion footgun.
|
|
_ALLOWED_ID_TOKEN_ALGS = ("RS256", "ES256", "RS384", "RS512", "ES384", "ES512")
|
|
|
|
_DISCOVERY_TIMEOUT_SEC = 10.0
|
|
# Discovery is effectively static; a soft TTL lets a long-running dashboard
|
|
# pick up an IDP endpoint migration within the hour.
|
|
_DISCOVERY_CACHE_TTL_SEC = 3600
|
|
|
|
LAST_SKIP_REASON: str = ""
|
|
|
|
|
|
def _require_https_or_loopback(url: str, *, field: str) -> str:
|
|
"""Reject non-HTTPS endpoint URLs (loopback http allowed) so a misconfigured
|
|
issuer can't ship auth codes / refresh tokens in cleartext."""
|
|
parsed = urllib.parse.urlparse(url)
|
|
if parsed.scheme == "https" or (
|
|
parsed.scheme == "http"
|
|
and (parsed.hostname or "") in ("localhost", "127.0.0.1", "::1")
|
|
):
|
|
return url
|
|
raise ProviderError(
|
|
f"OIDC {field} must be https:// (or http on localhost), got {url!r}"
|
|
)
|
|
|
|
|
|
class SelfHostedOIDCProvider(DashboardAuthProvider):
|
|
"""Generic self-hosted OpenID Connect provider (authorization-code + PKCE)."""
|
|
|
|
name = "self-hosted"
|
|
display_name = "Self-Hosted OIDC"
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
issuer: str,
|
|
client_id: str,
|
|
scopes: str = _DEFAULT_SCOPES,
|
|
client_secret: str = "",
|
|
) -> None:
|
|
if not issuer:
|
|
raise ValueError("issuer is required")
|
|
if not client_id:
|
|
raise ValueError("client_id is required")
|
|
# Trailing slash normalised for stable compares; ``iss`` is pinned
|
|
# against the *discovered* issuer so a config/IDP slash mismatch is tolerated.
|
|
self._issuer = issuer.rstrip("/")
|
|
_require_https_or_loopback(self._issuer, field="issuer")
|
|
self._client_id = client_id
|
|
self._scopes = scopes.strip() or _DEFAULT_SCOPES
|
|
# Empty/whitespace secret ⇒ public client, so a provisioned-but-blank
|
|
# secret can't flip us into a broken confidential mode.
|
|
self._client_secret = (client_secret or "").strip()
|
|
|
|
# Discovery + JWKS resolve lazily so registration never hits the
|
|
# network (the IDP may be down at boot; fail per-request instead).
|
|
self._discovery: Dict[str, Any] | None = None
|
|
self._discovery_fetched_at: float = 0.0
|
|
self._discovery_lock = threading.Lock()
|
|
self._jwks_client: Any = None
|
|
|
|
# ---- public API (DashboardAuthProvider) -------------------------------
|
|
|
|
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
|
# Validate the redirect before discovery so a bad redirect_uri
|
|
# surfaces even when the IDP is unreachable.
|
|
validate_redirect_uri(redirect_uri)
|
|
disco = self._get_discovery()
|
|
return pkce_login_start(
|
|
disco["authorization_endpoint"], client_id=self._client_id,
|
|
scope=self._scopes, redirect_uri=redirect_uri,
|
|
)
|
|
|
|
def complete_login(
|
|
self, *, code: str, state: str, code_verifier: str, redirect_uri: str
|
|
) -> Session:
|
|
# ``state`` is verified by the auth-route layer before this call.
|
|
return self._exchange(
|
|
{
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": redirect_uri,
|
|
"client_id": self._client_id,
|
|
"code_verifier": code_verifier,
|
|
},
|
|
bad_request_exc=InvalidCodeError,
|
|
)
|
|
|
|
def refresh_session(self, *, refresh_token: str) -> Session:
|
|
if not refresh_token:
|
|
raise RefreshExpiredError("no refresh token present in session")
|
|
return self._exchange(
|
|
{
|
|
"grant_type": "refresh_token",
|
|
"client_id": self._client_id,
|
|
"refresh_token": refresh_token,
|
|
# Re-request the same scopes so the rotated ID token keeps its
|
|
# identity claims (some IDPs narrow scope on refresh otherwise).
|
|
"scope": self._scopes,
|
|
},
|
|
bad_request_exc=RefreshExpiredError,
|
|
previous_refresh_token=refresh_token,
|
|
)
|
|
|
|
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
|
# The session cookie carries the ID token in the access-token slot
|
|
# (see _session_from_tokens) so this per-request check verifies a real
|
|
# JWT. None on expiry/invalidity; ProviderError if IDP/JWKS unreachable.
|
|
try:
|
|
claims = self._verify_id_token(access_token)
|
|
except InvalidCodeError:
|
|
return None
|
|
return self._session(access_token, "", claims)
|
|
|
|
def revoke_session(self, *, refresh_token: str) -> None:
|
|
# Best-effort RFC 7009 revocation when the IDP advertises an endpoint.
|
|
# Must never raise — logout is client-side cookie clearing regardless.
|
|
if not refresh_token:
|
|
return None
|
|
try:
|
|
disco = self._get_discovery()
|
|
except ProviderError:
|
|
return None
|
|
endpoint = str(disco.get("revocation_endpoint") or "").strip()
|
|
if not endpoint:
|
|
return None
|
|
# Confidential clients must authenticate on revocation too (RFC 7009 §2.1).
|
|
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
|
data = {"token": refresh_token, "token_type_hint": "refresh_token", "client_id": self._client_id, **extra_data}
|
|
try:
|
|
httpx.post(endpoint, data=data, headers={**JSON_HEADERS, **extra_headers}, timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC)
|
|
except Exception as exc: # noqa: BLE001 — best-effort
|
|
logger.debug("self-hosted OIDC: revoke failed (ignored): %s", exc)
|
|
return None
|
|
|
|
# ---- internals: token exchange ----------------------------------------
|
|
|
|
def _token_endpoint_auth(self, disco: Dict[str, Any]) -> tuple[Dict[str, str], Dict[str, str]]:
|
|
"""Return ``(extra_data, extra_headers)`` for token-endpoint client auth.
|
|
|
|
Public client → ``({}, {})`` (PKCE alone). Confidential client →
|
|
``client_secret_post`` when the IDP advertises it *without*
|
|
``client_secret_basic``, else HTTP Basic (the OIDC default and the
|
|
fallback when nothing is advertised). RFC 6749 §2.3.1.
|
|
"""
|
|
if not self._client_secret:
|
|
return {}, {}
|
|
methods = disco.get("token_endpoint_auth_methods_supported") or []
|
|
if "client_secret_post" in methods and "client_secret_basic" not in methods:
|
|
return {"client_secret": self._client_secret}, {}
|
|
# Both halves must be form-url-encoded *before* base64 (RFC 6749
|
|
# §2.3.1) or a secret containing ':' / reserved chars corrupts the header.
|
|
userpass = f"{urllib.parse.quote(self._client_id, safe='')}:{urllib.parse.quote(self._client_secret, safe='')}"
|
|
return {}, {"Authorization": f"Basic {base64.b64encode(userpass.encode('utf-8')).decode('ascii')}"}
|
|
|
|
def _exchange(
|
|
self,
|
|
data: Dict[str, str],
|
|
*,
|
|
bad_request_exc: type[Exception],
|
|
previous_refresh_token: str = "",
|
|
) -> Session:
|
|
"""POST the discovered token endpoint and turn the response into a Session.
|
|
|
|
Confidential-client auth (body field or Basic header) is added here
|
|
for both grants — the IDP rejects an unauthenticated refresh with
|
|
``invalid_client``. For a public client the request is PKCE-only.
|
|
"""
|
|
disco = self._get_discovery()
|
|
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
|
id_token, payload = exchange_token(
|
|
disco["token_endpoint"],
|
|
{**data, **extra_data},
|
|
headers=extra_headers,
|
|
bad_request_exc=bad_request_exc,
|
|
idp="IDP",
|
|
endpoint="OIDC token endpoint",
|
|
token_key="id_token",
|
|
missing_msg=(
|
|
"OIDC token response missing id_token — ensure the 'openid' "
|
|
"scope is configured and the client is allowed to receive an "
|
|
"ID token."
|
|
),
|
|
)
|
|
claims = self._verify_id_token(id_token)
|
|
# Prefer a freshly-issued RT, else keep the previous (some IDPs don't rotate).
|
|
return self._session(id_token, refresh_token_from(payload, previous_refresh_token), claims)
|
|
|
|
# ---- internals: discovery ---------------------------------------------
|
|
|
|
def _fresh_discovery(self) -> Dict[str, Any] | None:
|
|
if self._discovery is not None and time.time() - self._discovery_fetched_at < _DISCOVERY_CACHE_TTL_SEC:
|
|
return self._discovery
|
|
return None
|
|
|
|
def _get_discovery(self) -> Dict[str, Any]:
|
|
"""Return the cached OIDC discovery document, fetching if stale (double-checked lock)."""
|
|
disco = self._fresh_discovery()
|
|
if disco is None:
|
|
with self._discovery_lock:
|
|
disco = self._fresh_discovery()
|
|
if disco is None:
|
|
disco = self._discovery = self._fetch_discovery()
|
|
self._discovery_fetched_at = time.time()
|
|
# New issuer/keys → rebind the JWKS client to the fresh jwks_uri.
|
|
self._jwks_client = None
|
|
return disco
|
|
|
|
def _fetch_discovery(self) -> Dict[str, Any]:
|
|
url = f"{self._issuer}/.well-known/openid-configuration"
|
|
try:
|
|
# follow_redirects=True: many IDPs answer discovery with a 3xx
|
|
# (Authentik canonicalises .well-known; proxies upgrade http→https)
|
|
# and httpx defaults to not following. Safe because the issuer pin
|
|
# and HTTPS checks below validate the *resolved* document, so a
|
|
# redirect to a hostile location can't smuggle in a bad issuer or a
|
|
# cleartext endpoint. The token/revocation POSTs deliberately do
|
|
# NOT follow redirects (they carry an auth code / refresh token).
|
|
response = httpx.get(url, headers=JSON_HEADERS, timeout=_DISCOVERY_TIMEOUT_SEC, follow_redirects=True)
|
|
except httpx.RequestError as exc:
|
|
raise ProviderError(f"OIDC discovery unreachable: {exc}") from exc
|
|
if response.status_code != 200:
|
|
raise ProviderError(f"OIDC discovery returned {response.status_code} for {url!r}")
|
|
payload = parse_json_body(response)
|
|
if not payload:
|
|
raise ProviderError("OIDC discovery returned a non-JSON body")
|
|
|
|
def field(key: str) -> str:
|
|
return str(payload.get(key, "") or "").strip()
|
|
|
|
endpoints = {k: field(k) for k in ("authorization_endpoint", "token_endpoint", "jwks_uri")}
|
|
if not all(endpoints.values()):
|
|
raise ProviderError(
|
|
"OIDC discovery missing one of authorization_endpoint / "
|
|
"token_endpoint / jwks_uri"
|
|
)
|
|
|
|
# Issuer pin: a mismatch means the document came from the wrong place
|
|
# (proxy/MITM/misconfig). Only a trailing-slash difference is tolerated.
|
|
advertised_issuer = field("issuer")
|
|
if advertised_issuer and advertised_issuer.rstrip("/") != self._issuer:
|
|
raise ProviderError(
|
|
f"OIDC discovery issuer mismatch: document advertises "
|
|
f"{advertised_issuer!r} but configured issuer is "
|
|
f"{self._issuer!r}"
|
|
)
|
|
for key, url in endpoints.items():
|
|
_require_https_or_loopback(url, field=key)
|
|
|
|
# Absent/garbage auth-methods → [] → OIDC default (basic) applies.
|
|
auth_methods_raw = payload.get("token_endpoint_auth_methods_supported")
|
|
return {
|
|
"issuer": advertised_issuer or self._issuer,
|
|
**endpoints,
|
|
"revocation_endpoint": field("revocation_endpoint"),
|
|
"token_endpoint_auth_methods_supported": (
|
|
[str(m) for m in auth_methods_raw] if isinstance(auth_methods_raw, list) else []
|
|
),
|
|
}
|
|
|
|
# ---- internals: JWT verification + mapping ----------------------------
|
|
|
|
def _get_jwks_client(self) -> Any:
|
|
if self._jwks_client is None:
|
|
self._jwks_client = make_jwks_client(self._get_discovery()["jwks_uri"])
|
|
return self._jwks_client
|
|
|
|
def _verify_id_token(self, id_token: str) -> Dict[str, Any]:
|
|
issuer = self._get_discovery()["issuer"]
|
|
return verify_jwt(
|
|
id_token, self._get_jwks_client(), algorithms=list(_ALLOWED_ID_TOKEN_ALGS),
|
|
audience=self._client_id, issuer=issuer, label="ID token",
|
|
)
|
|
|
|
def _session(self, id_token: str, refresh_token: str, claims: Dict[str, Any]) -> Session:
|
|
"""Map verified OIDC claims onto a Session.
|
|
|
|
The verified ID token is stored in ``Session.access_token`` so the
|
|
per-request ``verify_session`` re-verifies a real JWT; the opaque OAuth
|
|
access token is not kept — the dashboard only needs identity.
|
|
"""
|
|
email = str(claims.get("email", "") or "")
|
|
# Org/tenant is non-standard: accept common spellings, else join
|
|
# ``groups`` so multi-tenant IDPs surface *something* (free-form string).
|
|
org_id = claims.get("org_id") or claims.get("organization") or ""
|
|
groups = claims.get("groups")
|
|
if not org_id and isinstance(groups, list) and groups:
|
|
org_id = ",".join(str(g) for g in groups)
|
|
return session_from_claims(
|
|
self.name, claims, access_token=id_token, refresh_token=refresh_token,
|
|
label="ID token", email=email,
|
|
display_name=str(
|
|
claims.get("name") or claims.get("preferred_username")
|
|
or claims.get("nickname") or email or ""
|
|
),
|
|
org_id=str(org_id or ""),
|
|
)
|
|
|
|
|
|
# ---- Plugin entry point ----
|
|
|
|
def _load_config_oauth_section() -> dict:
|
|
return load_config_section(logger, "dashboard-auth-self-hosted", "dashboard", "oauth", "self_hosted")
|
|
|
|
|
|
def register(ctx) -> None:
|
|
"""Register :class:`SelfHostedOIDCProvider` when issuer + client_id are set.
|
|
|
|
On skip, :data:`LAST_SKIP_REASON` names BOTH configuration surfaces so
|
|
operators don't guess wrong about which to set.
|
|
"""
|
|
global LAST_SKIP_REASON
|
|
LAST_SKIP_REASON = ""
|
|
|
|
oidc_cfg = _load_config_oauth_section()
|
|
|
|
def setting(env_name: str, cfg_key: str) -> str:
|
|
return resolve_env_or_cfg(env_name, oidc_cfg.get(cfg_key))
|
|
|
|
issuer = setting("HERMES_DASHBOARD_OIDC_ISSUER", "issuer")
|
|
client_id = setting("HERMES_DASHBOARD_OIDC_CLIENT_ID", "client_id")
|
|
scopes = setting("HERMES_DASHBOARD_OIDC_SCOPES", "scopes") or _DEFAULT_SCOPES
|
|
# Credential: canonical home is the env var / ~/.hermes/.env. Empty ⇒ public client.
|
|
client_secret = setting("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "client_secret")
|
|
|
|
if not issuer or not client_id:
|
|
LAST_SKIP_REASON = (
|
|
"Self-hosted OIDC dashboard auth is not configured. Set both an issuer and "
|
|
"a client_id — either as env vars (HERMES_DASHBOARD_OIDC_ISSUER + "
|
|
"HERMES_DASHBOARD_OIDC_CLIENT_ID) or under "
|
|
"dashboard.oauth.self_hosted.{issuer,client_id} in config.yaml — or pass "
|
|
"--insecure to skip the OAuth gate entirely. (issuer set: %s; client_id set: %s)"
|
|
% (bool(issuer), bool(client_id))
|
|
)
|
|
logger.debug("dashboard-auth-self-hosted: %s", LAST_SKIP_REASON)
|
|
return
|
|
|
|
try:
|
|
provider = SelfHostedOIDCProvider(
|
|
issuer=issuer, client_id=client_id, scopes=scopes, client_secret=client_secret
|
|
)
|
|
except (ValueError, ProviderError) as exc:
|
|
LAST_SKIP_REASON = f"SelfHostedOIDCProvider construction failed: {exc}"
|
|
logger.warning("dashboard-auth-self-hosted: %s", LAST_SKIP_REASON)
|
|
return
|
|
|
|
ctx.register_dashboard_auth_provider(provider)
|
|
logger.info(
|
|
"dashboard-auth-self-hosted: registered provider "
|
|
"(issuer=%s, client_id=%s, scopes=%r, confidential=%s)",
|
|
issuer, client_id, scopes, bool(client_secret), # never log the secret itself
|
|
)
|