13 KiB
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_idorredirect_uri→ error page (not redirected to RP, per spec) - Invalid
scope,response_type, etc. → error redirect to RP witherrorparameter - 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/authorizationroute handles the redirect to/logindirectly, 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 foruserid/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.
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:
"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:
server.context.cdb[settings.manage_client_id] = {
"client_id": settings.manage_client_id,
"client_secret": "<generated>",
"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": "<generated>",
}
Additional clients can be registered via config or CLI in a future phase.
idpyoidc Server Configuration
{
"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
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:
# 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):
- Pops
oidc_auth_requestfrom the session - Looks up the user from the repo, populates the userinfo cache
- Creates an idpyoidc session
- Calls
authz_part2()to mint the authorization code - 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=codeis 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.