feat(auth): generic OIDC SSO (Authelia, Keycloak, Authentik, Okta, …)#21
Merged
Conversation
Covers /api/v1/auth/config, /auth/oauth/oidc/login, and /auth/oauth/oidc/callback behaviors: disabled-by-default 503s, discovery + authorize redirect, callback upsert (new + existing user by email), no-email rejection, token-endpoint failure propagation. respx mocks the OIDC provider; no live Authelia needed. Implementation follows in the next commit.
Closes GitHub issue #18 (enterprise-sso-004). Backend: - New `app/api/v1/oidc.py` router with GET /api/v1/auth/oauth/oidc/login → 302 to provider's authorize endpoint GET /api/v1/auth/oauth/oidc/callback → token exchange + userinfo + upsert + 302 to /auth/callback#tokens Endpoints are discovered from {issuer}/.well-known/openid-configuration and cached per-process. Falls back to 503 when OIDC_* env vars are unset so the SPA can hide the button. - Mirrors the Google OAuth pattern in oauth_stub.py for consistency: same fragment-based token delivery, same email-keyed user upsert, same personal-workspace bootstrap on first login. New users land with auth_provider="oidc". - Settings: OIDC_ISSUER_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI, OIDC_SCOPES, OIDC_PROVIDER_NAME (display name). - New GET /api/v1/auth/config (public) returns {google_enabled, oidc_enabled, oidc_provider_name} so the SPA renders only the SSO buttons whose backends are actually configured. Frontend: - AuthPage fetches /auth/config on mount and conditionally renders the Google and/or OIDC buttons. The OIDC button label uses oidc_provider_name ("Continue with Authelia", "Continue with Keycloak"…). - The "or" divider only renders when at least one SSO option is available. Out of scope (separate tasks): - OIDC group/role mapping into ArchFlow roles - CSRF state cookie (matches existing Google flow — track as a security hardening pass across both providers later)
…TTP tests CI hung indefinitely on the previous version (33+ min on a job that normally finishes in ~2 min). Two issues, both about fixture/transport interaction: 1. `@respx.mock` with no base_url intercepts ALL httpx clients, including the test client's ASGITransport. Outbound requests to the fake provider were matched; the inbound test-client request to /api/v1/auth/... was silently swallowed and never reached FastAPI, hanging the test. Fix: scope each block with `respx.mock(base_url=ISSUER, ...)` so only provider URLs are intercepted. 2. Tests took both `client` AND `db` fixtures. Holding the fixture's AsyncSession open while the ASGI handler opens its own via `get_db` deadlocks (or reads stale state) because Postgres row locks from the handler's commit can't be observed by the long-lived fixture session. No other test in the suite mixes these two — that's the convention. Fix: drop `db` from HTTP tests, open a fresh `async_session()` after the response to verify DB state. Use uuid-suffixed emails so tests don't depend on table truncation order. Same 8 behaviors covered as before.
…l one
Previous attempt scoped respx with `respx.mock(base_url=ISSUER) as router`
but kept adding routes via module-level `respx.get(...)` — which writes to
the global default router, a different instance. The scoped router stayed
empty, so every outbound request raised AllMockedAssertionError ("not
mocked!"). Capture the router from the context manager and call
`router.get/post(...)` on it.
Three fixes, all applied to both providers for parity:
1. Don't leak provider error bodies into HTTP responses
`HTTPException(400, f"... {token_resp.text}")` echoed the provider's
error body straight back to the browser — could include client_id,
missing scopes, and other config hints. Log full response server-side
at WARNING, return a generic "<provider> token exchange failed".
2. Require email_verified before upsert
The user-upsert keys on email alone. Without verifying that the IdP
actually vouches for the address, an attacker with control of any OIDC
IdP (or an unverified-email Google account) could claim a victim's
email and take over a pre-existing local account on first SSO login.
Default-deny: missing claim is treated the same as explicit false.
* OIDC uses the spec field `email_verified`.
* Google's userinfo uses `verified_email` (Google's own naming).
3. Validate discovery doc has required endpoints
Previously a 200 response missing authorization_endpoint /
token_endpoint / userinfo_endpoint would throw KeyError → 500. Now
raises 502 at discovery time with the list of missing fields.
Tests:
- Existing happy paths now send `email_verified: true` in userinfo.
- New: `test_oidc_callback_rejects_unverified_email`
- New: `test_oidc_discovery_doc_missing_endpoints_returns_502`
Cross-provider hardening (state cookie / PKCE, strict cross-provider
collision check on existing-email upsert) stays as a follow-up PR — same
scope decision as the original.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #18.
Summary
/api/v1/auth/oauth/oidc/{login,callback}— works with any OIDC-compliant provider (Authelia, Keycloak, Authentik, Okta, Google, etc.). Endpoints are auto-discovered from{OIDC_ISSUER_URL}/.well-known/openid-configurationand cached per-process.GET /api/v1/auth/configreturns{google_enabled, oidc_enabled, oidc_provider_name}so the SPA hides SSO buttons whose backends aren't configured.AuthPage.tsxfetches/auth/configon mount and conditionally renders "Continue with Google" and/or "Continue with {OIDC_PROVIDER_NAME}". The label is configurable, so users see "Continue with Authelia" / "Continue with Keycloak" / etc.auth_provider="oidc".Config (
.env.example)```
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://localhost:8000/api/v1/auth/oauth/oidc/callback
OIDC_SCOPES=openid email profile
OIDC_PROVIDER_NAME=SSO
```
Leave issuer/client_id/secret blank to hide the button. Email/password login is unaffected.
Out of scope (separate follow-ups)
Test plan