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

@ -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: ""