Skip to content

feat(dashboard-auth): generic self-hosted OIDC provider (+ multi-provider verify fix) - #38806

Closed
benbarclay wants to merge 2 commits into
mainfrom
feat/dashboard-auth-self-hosted-oidc
Closed

benbarclay wants to merge 2 commits into
mainfrom
feat/dashboard-auth-self-hosted-oidc

Conversation

@benbarclay

Copy link
Copy Markdown
Contributor

Summary

Adds a generic self-hosted OpenID Connect auth provider for the hermes dashboard OAuth gate, so operators running their own IdP can gate the dashboard without any per-IdP code. Works against any conformant OIDC server — Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, Google, etc.

This is a pure drop-in plugin (plugins/dashboard_auth/self_hosted/) implementing the existing DashboardAuthProvider protocol — it touches no core auth/runtime/login paths. The one core change is an independent middleware bug fix (see below) surfaced while testing the plugin live.

What the plugin does

  • OIDC discovery from {issuer}/.well-known/openid-configuration (cached; advertised issuer pinned to config; endpoints required to be HTTPS, loopback http allowed for local-dev IdPs).
  • authorization-code + PKCE (S256), public client.
  • 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/preferred_username, groupsorg_id) onto a Session.
  • Standard refresh_token grant for silent re-auth; RFC 7009 revocation on logout when the IdP advertises it.

Verifies the ID token, not the access token, because OIDC guarantees the ID token is a signed JWT carrying identity, whereas access-token format is opaque to the client per spec (many IdPs issue opaque access tokens). This is the only universally-correct choice across self-hosted IdPs. (The bundled nous provider verifies its access token because Nous Portal mints a custom JWT access token — a non-OIDC shortcut.)

Public PKCE clients only for now; confidential clients (client_secret) are left as a documented TODO seam.

Configuration

dashboard:
  oauth:
    provider: self-hosted
    self_hosted:
      issuer: https://auth.example.com/realms/hermes   # required
      client_id: hermes-dashboard                       # required
      scopes: "openid profile email"                    # optional (default)

Or via env (env-wins-config, empty-is-unset — same convention as the nous plugin):
HERMES_DASHBOARD_OIDC_ISSUER, HERMES_DASHBOARD_OIDC_CLIENT_ID, HERMES_DASHBOARD_OIDC_SCOPES.

Bundled bug fix (core — needs review)

While testing the plugin against a local Keycloak I hit a real multi-provider bug in hermes_cli/dashboard_auth/middleware.py. The gate verifies a session cookie by trying each registered provider's verify_session in turn (the cookie stores only the access token, not the issuing provider). A provider whose IdP/JWKS is unreachable raises ProviderError — and the loop used to return HTTP 503 on the first ProviderError, before any later provider got a turn.

Concretely: a self-hosted-OIDC session hits the nous provider first (registered earlier); nous tries to reach Nous Portal's JWKS, which is unreachable in a self-hosted deployment, so it raises → the gate 503s before the self-hosted provider can verify the token.

