porchlight/docs/plans/2026-02-16-oidc-provider-design.md
2026-02-16 12:43:29 +01:00

308 lines
13 KiB
Markdown

# 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": "<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
```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.