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.fix/verification-admin-route-recovery
parent
481caa66f2
commit
53a75f147f
|
|
@ -1276,3 +1276,33 @@ updates:
|
||||||
# # default — works on Fly.io out of the box).
|
# # default — works on Fly.io out of the box).
|
||||||
# #
|
# #
|
||||||
# # public_url: "https://example.com/hermes"
|
# # public_url: "https://example.com/hermes"
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik,
|
||||||
|
# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the
|
||||||
|
# nous block above when gating the dashboard with your own identity provider.
|
||||||
|
# Each setting can be overridden by an environment variable:
|
||||||
|
#
|
||||||
|
# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER
|
||||||
|
# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||||
|
# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES
|
||||||
|
# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
#
|
||||||
|
# # OPTIONAL — set ONLY if your IDP registered the client as
|
||||||
|
# # *confidential* (Authentik / Keycloak often default to this). When
|
||||||
|
# # set, Hermes authenticates the client at the token endpoint
|
||||||
|
# # (client_secret_basic or client_secret_post, auto-selected from the
|
||||||
|
# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public
|
||||||
|
# # (PKCE-only) client — the common case.
|
||||||
|
# #
|
||||||
|
# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET
|
||||||
|
# # in ~/.hermes/.env over putting it here in config.yaml.
|
||||||
|
# # client_secret: ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
provider verifies its *access* token because Nous Portal mints a custom JWT
|
||||||
access token with the dashboard claims baked in — a non-OIDC shortcut.)
|
access token with the dashboard claims baked in — a non-OIDC shortcut.)
|
||||||
|
|
||||||
Public PKCE clients only. Confidential clients (with a ``client_secret``) are
|
Both **public** (PKCE-only) and **confidential** (PKCE + ``client_secret``)
|
||||||
not yet supported — see the ``# TODO(confidential-client)`` seam in
|
clients are supported. A self-hoster who registers a public client configures
|
||||||
``complete_login`` / ``refresh_session``. Self-hosters configuring a CLI/SPA
|
no secret and the token-endpoint calls authenticate with PKCE alone (the
|
||||||
client almost always register a public + PKCE client, which is the smaller,
|
default). A self-hoster whose IDP defaults the client to *confidential*
|
||||||
simpler surface.
|
(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
|
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 —
|
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
|
issuer: https://auth.example.com/application/o/hermes/ # required
|
||||||
client_id: hermes-dashboard # required
|
client_id: hermes-dashboard # required
|
||||||
scopes: "openid profile email" # optional
|
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)
|
# Environment overrides (Docker/Fly secret injection)
|
||||||
HERMES_DASHBOARD_OIDC_ISSUER
|
HERMES_DASHBOARD_OIDC_ISSUER
|
||||||
HERMES_DASHBOARD_OIDC_CLIENT_ID
|
HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||||
HERMES_DASHBOARD_OIDC_SCOPES # optional; defaults to "openid profile email"
|
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 /
|
Skip reasons: when the plugin loads but can't register (missing issuer /
|
||||||
client_id), it writes a human-readable reason to the module-level
|
client_id), it writes a human-readable reason to the module-level
|
||||||
|
|
@ -172,6 +183,7 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||||
issuer: str,
|
issuer: str,
|
||||||
client_id: str,
|
client_id: str,
|
||||||
scopes: str = _DEFAULT_SCOPES,
|
scopes: str = _DEFAULT_SCOPES,
|
||||||
|
client_secret: str = "",
|
||||||
) -> None:
|
) -> None:
|
||||||
if not issuer:
|
if not issuer:
|
||||||
raise ValueError("issuer is required")
|
raise ValueError("issuer is required")
|
||||||
|
|
@ -186,6 +198,10 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||||
_require_https_or_loopback(self._issuer, field="issuer")
|
_require_https_or_loopback(self._issuer, field="issuer")
|
||||||
self._client_id = client_id
|
self._client_id = client_id
|
||||||
self._scopes = scopes.strip() or _DEFAULT_SCOPES
|
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
|
# Discovery + JWKS are lazily resolved on first use so plugin
|
||||||
# registration never makes a network call (the IDP may be down at
|
# registration never makes a network call (the IDP may be down at
|
||||||
|
|
@ -245,11 +261,16 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||||
"client_id": self._client_id,
|
"client_id": self._client_id,
|
||||||
"code_verifier": code_verifier,
|
"code_verifier": code_verifier,
|
||||||
}
|
}
|
||||||
# TODO(confidential-client): when client_secret support lands, add it
|
# Confidential clients additionally authenticate the client here (basic
|
||||||
# here (and switch to HTTP Basic auth if the IDP's
|
# header or post body, per the IDP's advertised methods); public
|
||||||
# token_endpoint_auth_methods_supported prefers client_secret_basic).
|
# clients get ({}, {}) and authenticate with PKCE alone.
|
||||||
|
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
||||||
|
data.update(extra_data)
|
||||||
return self._exchange(
|
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:
|
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||||
|
|
@ -265,12 +286,17 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||||
# identity claims (some IDPs narrow scope on refresh otherwise).
|
# identity claims (some IDPs narrow scope on refresh otherwise).
|
||||||
"scope": self._scopes,
|
"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(
|
return self._exchange(
|
||||||
disco["token_endpoint"],
|
disco["token_endpoint"],
|
||||||
data,
|
data,
|
||||||
bad_request_exc=RefreshExpiredError,
|
bad_request_exc=RefreshExpiredError,
|
||||||
previous_refresh_token=refresh_token,
|
previous_refresh_token=refresh_token,
|
||||||
|
extra_headers=extra_headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||||
|
|
@ -304,15 +330,23 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||||
endpoint = str(disco.get("revocation_endpoint") or "").strip()
|
endpoint = str(disco.get("revocation_endpoint") or "").strip()
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
return None
|
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:
|
try:
|
||||||
httpx.post(
|
httpx.post(
|
||||||
endpoint,
|
endpoint,
|
||||||
data={
|
data=data,
|
||||||
"token": refresh_token,
|
headers=headers,
|
||||||
"token_type_hint": "refresh_token",
|
|
||||||
"client_id": self._client_id,
|
|
||||||
},
|
|
||||||
headers={"Accept": "application/json"},
|
|
||||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — best-effort
|
except Exception as exc: # noqa: BLE001 — best-effort
|
||||||
|
|
@ -321,6 +355,50 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||||
|
|
||||||
# ---- internals: token exchange ----------------------------------------
|
# ---- 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(
|
def _exchange(
|
||||||
self,
|
self,
|
||||||
token_endpoint: str,
|
token_endpoint: str,
|
||||||
|
|
@ -328,6 +406,7 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||||
*,
|
*,
|
||||||
bad_request_exc: type[Exception],
|
bad_request_exc: type[Exception],
|
||||||
previous_refresh_token: str = "",
|
previous_refresh_token: str = "",
|
||||||
|
extra_headers: Optional[Dict[str, str]] = None,
|
||||||
) -> Session:
|
) -> Session:
|
||||||
"""POST the token endpoint and turn the response into a 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 —
|
(refresh grant). ``bad_request_exc`` is raised on a 400 —
|
||||||
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
||||||
for the refresh path — preserving the middleware's distinct handling.
|
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:
|
try:
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data=data,
|
data=data,
|
||||||
headers={"Accept": "application/json"},
|
headers=headers,
|
||||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||||
)
|
)
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
|
|
@ -483,12 +570,24 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||||
payload.get("revocation_endpoint", "") or ""
|
payload.get("revocation_endpoint", "") or ""
|
||||||
).strip()
|
).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 {
|
return {
|
||||||
"issuer": advertised_issuer or self._issuer,
|
"issuer": advertised_issuer or self._issuer,
|
||||||
"authorization_endpoint": authorization_endpoint,
|
"authorization_endpoint": authorization_endpoint,
|
||||||
"token_endpoint": token_endpoint,
|
"token_endpoint": token_endpoint,
|
||||||
"jwks_uri": jwks_uri,
|
"jwks_uri": jwks_uri,
|
||||||
"revocation_endpoint": revocation_endpoint,
|
"revocation_endpoint": revocation_endpoint,
|
||||||
|
"token_endpoint_auth_methods_supported": token_endpoint_auth_methods,
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---- internals: JWT verification --------------------------------------
|
# ---- internals: JWT verification --------------------------------------
|
||||||
|
|
@ -713,6 +812,12 @@ def register(ctx) -> None:
|
||||||
_resolve_setting("HERMES_DASHBOARD_OIDC_SCOPES", oidc_cfg.get("scopes"))
|
_resolve_setting("HERMES_DASHBOARD_OIDC_SCOPES", oidc_cfg.get("scopes"))
|
||||||
or _DEFAULT_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:
|
if not issuer or not client_id:
|
||||||
LAST_SKIP_REASON = (
|
LAST_SKIP_REASON = (
|
||||||
|
|
@ -729,7 +834,10 @@ def register(ctx) -> None:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provider = SelfHostedOIDCProvider(
|
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:
|
except (ValueError, ProviderError) as exc:
|
||||||
LAST_SKIP_REASON = (
|
LAST_SKIP_REASON = (
|
||||||
|
|
@ -741,8 +849,10 @@ def register(ctx) -> None:
|
||||||
ctx.register_dashboard_auth_provider(provider)
|
ctx.register_dashboard_auth_provider(provider)
|
||||||
logger.info(
|
logger.info(
|
||||||
"dashboard-auth-self-hosted: registered provider "
|
"dashboard-auth-self-hosted: registered provider "
|
||||||
"(issuer=%s, client_id=%s, scopes=%r)",
|
"(issuer=%s, client_id=%s, scopes=%r, confidential=%s)",
|
||||||
issuer,
|
issuer,
|
||||||
client_id,
|
client_id,
|
||||||
scopes,
|
scopes,
|
||||||
|
# Log only whether a secret is present, never the secret itself.
|
||||||
|
bool(client_secret),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -127,14 +127,34 @@ def _mint_id_token(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(rsa_keypair, *, scopes: str | None = None):
|
def _make_provider(
|
||||||
"""Construct a provider with discovery + JWKS stubbed (no network)."""
|
rsa_keypair,
|
||||||
|
*,
|
||||||
|
scopes: str | None = None,
|
||||||
|
client_secret: str | None = None,
|
||||||
|
auth_methods: Any = "__unset__",
|
||||||
|
):
|
||||||
|
"""Construct a provider with discovery + JWKS stubbed (no network).
|
||||||
|
|
||||||
|
``client_secret`` flips the provider into confidential mode. ``auth_methods``
|
||||||
|
overrides ``token_endpoint_auth_methods_supported`` in the seeded discovery
|
||||||
|
doc (pass a list, or ``None`` to omit the key entirely); left unset, the
|
||||||
|
discovery doc carries no auth-methods key (the absent-key default).
|
||||||
|
"""
|
||||||
kwargs: Dict[str, Any] = {"issuer": _ISSUER, "client_id": _CLIENT_ID}
|
kwargs: Dict[str, Any] = {"issuer": _ISSUER, "client_id": _CLIENT_ID}
|
||||||
if scopes is not None:
|
if scopes is not None:
|
||||||
kwargs["scopes"] = scopes
|
kwargs["scopes"] = scopes
|
||||||
|
if client_secret is not None:
|
||||||
|
kwargs["client_secret"] = client_secret
|
||||||
p = oidc_plugin.SelfHostedOIDCProvider(**kwargs)
|
p = oidc_plugin.SelfHostedOIDCProvider(**kwargs)
|
||||||
# Pre-seed discovery so nothing hits the network.
|
# Pre-seed discovery so nothing hits the network.
|
||||||
p._discovery = dict(_DISCOVERY_DOC)
|
disco = dict(_DISCOVERY_DOC)
|
||||||
|
if auth_methods != "__unset__":
|
||||||
|
if auth_methods is None:
|
||||||
|
disco.pop("token_endpoint_auth_methods_supported", None)
|
||||||
|
else:
|
||||||
|
disco["token_endpoint_auth_methods_supported"] = auth_methods
|
||||||
|
p._discovery = disco
|
||||||
p._discovery_fetched_at = time.time()
|
p._discovery_fetched_at = time.time()
|
||||||
# Patch the JWKS client to return our fixture key.
|
# Patch the JWKS client to return our fixture key.
|
||||||
fake_key = MagicMock()
|
fake_key = MagicMock()
|
||||||
|
|
@ -655,6 +675,185 @@ class TestCompleteLogin:
|
||||||
assert kwargs["data"]["client_id"] == _CLIENT_ID
|
assert kwargs["data"]["client_id"] == _CLIENT_ID
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Confidential client (client_secret) — token-endpoint client authentication
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_GOOD_TOKEN_RESP_KEYS = {"token_type": "Bearer", "refresh_token": "rt_initial"}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_basic(header_value: str) -> tuple[str, str]:
|
||||||
|
"""Decode a ``Basic <b64>`` Authorization header back to (user, pass)."""
|
||||||
|
assert header_value.startswith("Basic ")
|
||||||
|
raw = base64.b64decode(header_value[len("Basic ") :]).decode("utf-8")
|
||||||
|
user, _, pw = raw.partition(":")
|
||||||
|
# client_id / secret are form-url-encoded before base64 (RFC 6749 §2.3.1).
|
||||||
|
return urllib.parse.unquote(user), urllib.parse.unquote(pw)
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfidentialClient:
|
||||||
|
"""A configured ``client_secret`` authenticates the client at the token
|
||||||
|
endpoint (basic header or post body, auto-selected from discovery), while
|
||||||
|
PKCE is still sent. A public client (no secret) is byte-identical to the
|
||||||
|
pre-confidential-client behaviour — no secret anywhere, no auth header."""
|
||||||
|
|
||||||
|
def _complete(self, provider, rsa_keypair):
|
||||||
|
id_token = _mint_id_token(rsa_keypair)
|
||||||
|
mock_resp = _mock_post(200, {"id_token": id_token, **_GOOD_TOKEN_RESP_KEYS})
|
||||||
|
with patch(
|
||||||
|
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||||
|
) as mock_post:
|
||||||
|
provider.complete_login(
|
||||||
|
code="the-code",
|
||||||
|
state="s",
|
||||||
|
code_verifier="the-verifier",
|
||||||
|
redirect_uri="https://hermes.example/auth/callback",
|
||||||
|
)
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
# -- public client: nothing changes ------------------------------------
|
||||||
|
|
||||||
|
def test_public_client_sends_no_secret_or_auth_header(self, rsa_keypair):
|
||||||
|
# No client_secret configured → no Authorization header, no
|
||||||
|
# client_secret in the body. Pins the unchanged public-client contract.
|
||||||
|
provider = _make_provider(rsa_keypair) # public
|
||||||
|
kwargs = self._complete(provider, rsa_keypair)
|
||||||
|
assert "Authorization" not in kwargs["headers"]
|
||||||
|
assert "client_secret" not in kwargs["data"]
|
||||||
|
# PKCE still present.
|
||||||
|
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||||
|
# Header is exactly the pre-feature value.
|
||||||
|
assert kwargs["headers"] == {"Accept": "application/json"}
|
||||||
|
|
||||||
|
# -- basic (default & explicit) ----------------------------------------
|
||||||
|
|
||||||
|
def test_confidential_defaults_to_basic_when_methods_absent(self, rsa_keypair):
|
||||||
|
# Discovery advertises no auth methods → OIDC default is Basic.
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair, client_secret="s3cr3t", auth_methods=None
|
||||||
|
)
|
||||||
|
kwargs = self._complete(provider, rsa_keypair)
|
||||||
|
assert "client_secret" not in kwargs["data"] # not in body for basic
|
||||||
|
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||||
|
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
|
||||||
|
# PKCE still sent alongside the secret.
|
||||||
|
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||||
|
|
||||||
|
def test_confidential_basic_when_explicitly_advertised(self, rsa_keypair):
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair,
|
||||||
|
client_secret="s3cr3t",
|
||||||
|
auth_methods=["client_secret_basic", "client_secret_post"],
|
||||||
|
)
|
||||||
|
kwargs = self._complete(provider, rsa_keypair)
|
||||||
|
# When both are advertised we prefer basic (secret stays out of body).
|
||||||
|
assert "client_secret" not in kwargs["data"]
|
||||||
|
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||||
|
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
|
||||||
|
|
||||||
|
# -- post --------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_confidential_post_when_only_post_advertised(self, rsa_keypair):
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair,
|
||||||
|
client_secret="s3cr3t",
|
||||||
|
auth_methods=["client_secret_post"],
|
||||||
|
)
|
||||||
|
kwargs = self._complete(provider, rsa_keypair)
|
||||||
|
assert kwargs["data"]["client_secret"] == "s3cr3t"
|
||||||
|
assert "Authorization" not in kwargs["headers"]
|
||||||
|
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||||
|
|
||||||
|
# -- url-encoding of reserved chars ------------------------------------
|
||||||
|
|
||||||
|
def test_basic_url_encodes_reserved_chars_in_secret(self, rsa_keypair):
|
||||||
|
# A secret with ':' / '@' / space must round-trip through the Basic
|
||||||
|
# header exactly — these are exactly the chars that corrupt a naive
|
||||||
|
# "id:secret" concatenation.
|
||||||
|
tricky = "p@ss:wo rd/+="
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair, client_secret=tricky, auth_methods=["client_secret_basic"]
|
||||||
|
)
|
||||||
|
kwargs = self._complete(provider, rsa_keypair)
|
||||||
|
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||||
|
assert user == _CLIENT_ID
|
||||||
|
assert pw == tricky
|
||||||
|
|
||||||
|
# -- blank secret is treated as public ---------------------------------
|
||||||
|
|
||||||
|
def test_whitespace_secret_is_public(self, rsa_keypair):
|
||||||
|
# A provisioned-but-blank secret must NOT flip us into confidential
|
||||||
|
# mode (which would send an empty secret and break the exchange).
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair, client_secret=" ", auth_methods=["client_secret_basic"]
|
||||||
|
)
|
||||||
|
kwargs = self._complete(provider, rsa_keypair)
|
||||||
|
assert "Authorization" not in kwargs["headers"]
|
||||||
|
assert "client_secret" not in kwargs["data"]
|
||||||
|
|
||||||
|
# -- refresh grant also authenticates ----------------------------------
|
||||||
|
|
||||||
|
def test_refresh_grant_authenticates_confidential_client(self, rsa_keypair):
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_post"]
|
||||||
|
)
|
||||||
|
id_token = _mint_id_token(rsa_keypair)
|
||||||
|
mock_resp = _mock_post(
|
||||||
|
200, {"id_token": id_token, "token_type": "Bearer", "refresh_token": "rt2"}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||||
|
) as mock_post:
|
||||||
|
provider.refresh_session(refresh_token="rt_old")
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
assert kwargs["data"]["grant_type"] == "refresh_token"
|
||||||
|
assert kwargs["data"]["client_secret"] == "s3cr3t"
|
||||||
|
|
||||||
|
def test_refresh_grant_basic_header_confidential_client(self, rsa_keypair):
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_basic"]
|
||||||
|
)
|
||||||
|
id_token = _mint_id_token(rsa_keypair)
|
||||||
|
mock_resp = _mock_post(
|
||||||
|
200, {"id_token": id_token, "token_type": "Bearer", "refresh_token": "rt2"}
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||||
|
) as mock_post:
|
||||||
|
provider.refresh_session(refresh_token="rt_old")
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||||
|
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
|
||||||
|
|
||||||
|
# -- revocation also authenticates -------------------------------------
|
||||||
|
|
||||||
|
def test_revoke_authenticates_confidential_client(self, rsa_keypair):
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_post"]
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"plugins.dashboard_auth.self_hosted.httpx.post"
|
||||||
|
) as mock_post:
|
||||||
|
provider.revoke_session(refresh_token="rt_old")
|
||||||
|
_, kwargs = mock_post.call_args
|
||||||
|
assert kwargs["data"]["client_secret"] == "s3cr3t"
|
||||||
|
|
||||||
|
# -- the secret never appears in logs ----------------------------------
|
||||||
|
|
||||||
|
def test_secret_not_in_repr_or_log(self, rsa_keypair, caplog):
|
||||||
|
import logging
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
provider = _make_provider(
|
||||||
|
rsa_keypair, client_secret="sup3r-s3cr3t", auth_methods=None
|
||||||
|
)
|
||||||
|
# The provider object's repr must not leak the secret.
|
||||||
|
assert "sup3r-s3cr3t" not in repr(provider)
|
||||||
|
assert "sup3r-s3cr3t" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# verify_session
|
# verify_session
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -853,6 +1052,7 @@ class TestPluginRegister:
|
||||||
"HERMES_DASHBOARD_OIDC_ISSUER",
|
"HERMES_DASHBOARD_OIDC_ISSUER",
|
||||||
"HERMES_DASHBOARD_OIDC_CLIENT_ID",
|
"HERMES_DASHBOARD_OIDC_CLIENT_ID",
|
||||||
"HERMES_DASHBOARD_OIDC_SCOPES",
|
"HERMES_DASHBOARD_OIDC_SCOPES",
|
||||||
|
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET",
|
||||||
):
|
):
|
||||||
monkeypatch.delenv(var, raising=False)
|
monkeypatch.delenv(var, raising=False)
|
||||||
|
|
||||||
|
|
@ -978,3 +1178,87 @@ class TestPluginRegister:
|
||||||
oidc_plugin.register(ctx)
|
oidc_plugin.register(ctx)
|
||||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||||
assert "construction failed" in oidc_plugin.LAST_SKIP_REASON
|
assert "construction failed" in oidc_plugin.LAST_SKIP_REASON
|
||||||
|
|
||||||
|
# -- client_secret wiring ----------------------------------------------
|
||||||
|
|
||||||
|
def test_registers_public_when_no_secret(self, patch_config, monkeypatch):
|
||||||
|
patch_config(None)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||||
|
ctx = MagicMock()
|
||||||
|
oidc_plugin.register(ctx)
|
||||||
|
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||||
|
assert registered._client_secret == ""
|
||||||
|
|
||||||
|
def test_secret_from_env(self, patch_config, monkeypatch):
|
||||||
|
patch_config(None)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
|
||||||
|
ctx = MagicMock()
|
||||||
|
oidc_plugin.register(ctx)
|
||||||
|
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||||
|
assert registered._client_secret == "env-secret"
|
||||||
|
|
||||||
|
def test_secret_from_config_yaml(self, patch_config):
|
||||||
|
patch_config(
|
||||||
|
{
|
||||||
|
"self_hosted": {
|
||||||
|
"issuer": _ISSUER,
|
||||||
|
"client_id": _CLIENT_ID,
|
||||||
|
"client_secret": "cfg-secret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ctx = MagicMock()
|
||||||
|
oidc_plugin.register(ctx)
|
||||||
|
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||||
|
assert registered._client_secret == "cfg-secret"
|
||||||
|
|
||||||
|
def test_env_secret_overrides_config(self, patch_config, monkeypatch):
|
||||||
|
patch_config(
|
||||||
|
{
|
||||||
|
"self_hosted": {
|
||||||
|
"issuer": _ISSUER,
|
||||||
|
"client_id": _CLIENT_ID,
|
||||||
|
"client_secret": "cfg-secret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
|
||||||
|
ctx = MagicMock()
|
||||||
|
oidc_plugin.register(ctx)
|
||||||
|
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||||
|
assert registered._client_secret == "env-secret"
|
||||||
|
|
||||||
|
def test_empty_env_secret_does_not_shadow_config(self, patch_config, monkeypatch):
|
||||||
|
patch_config(
|
||||||
|
{
|
||||||
|
"self_hosted": {
|
||||||
|
"issuer": _ISSUER,
|
||||||
|
"client_id": _CLIENT_ID,
|
||||||
|
"client_secret": "cfg-secret",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "")
|
||||||
|
ctx = MagicMock()
|
||||||
|
oidc_plugin.register(ctx)
|
||||||
|
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||||
|
assert registered._client_secret == "cfg-secret"
|
||||||
|
|
||||||
|
def test_register_log_reports_confidential_not_secret(
|
||||||
|
self, patch_config, monkeypatch, caplog
|
||||||
|
):
|
||||||
|
import logging
|
||||||
|
|
||||||
|
patch_config(None)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||||
|
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "logme-secret")
|
||||||
|
ctx = MagicMock()
|
||||||
|
with caplog.at_level(logging.INFO):
|
||||||
|
oidc_plugin.register(ctx)
|
||||||
|
# The success log reports confidentiality as a boolean, never the value.
|
||||||
|
assert "logme-secret" not in caplog.text
|
||||||
|
assert "confidential=True" in caplog.text
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue