Skip to content

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

Merged
teknium1 merged 2 commits into
mainfrom
hermes/hermes-441eed3d
Jun 4, 2026
Merged

feat(dashboard-auth): generic self-hosted OIDC provider (+ multi-provider verify fix)#38917
teknium1 merged 2 commits into
mainfrom
hermes/hermes-441eed3d

Conversation

@teknium1

@teknium1 teknium1 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Operators running their own IdP can now gate the hermes dashboard behind any conformant OIDC server with zero per-IdP code. Salvage of #38806 (@benbarclay) onto current main.

Generic self-hosted OpenID Connect auth provider as a drop-in plugin implementing the existing DashboardAuthProvider protocol — Authentik, Keycloak, Zitadel, Authelia, Auth0, Okta, Google, etc. Plus a multi-provider verify-chain fix surfaced while testing it live.

Changes

  • plugins/dashboard_auth/self_hosted/: OIDC discovery (cached, issuer-pinned, HTTPS-enforced w/ loopback http exception), auth-code + PKCE (S256) public client, ID-token verification (RS256/ES256) against discovered JWKS with iss/aud pinned, standard OIDC claim mapping, refresh_token silent re-auth, RFC 7009 revocation on logout.
  • hermes_cli/dashboard_auth/middleware.py: a ProviderError from one provider (its IdP/JWKS unreachable) no longer aborts the verify chain — the loop continues so a later reachable provider can verify. 503 only when no provider verifies and at least one was unreachable (transient outage vs. genuinely-invalid token). Single-provider behaviour unchanged.
  • website/docs/.../web-dashboard.md: Self-hosted OIDC section + copy-paste Keycloak worked example.

Salvage notes

Original branch was 21 commits behind and predated the password-login subsystem (ed9e8ba09). The original middleware.py diff would have reverted /auth/password-login out of _GATE_PUBLIC_PREFIXES. Cherry-picked onto current main; the 3-way merge dropped the stale deletion, and I verified /auth/password-login survives. Docs conflict (adjacent username/password + OIDC sections) resolved keeping both.

Validation

Check Result
test_dashboard_auth_middleware.py + test_self_hosted_provider.py 90 passed
Stale /auth/password-login revert neutralized — prefix intact on salvaged main
Verify-chain E2E (4 scenarios) unreachable-first→AUTH, all-unreachable→503, unrecognized→fallthrough, single-unreachable→503 — all pass

Original PR: #38806. Authorship preserved per-commit (will rebase-merge).

Infographic

self-hosted-oidc-dashboard-auth

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.
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: hermes/hermes-441eed3d vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9791 on HEAD, 9788 on base (🆕 +3)

🆕 New issues (3):

Rule Count
unresolved-import 3
First entries
tests/plugins/dashboard_auth/test_self_hosted_provider.py:28: [unresolved-import] unresolved-import: Cannot resolve imported module `pytest`
plugins/dashboard_auth/self_hosted/__init__.py:77: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`
tests/plugins/dashboard_auth/test_self_hosted_provider.py:26: [unresolved-import] unresolved-import: Cannot resolve imported module `httpx`

✅ Fixed issues: none

Unchanged: 5081 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

@teknium1
teknium1 merged commit 616c0a3 into main Jun 4, 2026
23 checks passed
@teknium1
teknium1 deleted the hermes/hermes-441eed3d branch June 4, 2026 10:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants