Skip to content

feat(auth): generic OIDC SSO (Authelia, Keycloak, Authentik, Okta, …)#21

Merged
TheAlexPG merged 6 commits into
mainfrom
feat/oidc-sso
May 17, 2026
Merged

feat(auth): generic OIDC SSO (Authelia, Keycloak, Authentik, Okta, …)#21
TheAlexPG merged 6 commits into
mainfrom
feat/oidc-sso

Conversation

@TheAlexPG
Copy link
Copy Markdown
Owner

Closes #18.

Summary

  • Adds a generic OIDC authorization-code flow at /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-configuration and cached per-process.
  • New public GET /api/v1/auth/config returns {google_enabled, oidc_enabled, oidc_provider_name} so the SPA hides SSO buttons whose backends aren't configured.
  • AuthPage.tsx fetches /auth/config on 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.
  • Mirrors the existing Google OAuth pattern (composition, no new abstractions): same email-keyed user upsert, same personal-workspace bootstrap on first login, same fragment-based JWT delivery (tokens never hit server logs). New users land with auth_provider="oidc".
  • README roadmap updated to mark AI agents (feat(agents): supervisor + GitHub repo researcher + tracing #14), per-diagram export (feat: per-diagram export to Mermaid / PlantUML / Structurizr DSL / JSON + UI #15), and OIDC SSO as shipped.

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)

  • CSRF state cookie + PKCE — intentionally matches the existing Google flow; track as a cross-provider hardening pass rather than an OIDC-only change.
  • Mapping OIDC group/role claims to ArchFlow roles.

Test plan

  • CI runs `backend/tests/api/test_oidc.py` against the `postgres:16-alpine` service — 8 respx-mocked cases (disabled-by-default 503s, authorize-redirect query params, callback happy path + new user + workspace, existing-user upsert, no-email rejection, token-endpoint failure).
  • Local: `make dev-infra && cd backend && .venv/bin/python -m pytest tests/api/test_oidc.py`. (Not run on my machine — Docker daemon was off; relying on CI.)
  • Smoke against a real Authelia instance: set the five OIDC env vars, restart backend, click "Continue with Authelia" on the login page, verify you land back authenticated with a personal workspace created.
  • Verify "Continue with Google" still renders when only Google is configured (no regression).

TheAlexPG added 6 commits May 17, 2026 18:58
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)
…pped

AI agents (#14) and per-diagram export (#15) already landed on main;
OIDC SSO ships in this PR.
…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.
@TheAlexPG TheAlexPG merged commit b796fb8 into main May 17, 2026
4 checks passed
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.

IODC confiugration

1 participant