diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 81644d8907b..e517b5f1aee 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -1276,3 +1276,33 @@ updates: # # default — works on Fly.io out of the box). # # # # 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: "" diff --git a/plugins/dashboard_auth/self_hosted/__init__.py b/plugins/dashboard_auth/self_hosted/__init__.py index 325c6536d7e..fe01cb97232 100644 --- a/plugins/dashboard_auth/self_hosted/__init__.py +++ b/plugins/dashboard_auth/self_hosted/__init__.py @@ -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), ) diff --git a/tests/plugins/dashboard_auth/test_self_hosted_provider.py b/tests/plugins/dashboard_auth/test_self_hosted_provider.py index 02e4908c296..1f502a020a7 100644 --- a/tests/plugins/dashboard_auth/test_self_hosted_provider.py +++ b/tests/plugins/dashboard_auth/test_self_hosted_provider.py @@ -127,14 +127,34 @@ def _mint_id_token( ) -def _make_provider(rsa_keypair, *, scopes: str | None = None): - """Construct a provider with discovery + JWKS stubbed (no network).""" +def _make_provider( + 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} if scopes is not None: kwargs["scopes"] = scopes + if client_secret is not None: + kwargs["client_secret"] = client_secret p = oidc_plugin.SelfHostedOIDCProvider(**kwargs) # 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() # Patch the JWKS client to return our fixture key. fake_key = MagicMock() @@ -655,6 +675,185 @@ class TestCompleteLogin: 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 `` 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 # --------------------------------------------------------------------------- @@ -853,6 +1052,7 @@ class TestPluginRegister: "HERMES_DASHBOARD_OIDC_ISSUER", "HERMES_DASHBOARD_OIDC_CLIENT_ID", "HERMES_DASHBOARD_OIDC_SCOPES", + "HERMES_DASHBOARD_OIDC_CLIENT_SECRET", ): monkeypatch.delenv(var, raising=False) @@ -978,3 +1178,87 @@ class TestPluginRegister: oidc_plugin.register(ctx) ctx.register_dashboard_auth_provider.assert_not_called() 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