From fa8ec1459082eae95bdf8196dfd2704b401d19dd Mon Sep 17 00:00:00 2001 From: Johan Lundberg Date: Mon, 16 Feb 2026 12:43:29 +0100 Subject: [PATCH] docs: add OIDC provider integration design (Phase 5) --- docs/plans/2026-02-16-oidc-provider-design.md | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 docs/plans/2026-02-16-oidc-provider-design.md diff --git a/docs/plans/2026-02-16-oidc-provider-design.md b/docs/plans/2026-02-16-oidc-provider-design.md new file mode 100644 index 0000000..8110395 --- /dev/null +++ b/docs/plans/2026-02-16-oidc-provider-design.md @@ -0,0 +1,308 @@ +# OIDC Provider Integration Design (Phase 5) + +> **Status: APPROVED** — Minimal working OP with idpyoidc. + +## Goal + +Integrate `idpyoidc` into the existing Porchlight application to serve as a standards-compliant OpenID Connect Provider. This phase delivers a minimal working OP: discovery, authorization code flow with PKCE, token exchange, userinfo, and JWKS. Dynamic client registration and federation are deferred. + +## Architecture + +Thin FastAPI wrapper around `idpyoidc.server.Server`. The idpyoidc Server handles all OIDC protocol logic (request validation, token minting, session/grant management, key management). FastAPI routes act as HTTP adapters calling the idpyoidc endpoint pipeline: `parse_request` → `process_request` → `do_response`. + +### New Files + +| File | Purpose | +|------|---------| +| `src/fastapi_oidc_op/oidc/provider.py` | Initialize `idpyoidc.server.Server` with config, custom auth method, custom userinfo source | +| `src/fastapi_oidc_op/oidc/endpoints.py` | FastAPI routes wrapping idpyoidc endpoint processing | +| `src/fastapi_oidc_op/oidc/claims.py` | `User` model → OIDC standard claims dict + custom `UserInfo` subclass | + +### Modified Files + +| File | Change | +|------|--------| +| `src/fastapi_oidc_op/config.py` | Add `signing_key_path` setting | +| `src/fastapi_oidc_op/app.py` | Initialize idpyoidc Server in lifespan, include OIDC router | +| `src/fastapi_oidc_op/authn/routes.py` | After login, detect pending OIDC authorization request and resume flow | + +## Endpoints + +| Route | Method | idpyoidc Endpoint | Purpose | +|-------|--------|-------------------|---------| +| `/.well-known/openid-configuration` | GET | `ProviderConfiguration` | Discovery | +| `/authorization` | GET | `Authorization` | Authorization endpoint | +| `/authorization/complete` | GET | (internal) | Resume flow after login | +| `/token` | POST | `Token` | Token exchange | +| `/userinfo` | GET, POST | `UserInfo` | User claims | +| `/jwks` | GET | (keyjar) | Public signing keys | + +## Authorization Code Flow + +### Happy Path + +``` +RP → GET /authorization?response_type=code&client_id=...&redirect_uri=...&scope=openid&state=...&nonce=...&code_challenge=...&code_challenge_method=S256 + ↓ +idpyoidc parses + validates request + ↓ +No existing session → store auth request in Starlette session["oidc_auth_request"] + ↓ +Redirect to /login + ↓ +User authenticates (password or webauthn — existing routes) + ↓ +Login route detects session["oidc_auth_request"] → redirect to /authorization/complete + ↓ +/authorization/complete: + 1. Pop auth request from session + 2. Create idpyoidc session via authorization endpoint's create_session() + 3. Call authz_part2() to mint authorization code + 4. do_response() → redirect to RP's redirect_uri with code + state + ↓ +RP → POST /token (code + code_verifier) + ↓ +idpyoidc validates code, PKCE, client auth → returns access_token + id_token + ↓ +RP → GET /userinfo (Bearer access_token) + ↓ +idpyoidc validates token → returns claims from PorchlightUserInfo +``` + +### Already Authenticated + +If the user has an active idpyoidc session (tracked via cookie), the authorization endpoint skips the login redirect and immediately mints a code. The `authenticated_as()` method on our custom auth method checks for this. + +### Error Cases + +- Invalid `client_id` or `redirect_uri` → error page (not redirected to RP, per spec) +- Invalid `scope`, `response_type`, etc. → error redirect to RP with `error` parameter +- Login failure → stays on `/login` (existing behavior), pending auth request remains in session +- Token endpoint errors → JSON error response per RFC 6749 + +## Custom Authentication Method + +`PorchlightAuthnMethod(UserAuthnMethod)`: + +- **`__call__(**kwargs)`** — Not used for rendering. The `/authorization` route handles the redirect to `/login` directly, bypassing idpyoidc's built-in login page rendering. Returns empty string. +- **`verify(*args, **kwargs)`** — Not called directly; our login routes handle credential verification. +- **`authenticated_as(client_id, cookie, **kwargs)`** — Checks the Starlette session for `userid`/`username`. Returns `({"uid": username}, timestamp)` when authenticated, `(None, 0)` when not. + +In practice, the authorization route does not rely on idpyoidc's `process_request()` for the auth redirect. Instead, it calls `parse_request()` to validate the OIDC parameters, then checks the Starlette session itself. If not authenticated, it stores the request and redirects to `/login`. If authenticated, it calls `create_session()` + `authz_part2()` directly. + +This bypasses idpyoidc's `AuthnBroker` machinery, which is designed for server-rendered login pages embedded in the authorization response. Our architecture uses a separate login page with HTMX, so we handle the redirect/resume ourselves. + +## UserInfo Source + +### The Sync/Async Bridge Problem + +idpyoidc's `UserInfo.__call__()` is synchronous, but our `UserRepository` is async. + +**Solution:** `PorchlightUserInfo` maintains an in-memory dict (`self.db`) that acts as a claims cache. When an idpyoidc session is created (in `/authorization/complete`), we look up the user from the async repo and populate the cache. The `UserInfo.__call__()` reads from this sync dict. + +```python +class PorchlightUserInfo(UserInfo): + def __init__(self): + super().__init__(db={}) + + def set_user_claims(self, user_id: str, claims: dict) -> None: + """Called after authentication to populate claims cache.""" + self.db[user_id] = claims +``` + +The cache is per-process (in-memory). For a single-process deployment this is fine. For multi-process, claims may need re-fetching (tolerable since sessions are per-process too in idpyoidc's default in-memory session store). + +### Claims Mapping + +| User Field | OIDC Claim | Scope | +|------------|------------|-------| +| `userid` | `sub` | `openid` | +| `preferred_username` (or `username`) | `preferred_username` | `profile` | +| `given_name` | `given_name` | `profile` | +| `family_name` | `family_name` | `profile` | +| `nickname` | `nickname` | `profile` | +| `email` | `email` | `email` | +| `email_verified` | `email_verified` | `email` | +| `phone_number` | `phone_number` | `phone` | +| `phone_number_verified` | `phone_number_verified` | `phone` | +| `picture` | `picture` | `profile` | +| `locale` | `locale` | `profile` | +| `updated_at` | `updated_at` | `profile` | + +`groups` is not a standard OIDC claim but may be included via a custom scope in a future phase. + +## Key Management + +idpyoidc manages its own `KeyJar` with RSA and EC signing keys. Configuration: + +```python +"key_conf": { + "key_defs": [ + {"type": "RSA", "use": ["sig"]}, + {"type": "EC", "crv": "P-256", "use": ["sig"]}, + ], + "uri_path": "jwks", # Served at /jwks + "private_path": "data/keys/private_jwks.json", + "read_only": False, +} +``` + +Keys are generated on first startup and persisted to `signing_key_path` (default: `data/keys/`). Subsequent startups load existing keys. The `/jwks` endpoint serves the public portion. + +## Static Client Registration + +Clients are registered in-memory via `server.context.cdb` during the lifespan. The management app auto-registers itself: + +```python +server.context.cdb[settings.manage_client_id] = { + "client_id": settings.manage_client_id, + "client_secret": "", + "redirect_uris": [(f"{settings.issuer}/manage/callback", {})], + "response_types_supported": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": ["openid", "profile", "email"], + "allowed_scopes": ["openid", "profile", "email"], + "client_salt": "", +} +``` + +Additional clients can be registered via config or CLI in a future phase. + +## idpyoidc Server Configuration + +```python +{ + "issuer": settings.issuer, + "key_conf": { ... }, # as above + "endpoint": { + "provider_config": { + "path": ".well-known/openid-configuration", + "class": "idpyoidc.server.oidc.provider_config.ProviderConfiguration", + "kwargs": {"client_authn_method": None}, + }, + "authorization": { + "path": "authorization", + "class": "idpyoidc.server.oidc.authorization.Authorization", + "kwargs": { + "client_authn_method": None, + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + }, + }, + "token": { + "path": "token", + "class": "idpyoidc.server.oidc.token.Token", + "kwargs": { + "client_authn_method": ["client_secret_post", "client_secret_basic"], + }, + }, + "userinfo": { + "path": "userinfo", + "class": "idpyoidc.server.oidc.userinfo.UserInfo", + "kwargs": {}, + }, + }, + "userinfo": { + "class": "fastapi_oidc_op.oidc.claims.PorchlightUserInfo", + "kwargs": {}, + }, + "authentication": {}, # We handle auth outside idpyoidc + "authz": { + "class": "idpyoidc.server.authz.AuthzHandling", + "kwargs": { + "grant_config": { + "usage_rules": { + "authorization_code": { + "supports_minting": ["access_token", "refresh_token", "id_token"], + "max_usage": 1, + "expires_in": 120, + }, + "access_token": {"expires_in": 3600}, + "refresh_token": { + "supports_minting": ["access_token", "refresh_token", "id_token"], + "expires_in": 86400, + }, + }, + "expires_in": 2592000, # 30 days + }, + }, + }, + "token_handler_args": { + "jwks_file": "data/keys/token_jwks.json", + "code": {"kwargs": {"lifetime": 600}}, + "token": { + "class": "idpyoidc.server.token.jwt_token.JWTToken", + "kwargs": {"lifetime": 3600, "add_claims_by_scope": True}, + }, + "refresh": { + "class": "idpyoidc.server.token.jwt_token.JWTToken", + "kwargs": {"lifetime": 86400}, + }, + "id_token": { + "class": "idpyoidc.server.token.id_token.IDToken", + "kwargs": { + "lifetime": 3600, + "add_claims_by_scope": True, + }, + }, + }, + "scopes_to_claims": { + "openid": ["sub"], + "profile": [ + "preferred_username", "given_name", "family_name", + "nickname", "picture", "locale", "updated_at", + ], + "email": ["email", "email_verified"], + "phone": ["phone_number", "phone_number_verified"], + }, +} +``` + +## Config Additions + +```python +class Settings(BaseSettings): + # ... existing fields ... + signing_key_path: str = "data/keys" +``` + +## Login Route Changes + +In `authn/routes.py`, the login success handlers (`login_password`, `login_webauthn_complete`) gain a check: + +```python +# After setting session["userid"] and session["username"]: +if "oidc_auth_request" in request.session: + redirect_target = "/authorization/complete" +else: + redirect_target = "/manage/credentials" +``` + +The `/authorization/complete` route (in `oidc/endpoints.py`): +1. Pops `oidc_auth_request` from the session +2. Looks up the user from the repo, populates the userinfo cache +3. Creates an idpyoidc session +4. Calls `authz_part2()` to mint the authorization code +5. Returns the redirect to the RP + +## Testing Strategy + +Tests use `httpx.AsyncClient` with the app's test configuration (in-memory SQLite, localhost issuer). A test client is registered statically. + +| Test Area | What's Tested | +|-----------|---------------| +| Discovery | `/.well-known/openid-configuration` returns valid metadata with correct endpoints | +| JWKS | `/jwks` returns valid JWK Set with signing keys | +| Authorization | Unauthenticated request redirects to `/login`; authenticated request returns code | +| Token | Valid code exchange returns `access_token` + `id_token`; invalid code returns error | +| UserInfo | Valid access token returns claims; invalid token returns 401 | +| Full flow | End-to-end: authorize → login → code → token → userinfo | +| Login integration | Password/WebAuthn login with pending OIDC request resumes the flow | + +## Known Constraints + +- idpyoidc's session/grant storage is in-memory. Restarting the server invalidates all active sessions and tokens. This is acceptable for initial development; persistent storage can be added later. +- The userinfo claims cache is per-process. Multi-worker deployments need shared storage (deferred). +- Only `response_type=code` is supported (Authorization Code flow). Implicit and Hybrid flows are out of scope. +- PKCE is supported but not enforced for confidential clients (can be tightened later). +- No dynamic client registration — clients must be pre-configured.