308 lines
13 KiB
Markdown
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.
|