Fix: a ProviderError from one provider is logged and the loop continues. A 503 is returned only if no provider verified the token and at least one was unreachable — distinguishing a transient IdP outage (don't force a needless re-login) from a token that's genuinely invalid (fall through to refresh/relogin). Single-provider behaviour is unchanged.

Docs

Adds a Self-hosted OIDC provider section to web-dashboard.md, including a copy-paste Keycloak worked example (realm import JSON + docker run + dashboard wiring + login walkthrough), with a note that the same pattern applies to Authentik/Zitadel/Authelia.

Testing

  • 65 new tests for the provider (construction, discovery incl. issuer-mismatch + HTTPS enforcement, start_login/PKCE, complete_login, ID-token verification, refresh/revoke, env/config precedence).
  • 3 new middleware regression tests (unreachable-provider-first must not block a working second; all-unreachable still 503s; reachable-but-unrecognised falls through to relogin, not 503). Mutation-tested — reverting the fix makes the first case fail with the exact 503 bug.
  • Verified end-to-end against a live Keycloak: real ID-token round trip → JWKS verification → authenticated dashboard (/api/auth/me returns provider: self-hosted).
  • Full suite green except 5 pre-existing pathspec-missing failures in unrelated code.

Test Plan

  • CI green
  • Spin up Keycloak per the docs example and confirm the dashboard login round trip

Adds a bundled dashboard-auth provider plugin that authenticates the
web dashboard against any conformant self-hosted OpenID Connect server
(Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …) using
standard OIDC — no per-IDP code.

It's a pure drop-in plugin implementing the DashboardAuthProvider
protocol; it touches no core auth/runtime/login paths. Mechanics:

- OIDC discovery from {issuer}/.well-known/openid-configuration
  (cached; issuer pinned; endpoints required HTTPS, loopback http
  allowed for local-dev IDPs)
- authorization-code + PKCE (S256), public client
- verifies the OIDC ID token (RS256/ES256) against the discovered
  jwks_uri with iss/aud pinned to the configured issuer/client_id, and
  maps standard claims (sub/email/name/preferred_username, groups→org)
  onto a Session
- standard refresh_token grant for silent re-auth; RFC 7009 revocation
  on logout when advertised

Verifies the ID token (not the access token) because OIDC guarantees the
ID token is a signed JWT carrying identity, while access-token format is
opaque to the client per spec — the only universally-correct choice
across self-hosted IDPs.

Config via dashboard.oauth.self_hosted.{issuer,client_id,scopes} in
config.yaml or HERMES_DASHBOARD_OIDC_{ISSUER,CLIENT_ID,SCOPES} env vars
(env-wins-config, empty-is-unset — same convention as the nous plugin).
Confidential clients (client_secret) left as a documented TODO seam.

Docs: adds a Self-hosted OIDC section to the web-dashboard guide,
including a copy-paste Keycloak worked example (realm import + docker
run + dashboard wiring + login walkthrough).

Tests: 65 cases covering construction, discovery (incl. issuer
mismatch + https enforcement), start_login/PKCE, complete_login, ID
token verification, refresh/revoke, and env/config precedence.
…derError

The gated dashboard verifies a session cookie by trying each registered
DashboardAuthProvider's verify_session in turn (the session cookie stores
only the access token, not which provider issued it). A provider that
doesn't recognise a token returns None; a provider whose IDP/JWKS is
unreachable raises ProviderError.

The loop used to return HTTP 503 on the FIRST ProviderError, before any
later provider got a turn. With multiple providers stacked, that means an
unreachable IDP for a session you didn't even use blocks login through a
different, reachable provider.

Concrete repro: a self-hosted-OIDC session hits the 'nous' provider first
(registered earlier); nous tries to reach Nous Portal's JWKS, which is
unreachable in a self-hosted deployment, so it raises — and the gate
503s before the 'self-hosted' provider can verify the token. Hit live
while testing the new self-hosted OIDC plugin against a local Keycloak.

Fix: a ProviderError from one provider is logged and the loop continues
to the next. A 503 is returned only if NO provider verified the token
AND at least one was unreachable — distinguishing a transient IDP outage
(don't force a needless re-login) from a token that's genuinely invalid
(fall through to refresh/relogin). Single-provider behaviour is
unchanged.

Tests: adds an _UnreachableProvider stub and three cases — unreachable
provider first must not block a working second; all-unreachable still
503s; reachable-but-unrecognised falls through to 401/relogin (not 503).
Mutation-tested: reverting the fix makes the first case fail with the
exact 503 bug.
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have area/auth Authentication, OAuth, credential pools comp/plugins Plugin system and bundled plugins labels Jun 4, 2026
@teknium1

teknium1 commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator

Merged via #38917. Your commits were cherry-picked onto current main with your authorship preserved in git log (rebase merge — 616c0a3, f57ce34).

One salvage note: your branch predated the password-login subsystem (ed9e8ba), so the original middleware.py diff would have reverted /auth/password-login out of _GATE_PUBLIC_PREFIXES. The cherry-pick's 3-way merge dropped that stale deletion and I verified the prefix survives — the verify-chain fix itself landed unchanged. Docs conflict (your OIDC section vs. the new username/password section) resolved keeping both.

Verified: 90 targeted tests pass + 4-scenario E2E of the verify chain. Thanks!

@teknium1 teknium1 closed this Jun 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants