feat(dashboard_auth): support confidential clients (client_secret) in self-hosted OIDC (#55344)

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.
This commit is contained in:
Ben Barclay 2026-06-30 13:32:51 +10:00 committed by GitHub
parent 481caa66f2
commit 53a75f147f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 446 additions and 22 deletions

View file

@ -32,11 +32,17 @@ 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.)
Public PKCE clients only. Confidential clients (with a ``client_secret``) are
not yet supported see the ``# TODO(confidential-client)`` seam in
``complete_login`` / ``refresh_session``. Self-hosters configuring a CLI/SPA
client almost always register a public + PKCE client, which is the smaller,
simpler surface.
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 —
@ -50,11 +56,16 @@ same precedence convention as the ``nous`` plugin)::
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
@ -172,6 +183,7 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
issuer: str,
client_id: str,
scopes: str = _DEFAULT_SCOPES,
client_secret: str = "",
) -> None:
if not issuer:
raise ValueError("issuer is required")
@ -186,6 +198,10 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
_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
@ -245,11 +261,16 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
"client_id": self._client_id,
"code_verifier": code_verifier,
}
# TODO(confidential-client): when client_secret support lands, add it
# here (and switch to HTTP Basic auth if the IDP's
# token_endpoint_auth_methods_supported prefers client_secret_basic).
# 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
disco["token_endpoint"],
data,
bad_request_exc=InvalidCodeError,
extra_headers=extra_headers,
)
def refresh_session(self, *, refresh_token: str) -> Session:
@ -265,12 +286,17 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
# identity claims (some IDPs narrow scope on refresh otherwise).
"scope": self._scopes,
}
# TODO(confidential-client): add client_secret here when supported.
# 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]:
@ -304,15 +330,23 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
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={
"token": refresh_token,
"token_type_hint": "refresh_token",
"client_id": self._client_id,
},
headers={"Accept": "application/json"},
data=data,
headers=headers,
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except Exception as exc: # noqa: BLE001 — best-effort
@ -321,6 +355,50 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
# ---- internals: token exchange ----------------------------------------
def _token_endpoint_auth(
self, disco: Dict[str, Any]
) -> tuple[Dict[str, str], Dict[str, str]]:
"""Return ``(extra_data, extra_headers)`` for token-endpoint client auth.
Public client (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,
@ -328,6 +406,7 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
*,
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.
@ -335,12 +414,20 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
(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={"Accept": "application/json"},
headers=headers,
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
)
except httpx.RequestError as exc:
@ -483,12 +570,24 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
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 --------------------------------------
@ -713,6 +812,12 @@ def register(ctx) -> None:
_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 = (
@ -729,7 +834,10 @@ def register(ctx) -> None:
try:
provider = SelfHostedOIDCProvider(
issuer=issuer, client_id=client_id, scopes=scopes
issuer=issuer,
client_id=client_id,
scopes=scopes,
client_secret=client_secret,
)
except (ValueError, ProviderError) as exc:
LAST_SKIP_REASON = (
@ -741,8 +849,10 @@ def register(ctx) -> None:
ctx.register_dashboard_auth_provider(provider)
logger.info(
"dashboard-auth-self-hosted: registered provider "
"(issuer=%s, client_id=%s, scopes=%r)",
"(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),
)