# 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.