mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
The self-hosted OIDC dashboard provider was public-client + PKCE only, with two `# TODO(confidential-client)` seams. Authentik and Keycloak commonly default a new OIDC client to *confidential*, whose token endpoint rejects an unauthenticated exchange (`invalid_client`) — so a self-hoster who accepts their IDP's default could not complete dashboard login without manually flipping the client to public. Add optional confidential-client support: - New optional `client_secret` (env `HERMES_DASHBOARD_OIDC_CLIENT_SECRET`, or `dashboard.oauth.self_hosted.client_secret`; env-wins-config, empty treated as unset). It is a credential, so docs steer operators to the `.env` file; config.yaml is supported only for precedence symmetry. - `_token_endpoint_auth()` selects `client_secret_basic` (HTTP Basic header) vs `client_secret_post` (form body) from the IDP's advertised `token_endpoint_auth_methods_supported`, defaulting to basic (the OIDC default) when absent. Applied to complete_login, refresh_session, and revoke_session (RFC 7009 §2.1). - PKCE is sent in BOTH modes — the secret is client authentication layered on top, never a replacement (OAuth 2.1 / RFC 9700 keep PKCE mandatory). - Basic header url-encodes client_id/secret before base64 per RFC 6749 §2.3.1, so reserved chars (`:`, `@`, space) round-trip correctly. Non-breaking: with no secret configured the provider is a pure public PKCE client, byte-identical to prior behaviour (no Authorization header, no client_secret in the body). The secret is never logged — register() reports only a `confidential=<bool>` flag. Tests: 16 new cases covering basic/post selection, default-when-absent, public-unchanged contract, PKCE-preserved, reserved-char url-encoding, blank-secret-is-public, refresh + revoke auth, no-secret-in-logs, and env/config register wiring. Full dashboard-auth suite (nous provider, middleware, gate, cookies, WS, 401-reauth, status endpoint) — 396 tests — green, proving no existing auth path regressed.
858 lines
35 KiB
Python
858 lines
35 KiB
Python
"""SelfHostedOIDCProvider — generic self-hosted OpenID Connect dashboard auth.
|
|
|
|
A standards-compliant OpenID Connect Relying Party for the ``hermes dashboard``
|
|
OAuth gate. Unlike the bundled ``nous`` provider (which encodes Nous Portal's
|
|
bespoke contract — ``agent:{instance_id}`` client ids, a custom access-token
|
|
JWT, the ``x-nous-refresh-token`` header, an ``oauth_contract_version`` claim),
|
|
this provider speaks **plain OIDC** so it works against any conformant
|
|
self-hosted identity provider:
|
|
|
|
Authentik · Keycloak · Zitadel · Authelia · Auth0 · Okta · Google · …
|
|
|
|
It is a pure drop-in plugin: it implements the five
|
|
:class:`~hermes_cli.dashboard_auth.DashboardAuthProvider` methods and touches
|
|
nothing in core auth/runtime/login. The HTTP round trip, cookies, CSRF
|
|
``state`` check and ``redirect_uri`` reconstruction are all owned by
|
|
``hermes_cli/dashboard_auth/routes.py``; this provider only:
|
|
|
|
1. discovers the IDP's endpoints from ``{issuer}/.well-known/openid-configuration``,
|
|
2. builds the ``/authorize`` URL with PKCE (S256),
|
|
3. exchanges the authorization code for tokens at the discovered
|
|
``token_endpoint``,
|
|
4. verifies the **ID token** (RS256/ES256) against the discovered
|
|
``jwks_uri`` with ``iss`` / ``aud`` pinned to the configured issuer /
|
|
client id, and maps standard OIDC claims (``sub``, ``email``, ``name``)
|
|
onto a :class:`~hermes_cli.dashboard_auth.Session`.
|
|
|
|
Why the ID token (not the access token)? OIDC guarantees the ID token is a
|
|
signed JWT carrying identity claims — that is its entire purpose. The access
|
|
token's format is opaque to the client per the spec; many IDPs issue random
|
|
opaque strings the client cannot verify locally. Verifying the ID token is the
|
|
only choice that is universally correct across self-hosted IDPs. (The ``nous``
|
|
provider verifies its *access* token because Nous Portal mints a custom JWT
|
|
access token with the dashboard claims baked in — a non-OIDC shortcut.)
|
|
|
|
Both **public** (PKCE-only) and **confidential** (PKCE + ``client_secret``)
|
|
clients are supported. A self-hoster who registers a public client configures
|
|
no secret and the token-endpoint calls authenticate with PKCE alone (the
|
|
default). A self-hoster whose IDP defaults the client to *confidential*
|
|
(Authentik and Keycloak commonly do) sets ``client_secret`` and the provider
|
|
additionally authenticates the client at the token endpoint, choosing
|
|
``client_secret_basic`` (HTTP Basic header) or ``client_secret_post`` (secret
|
|
in the form body) from the IDP's advertised
|
|
``token_endpoint_auth_methods_supported``. PKCE is sent in **both** modes —
|
|
the secret is client authentication layered on top, never a replacement for
|
|
PKCE (OAuth 2.1 / RFC 9700 keep PKCE mandatory regardless).
|
|
|
|
Configuration surfaces (env wins over config.yaml when set non-empty, so a
|
|
provisioned-but-not-populated secret can't shadow a valid config.yaml entry —
|
|
same precedence convention as the ``nous`` plugin)::
|
|
|
|
# config.yaml — canonical surface
|
|
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: set ONLY for a confidential client. It is a
|
|
# credential — prefer the env var / ~/.hermes/.env over config.yaml.
|
|
|
|
# Environment overrides (Docker/Fly secret injection)
|
|
HERMES_DASHBOARD_OIDC_ISSUER
|
|
HERMES_DASHBOARD_OIDC_CLIENT_ID
|
|
HERMES_DASHBOARD_OIDC_SCOPES # optional; defaults to "openid profile email"
|
|
HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; set for a confidential client
|
|
# (the .env file is the canonical home —
|
|
# it's a secret, not a behavioural setting)
|
|
|
|
Skip reasons: when the plugin loads but can't register (missing issuer /
|
|
client_id), it writes a human-readable reason to the module-level
|
|
:data:`LAST_SKIP_REASON` so the gate's fail-closed branch can surface a useful
|
|
operator error instead of the bare "no providers registered".
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import secrets
|
|
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,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Defaults / constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# OIDC core scopes. ``openid`` is mandatory (without it the IDP won't issue an
|
|
# ID token); ``profile``/``email`` populate the Session's display_name/email.
|
|
_DEFAULT_SCOPES = "openid profile email"
|
|
|
|
# Signing algorithms we accept on the ID token. RS256 is the OIDC default;
|
|
# ES256 is common on modern self-hosted IDPs (Zitadel, newer Keycloak realms).
|
|
# HS256 is deliberately excluded — it implies a shared secret we don't have in
|
|
# the public-client model and is a well-known JWT confusion footgun.
|
|
_ALLOWED_ID_TOKEN_ALGS = ("RS256", "ES256", "RS384", "RS512", "ES384", "ES512")
|
|
|
|
# httpx timeouts.
|
|
_DISCOVERY_TIMEOUT_SEC = 10.0
|
|
_TOKEN_ENDPOINT_TIMEOUT_SEC = 10.0
|
|
|
|
# OIDC discovery is low-frequency and the document is effectively static;
|
|
# cache it for the process lifetime with a soft TTL so a long-running
|
|
# dashboard picks up an IDP endpoint migration within the hour.
|
|
_DISCOVERY_CACHE_TTL_SEC = 3600
|
|
|
|
# JWKS cache (PyJWKClient handles its own caching; this mirrors the nous
|
|
# provider's 5-minute lifespan so key rotation is picked up promptly).
|
|
_JWKS_CACHE_SECONDS = 300
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Skip-reason channel (mirrors the nous plugin)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
LAST_SKIP_REASON: str = ""
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _b64url_no_pad(raw: bytes) -> str:
|
|
"""Base64url-encode without ``=`` padding (RFC 7636 §4)."""
|
|
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
|
|
|
|
def _require_https_or_loopback(url: str, *, field: str) -> str:
|
|
"""Reject an endpoint URL that isn't HTTPS (loopback http is allowed).
|
|
|
|
OAuth credentials (codes, tokens) flow over these URLs. We require HTTPS
|
|
for everything except an explicit loopback host so a misconfigured issuer
|
|
can't ship the authorization code / refresh token in cleartext. Returns
|
|
the URL unchanged on success; raises :class:`ProviderError` otherwise.
|
|
"""
|
|
parsed = urllib.parse.urlparse(url)
|
|
if parsed.scheme == "https":
|
|
return url
|
|
if 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}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Provider
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
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")
|
|
# ``issuer`` is the OIDC issuer identifier. Normalise the trailing
|
|
# slash for stable string compares (the ``iss`` claim must match the
|
|
# issuer the IDP advertises in discovery — we pin against the
|
|
# discovered value, not this normalised one, to be tolerant of a
|
|
# trailing-slash mismatch between config and the IDP).
|
|
self._issuer = issuer.rstrip("/")
|
|
_require_https_or_loopback(self._issuer, field="issuer")
|
|
self._client_id = client_id
|
|
self._scopes = scopes.strip() or _DEFAULT_SCOPES
|
|
# An empty/whitespace secret means "public client" — strip so a
|
|
# provisioned-but-blank secret can't flip us into a broken confidential
|
|
# mode that sends an empty client_secret. Non-empty ⇒ confidential.
|
|
self._client_secret = (client_secret or "").strip()
|
|
|
|
# Discovery + JWKS are lazily resolved on first use so plugin
|
|
# registration never makes a network call (the IDP may be down at
|
|
# boot; the gate should still come up and fail per-request).
|
|
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:
|
|
self._validate_redirect_uri(redirect_uri)
|
|
disco = self._get_discovery()
|
|
|
|
code_verifier = _b64url_no_pad(secrets.token_bytes(64)) # ~86 chars
|
|
code_challenge = _b64url_no_pad(
|
|
hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
)
|
|
state = _b64url_no_pad(secrets.token_bytes(32))
|
|
|
|
params = {
|
|
"response_type": "code",
|
|
"client_id": self._client_id,
|
|
"redirect_uri": redirect_uri,
|
|
"scope": self._scopes,
|
|
"state": state,
|
|
"code_challenge": code_challenge,
|
|
"code_challenge_method": "S256",
|
|
}
|
|
redirect_url = (
|
|
f"{disco['authorization_endpoint']}?{urllib.parse.urlencode(params)}"
|
|
)
|
|
# Same flat ``state=…;verifier=…`` cookie shape every provider uses;
|
|
# the auth-route layer prepends ``provider=`` and parses it back out.
|
|
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,
|
|
) -> Session:
|
|
# ``state`` is verified by the auth-route layer before this call.
|
|
_ = state
|
|
disco = self._get_discovery()
|
|
|
|
data = {
|
|
"grant_type": "authorization_code",
|
|
"code": code,
|
|
"redirect_uri": redirect_uri,
|
|
"client_id": self._client_id,
|
|
"code_verifier": code_verifier,
|
|
}
|
|
# Confidential clients additionally authenticate the client here (basic
|
|
# header or post body, per the IDP's advertised methods); public
|
|
# clients get ({}, {}) and authenticate with PKCE alone.
|
|
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
|
data.update(extra_data)
|
|
return self._exchange(
|
|
disco["token_endpoint"],
|
|
data,
|
|
bad_request_exc=InvalidCodeError,
|
|
extra_headers=extra_headers,
|
|
)
|
|
|
|
def refresh_session(self, *, refresh_token: str) -> Session:
|
|
if not refresh_token:
|
|
raise RefreshExpiredError("no refresh token present in session")
|
|
disco = self._get_discovery()
|
|
|
|
data = {
|
|
"grant_type": "refresh_token",
|
|
"client_id": self._client_id,
|
|
"refresh_token": refresh_token,
|
|
# Re-request the same scopes so the rotated ID token keeps the
|
|
# identity claims (some IDPs narrow scope on refresh otherwise).
|
|
"scope": self._scopes,
|
|
}
|
|
# Same client-authentication treatment as complete_login: confidential
|
|
# clients must authenticate on the refresh grant too, or the IDP
|
|
# rejects the rotation with invalid_client.
|
|
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
|
data.update(extra_data)
|
|
return self._exchange(
|
|
disco["token_endpoint"],
|
|
data,
|
|
bad_request_exc=RefreshExpiredError,
|
|
previous_refresh_token=refresh_token,
|
|
extra_headers=extra_headers,
|
|
)
|
|
|
|
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
|
# The session cookie stores the ID token in the access-token slot (see
|
|
# ``_session_from_tokens``) precisely so this per-request check can
|
|
# verify a real JWT. Returns None on expiry/invalidity (middleware
|
|
# then refreshes or logs out); raises ProviderError if the IDP/JWKS is
|
|
# unreachable.
|
|
try:
|
|
claims = self._verify_id_token(access_token)
|
|
except InvalidCodeError:
|
|
# Expired / invalid token — protocol says return None, not raise.
|
|
return None
|
|
except ProviderError:
|
|
raise
|
|
# No refresh token available on this path; "" is fine — the middleware
|
|
# re-reads the refresh-token cookie separately for refresh_session.
|
|
return self._session_from_tokens(
|
|
id_token=access_token, refresh_token="", claims=claims
|
|
)
|
|
|
|
def revoke_session(self, *, refresh_token: str) -> None:
|
|
# Best-effort RFC 7009 revocation if the IDP advertised 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
|
|
data = {
|
|
"token": refresh_token,
|
|
"token_type_hint": "refresh_token",
|
|
"client_id": self._client_id,
|
|
}
|
|
headers = {"Accept": "application/json"}
|
|
# A confidential client must authenticate on revocation too (RFC 7009
|
|
# §2.1), or the IDP rejects it with invalid_client. Reuse the same
|
|
# method selection as the token endpoint; public clients add nothing.
|
|
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
|
data.update(extra_data)
|
|
headers.update(extra_headers)
|
|
try:
|
|
httpx.post(
|
|
endpoint,
|
|
data=data,
|
|
headers=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 (no ``client_secret`` configured): returns ``({}, {})`` —
|
|
the exchange authenticates with PKCE alone, exactly as before this
|
|
method existed.
|
|
|
|
Confidential client (``client_secret`` set): authenticates the client
|
|
per RFC 6749 §2.3.1, choosing the method from the IDP's advertised
|
|
``token_endpoint_auth_methods_supported``:
|
|
|
|
* ``client_secret_post`` advertised (and ``client_secret_basic`` not)
|
|
→ secret in the form body.
|
|
* otherwise → HTTP Basic ``Authorization`` header (the OIDC default;
|
|
also the fallback when the IDP advertises nothing).
|
|
|
|
PKCE's ``code_verifier`` is sent regardless by the callers — the secret
|
|
is layered on top, never a replacement.
|
|
"""
|
|
if not self._client_secret:
|
|
return {}, {}
|
|
|
|
methods = disco.get("token_endpoint_auth_methods_supported") or []
|
|
prefer_post = (
|
|
"client_secret_post" in methods
|
|
and "client_secret_basic" not in methods
|
|
)
|
|
if prefer_post:
|
|
# Secret travels in the application/x-www-form-urlencoded body.
|
|
return {"client_secret": self._client_secret}, {}
|
|
|
|
# HTTP Basic: base64(urlencode(client_id) ":" urlencode(secret)).
|
|
# Both halves must be form-url-encoded *before* base64 per RFC 6749
|
|
# §2.3.1, or a secret containing ':' / reserved chars corrupts the
|
|
# header.
|
|
userpass = (
|
|
f"{urllib.parse.quote(self._client_id, safe='')}:"
|
|
f"{urllib.parse.quote(self._client_secret, safe='')}"
|
|
)
|
|
encoded = base64.b64encode(userpass.encode("utf-8")).decode("ascii")
|
|
return {}, {"Authorization": f"Basic {encoded}"}
|
|
|
|
def _exchange(
|
|
self,
|
|
token_endpoint: str,
|
|
data: Dict[str, str],
|
|
*,
|
|
bad_request_exc: type[Exception],
|
|
previous_refresh_token: str = "",
|
|
extra_headers: Optional[Dict[str, str]] = None,
|
|
) -> Session:
|
|
"""POST the token endpoint and turn the response into a Session.
|
|
|
|
Shared by ``complete_login`` (auth-code grant) and ``refresh_session``
|
|
(refresh grant). ``bad_request_exc`` is raised on a 400 —
|
|
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
|
for the refresh path — preserving the middleware's distinct handling.
|
|
|
|
``extra_headers`` carries the confidential-client ``Authorization``
|
|
header when one applies (see ``_token_endpoint_auth``); it is empty for
|
|
a public client, so the request is byte-identical to the pre-
|
|
confidential-client behaviour in that case.
|
|
"""
|
|
headers = {"Accept": "application/json"}
|
|
if extra_headers:
|
|
headers.update(extra_headers)
|
|
try:
|
|
response = httpx.post(
|
|
token_endpoint,
|
|
data=data,
|
|
headers=headers,
|
|
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
|
)
|
|
except httpx.RequestError as exc:
|
|
raise ProviderError(
|
|
f"OIDC token endpoint unreachable: {exc}"
|
|
) from exc
|
|
|
|
if response.status_code == 400:
|
|
body = self._parse_json_body(response)
|
|
error_code = body.get("error", "invalid_request")
|
|
raise bad_request_exc(
|
|
f"IDP rejected token request: {error_code}"
|
|
)
|
|
if response.status_code != 200:
|
|
raise ProviderError(
|
|
f"OIDC token endpoint returned {response.status_code}: "
|
|
f"{response.text[:200]!r}"
|
|
)
|
|
|
|
payload = self._parse_json_body(response)
|
|
|
|
id_token = payload.get("id_token")
|
|
if not id_token or not isinstance(id_token, str):
|
|
raise ProviderError(
|
|
"OIDC token response missing id_token — ensure the 'openid' "
|
|
"scope is configured and the client is allowed to receive an "
|
|
"ID 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_id_token(id_token)
|
|
|
|
# Refresh-token rotation: prefer a freshly-issued one, else keep the
|
|
# previous (some IDPs don't rotate). Empty string if neither — the
|
|
# session then behaves as ID-token-only until expiry.
|
|
refresh_token = payload.get("refresh_token")
|
|
if not isinstance(refresh_token, str) or not refresh_token:
|
|
refresh_token = previous_refresh_token or ""
|
|
|
|
return self._session_from_tokens(
|
|
id_token=id_token, refresh_token=refresh_token, claims=claims
|
|
)
|
|
|
|
# ---- internals: discovery ---------------------------------------------
|
|
|
|
def _get_discovery(self) -> Dict[str, Any]:
|
|
"""Return the cached OIDC discovery document, fetching if stale."""
|
|
now = time.time()
|
|
if (
|
|
self._discovery is not None
|
|
and (now - self._discovery_fetched_at) < _DISCOVERY_CACHE_TTL_SEC
|
|
):
|
|
return self._discovery
|
|
with self._discovery_lock:
|
|
now = time.time()
|
|
if (
|
|
self._discovery is not None
|
|
and (now - self._discovery_fetched_at) < _DISCOVERY_CACHE_TTL_SEC
|
|
):
|
|
return self._discovery
|
|
disco = self._fetch_discovery()
|
|
self._discovery = disco
|
|
self._discovery_fetched_at = now
|
|
# New issuer/keys → drop the JWKS client so it re-binds to the
|
|
# freshly-discovered jwks_uri.
|
|
self._jwks_client = None
|
|
return disco
|
|
|
|
def _discovery_url(self) -> str:
|
|
# RFC 8414 / OIDC Discovery: ``{issuer}/.well-known/openid-configuration``.
|
|
return f"{self._issuer}/.well-known/openid-configuration"
|
|
|
|
def _fetch_discovery(self) -> Dict[str, Any]:
|
|
url = self._discovery_url()
|
|
try:
|
|
# follow_redirects=True: many IDPs answer the discovery GET with a
|
|
# 3xx rather than a direct 200 — Authentik canonicalises the
|
|
# ``.well-known`` path, and any IDP behind a reverse proxy doing an
|
|
# http→https upgrade redirects too. httpx (unlike curl -L or the
|
|
# requests library) defaults to follow_redirects=False, so without
|
|
# this the redirect comes back as a bare 3xx with an empty body and
|
|
# the ``status != 200`` check below raises "discovery returned 302"
|
|
# → provider_unreachable → 503. Following the redirect is safe: the
|
|
# issuer-pin check and _require_https_or_loopback below still
|
|
# validate the *resolved* document and every endpoint in it, 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 — see _exchange — because they carry an auth
|
|
# code / refresh token and the endpoint is already the canonical
|
|
# absolute URL resolved here.)
|
|
response = httpx.get(
|
|
url,
|
|
headers={"Accept": "application/json"},
|
|
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 = self._parse_json_body(response)
|
|
if not payload:
|
|
raise ProviderError("OIDC discovery returned a non-JSON body")
|
|
|
|
authorization_endpoint = str(
|
|
payload.get("authorization_endpoint", "") or ""
|
|
).strip()
|
|
token_endpoint = str(payload.get("token_endpoint", "") or "").strip()
|
|
jwks_uri = str(payload.get("jwks_uri", "") or "").strip()
|
|
if not authorization_endpoint or not token_endpoint or not jwks_uri:
|
|
raise ProviderError(
|
|
"OIDC discovery missing one of authorization_endpoint / "
|
|
"token_endpoint / jwks_uri"
|
|
)
|
|
|
|
# Pin the discovered issuer: a mismatch between the configured issuer
|
|
# and the ``issuer`` the IDP advertises means the discovery document
|
|
# was served from the wrong place (proxy/MITM/misconfig). We tolerate
|
|
# only a trailing-slash difference.
|
|
advertised_issuer = str(payload.get("issuer", "") or "").strip()
|
|
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}"
|
|
)
|
|
|
|
_require_https_or_loopback(
|
|
authorization_endpoint, field="authorization_endpoint"
|
|
)
|
|
_require_https_or_loopback(token_endpoint, field="token_endpoint")
|
|
_require_https_or_loopback(jwks_uri, field="jwks_uri")
|
|
|
|
revocation_endpoint = str(
|
|
payload.get("revocation_endpoint", "") or ""
|
|
).strip()
|
|
|
|
# Client-authentication methods the IDP advertises for the token
|
|
# endpoint. Used to pick client_secret_basic vs client_secret_post for
|
|
# a confidential client (see ``_token_endpoint_auth``). Absent/garbage
|
|
# → empty list → we fall back to the OIDC default (basic).
|
|
auth_methods_raw = payload.get("token_endpoint_auth_methods_supported")
|
|
token_endpoint_auth_methods = (
|
|
[str(m) for m in auth_methods_raw]
|
|
if isinstance(auth_methods_raw, list)
|
|
else []
|
|
)
|
|
|
|
return {
|
|
"issuer": advertised_issuer or self._issuer,
|
|
"authorization_endpoint": authorization_endpoint,
|
|
"token_endpoint": token_endpoint,
|
|
"jwks_uri": jwks_uri,
|
|
"revocation_endpoint": revocation_endpoint,
|
|
"token_endpoint_auth_methods_supported": token_endpoint_auth_methods,
|
|
}
|
|
|
|
# ---- internals: JWT verification --------------------------------------
|
|
|
|
def _get_jwks_client(self) -> Any:
|
|
if self._jwks_client is None:
|
|
from jwt import PyJWKClient # lazy import
|
|
|
|
disco = self._get_discovery()
|
|
self._jwks_client = PyJWKClient(
|
|
disco["jwks_uri"],
|
|
cache_keys=True,
|
|
lifespan=_JWKS_CACHE_SECONDS,
|
|
)
|
|
return self._jwks_client
|
|
|
|
def _verify_id_token(self, id_token: str) -> Dict[str, Any]:
|
|
import jwt # lazy import — keeps startup fast for the ungated path
|
|
|
|
disco = self._get_discovery()
|
|
|
|
try:
|
|
signing_key = self._get_jwks_client().get_signing_key_from_jwt(
|
|
id_token
|
|
)
|
|
except jwt.PyJWKClientError as exc:
|
|
raise ProviderError(f"JWKS lookup failed: {exc}") from exc
|
|
except Exception as exc: # pragma: no cover - defensive
|
|
raise ProviderError(f"JWKS lookup failed: {exc!r}") from exc
|
|
|
|
try:
|
|
claims = jwt.decode(
|
|
id_token,
|
|
signing_key.key,
|
|
algorithms=list(_ALLOWED_ID_TOKEN_ALGS),
|
|
audience=self._client_id,
|
|
issuer=disco["issuer"],
|
|
options={"require": ["exp", "iat", "aud", "iss", "sub"]},
|
|
)
|
|
except jwt.ExpiredSignatureError as exc:
|
|
# verify_session() catches this and returns None per protocol.
|
|
raise InvalidCodeError(f"ID token expired: {exc}") from exc
|
|
except jwt.InvalidTokenError as exc:
|
|
# Surface the actual iss/aud the token carried so operators can
|
|
# debug config drift between the configured issuer/client_id and
|
|
# what the IDP emits. Decoding-without-verification is safe here:
|
|
# we already failed verification and never trust these values.
|
|
details = ""
|
|
try:
|
|
unverified = jwt.decode(
|
|
id_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={disco['issuer']!r} "
|
|
f"aud={self._client_id!r}]"
|
|
)
|
|
except Exception:
|
|
pass
|
|
raise ProviderError(
|
|
f"ID token verification failed: {exc}{details}"
|
|
) from exc
|
|
|
|
return claims
|
|
|
|
# ---- internals: mapping + misc ----------------------------------------
|
|
|
|
def _session_from_tokens(
|
|
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 intentionally NOT stored — Hermes does not call
|
|
any resource API with it; the dashboard only needs identity.
|
|
"""
|
|
user_id = str(claims.get("sub", ""))
|
|
if not user_id:
|
|
raise ProviderError("ID token missing 'sub' (user_id) claim")
|
|
|
|
email = str(claims.get("email", "") or "")
|
|
# Standard OIDC display claims, in preference order.
|
|
display_name = str(
|
|
claims.get("name")
|
|
or claims.get("preferred_username")
|
|
or claims.get("nickname")
|
|
or email
|
|
or ""
|
|
)
|
|
# Org/tenant is non-standard; accept the common spellings. Groups, if
|
|
# present as a list, are joined so multi-tenant IDPs surface *something*
|
|
# rather than dropping the info — org_id is a free-form string.
|
|
org_id = claims.get("org_id") or claims.get("organization") or ""
|
|
if not org_id:
|
|
groups = claims.get("groups")
|
|
if isinstance(groups, list) and groups:
|
|
org_id = ",".join(str(g) for g in groups)
|
|
org_id = str(org_id or "")
|
|
|
|
return Session(
|
|
user_id=user_id,
|
|
email=email,
|
|
display_name=display_name,
|
|
org_id=org_id,
|
|
provider=self.name,
|
|
expires_at=int(claims["exp"]),
|
|
access_token=id_token,
|
|
refresh_token=refresh_token,
|
|
)
|
|
|
|
def _validate_redirect_uri(self, redirect_uri: str) -> None:
|
|
"""Fast-fail obviously-broken redirect_uris before bouncing to the IDP.
|
|
|
|
The IDP's own allowlist is authoritative; this just catches the common
|
|
operator-error case with a clear message. 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; the IDP makes the final call on which
|
|
redirect_uris are permitted. Mirrors the nous provider.
|
|
"""
|
|
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 {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Plugin entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_config_oauth_section() -> dict:
|
|
"""Return the ``dashboard.oauth`` block from config.yaml, or ``{}``.
|
|
|
|
Robust to load_config() raising, the ``dashboard`` key being absent or
|
|
non-dict, and ``oauth`` being present but not a dict — each falls through
|
|
to ``{}`` so callers can rely on ``.get(...)``.
|
|
"""
|
|
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-self-hosted: 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 _oidc_subsection(oauth_section: dict) -> dict:
|
|
"""Return the ``dashboard.oauth.self_hosted`` sub-block, or ``{}``."""
|
|
sub = oauth_section.get("self_hosted")
|
|
return sub if isinstance(sub, dict) else {}
|
|
|
|
|
|
def _resolve_setting(env_var: str, cfg_value: Any) -> str:
|
|
"""env-wins-config with empty-is-unset precedence.
|
|
|
|
1. ``env_var`` when non-empty after strip (an empty provisioned secret
|
|
must not shadow a valid config.yaml entry).
|
|
2. ``cfg_value`` from config.yaml.
|
|
3. Empty string.
|
|
"""
|
|
env = os.environ.get(env_var, "").strip()
|
|
if env:
|
|
return env
|
|
return str(cfg_value or "").strip()
|
|
|
|
|
|
def register(ctx) -> None:
|
|
"""Plugin entry — called by the plugin loader at startup.
|
|
|
|
Registers :class:`SelfHostedOIDCProvider` only when both an issuer and a
|
|
client_id are configured (via ``HERMES_DASHBOARD_OIDC_*`` env vars or the
|
|
``dashboard.oauth.self_hosted`` block in config.yaml). Operator-owned
|
|
loopback / ``--insecure`` dashboards leave these unset, so the plugin is a
|
|
no-op for them.
|
|
|
|
On skip, writes a reason to :data:`LAST_SKIP_REASON` that names BOTH
|
|
configuration surfaces so operators don't guess wrong about which to set.
|
|
"""
|
|
global LAST_SKIP_REASON
|
|
LAST_SKIP_REASON = ""
|
|
|
|
oauth_section = _load_config_oauth_section()
|
|
oidc_cfg = _oidc_subsection(oauth_section)
|
|
|
|
issuer = _resolve_setting(
|
|
"HERMES_DASHBOARD_OIDC_ISSUER", oidc_cfg.get("issuer")
|
|
)
|
|
client_id = _resolve_setting(
|
|
"HERMES_DASHBOARD_OIDC_CLIENT_ID", oidc_cfg.get("client_id")
|
|
)
|
|
scopes = (
|
|
_resolve_setting("HERMES_DASHBOARD_OIDC_SCOPES", oidc_cfg.get("scopes"))
|
|
or _DEFAULT_SCOPES
|
|
)
|
|
# Optional — set only for a confidential client. A credential, so the
|
|
# canonical home is the env var / ~/.hermes/.env; config.yaml is supported
|
|
# for precedence symmetry. Empty ⇒ public client (unchanged behaviour).
|
|
client_secret = _resolve_setting(
|
|
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET", oidc_cfg.get("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,
|
|
# Log only whether a secret is present, never the secret itself.
|
|
bool(client_secret),
|
|
)
|