docs: add OIDC provider integration design (Phase 5)
This commit is contained in:
parent
e8fd7eb01d
commit
fa8ec14590
1 changed files with 308 additions and 0 deletions
308
docs/plans/2026-02-16-oidc-provider-design.md
Normal file
308
docs/plans/2026-02-16-oidc-provider-design.md
Normal file
|
|
@ -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": "<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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue