feat(auth): generic OIDC provider + Microsoft Entra preset (PR2)
Implements the OIDC half of the optional auth layer scaffolded in PR1.
``AUTH_MODE=oidc`` becomes a real, working choice — Entra is just a
config preset (its discovery URL is the only thing distinguishing it
from Google / Okta / Auth0 / Keycloak / generic OIDC).
Backend
- src/auth/providers/oidc_provider.py — generic OIDCAuthProvider
parametrised by an OpenID Connect discovery URL. Authorisation-code
flow with PKCE (we use it even with a confidential client; defence in
depth). ID-token validation: signature against the IdP's JWKS (with
one force-refresh retry on rotation), iss matches discovery's issuer,
aud includes our client_id, exp not in the past (60s leeway), nonce
matches what we minted on kickoff. Discovery + JWKS cached for an
hour; rotation is handled silently.
- src/auth/factory.py — swap NotImplementedError stub for real
OIDCAuthProvider. Add get_auth_providers() returning a {id: provider}
map so routes look up by id under multi-provider OIDC; PR3 surfaces
this in the UI.
- src/auth/cookies.py — set/clear helpers for the short-lived (10 min)
reflow_oauth_tx cookie that carries state, nonce, PKCE verifier, and
the original next_path through the IdP redirect-back.
- src/auth/session.py — make_tx_serializer() with a different
itsdangerous salt from the session cookie so the two can never be
confused (session value can't be replayed as a tx value or vice
versa).
- src/auth/routes.py — new GET /api/v1/auth/login/{provider_id} kickoff
and GET /api/v1/auth/callback/{provider_id} callback. Open-redirect
sanitiser on ?next= rejects schemes and protocol-relative URLs.
/auth/config now iterates all configured providers (Phase 3 will
surface a chooser UI; backend already returns the list).
Dependencies
- Add joserfc>=1.0.0 (JWT validation; authlib.jose was deprecated by
the authlib maintainers in favour of joserfc).
- Add respx>=0.21.1 to dev deps (httpx-native IdP mock for tests).
Tests
- tests/unit/auth/test_oidc_provider.py (13 tests) — PKCE round-trip,
state tampering, provider-id binding, nonce/aud/iss mismatch,
expired ID token, aud-as-list accepted, token-endpoint error
surfaces, JWKS rotation handled, login_url + Protocol-method shape.
- tests/integration/auth/test_oidc_roundtrip.py (8 tests) — full
kickoff→callback→/me round-trip against a respx-mocked IdP. Plus
IdP-error redirect, missing-tx 400, tampered-state 400, unknown-
provider 404, open-redirect sanitisation.
- tests/conftest_fixtures/auth.py — auth_oidc_app/_client fixtures.
Docs
- New docs/how-to/configure-sso.md — end-to-end Entra walkthrough
(register app, add ID-token claims, generate secret, env block,
smoke test) plus discovery-URL reference table for Google/Okta/
Auth0/Keycloak. Calls out just-in-time provisioning explicitly so
operators don't hunt for a "users" admin page.
- docs/explanation/authentication-design.md — appended sequence
diagram and design rationale: why two cookies, why PKCE with a
confidential client, why we validate every claim ourselves, why
JWKS rotation gets a force-refresh retry, why the open-redirect
sanitiser, why no group/role gating yet.
- docs/reference/authentication.md — extended cookie list with
reflow_oauth_tx, endpoint table now lists OIDC routes as live, env
matrix updated.
- AGENTS.md — Common workflows row.
Test totals: 579 unit + 36 auth integration, all green. No regressions
against PR1's tests.
Live verification against a real IdP requires a registered app at the
target IdP — that's part of the UIC rollout (the user side), not PR2
sign-off. The respx-mocked integration test exercises the same code
path end-to-end against the live route handlers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>