porchlight/docs/plans/2026-02-11-oidc-op-design.md

350 lines
12 KiB
Markdown

# FastAPI OIDC OP - Design Document
## Overview
An OpenID Connect Provider built with FastAPI and idpy-oidc, featuring built-in user management, WebAuthn-first authentication, and support for both MongoDB and SQLite storage backends.
The management interface dogfoods the OP by acting as an OIDC Relying Party that authenticates against the OP itself.
OIDC Federation support is architecturally planned but deferred to a later phase.
## High-Level Architecture
Single FastAPI application with two logical domains:
### 1. OIDC Provider (root `/`)
Core OP built on idpy-oidc:
| Endpoint | Purpose |
|----------|---------|
| `/.well-known/openid-configuration` | Discovery |
| `/authorization` | Authorization endpoint |
| `/token` | Token exchange |
| `/userinfo` | User claims |
| `/jwks` | Public keys |
| `/registration` | Dynamic client registration |
| `/login` | Login UI (webauthn + password) |
| `/register/<token>` | Magic link registration UI |
### 2. Management App (`/manage`)
OIDC RP that authenticates against the OP above:
| Route | Audience | Purpose |
|-------|----------|---------|
| `/manage/` | All | Dashboard |
| `/manage/callback` | System | OIDC callback |
| `/manage/profile` | Users | View/edit profile |
| `/manage/credentials` | Users | Manage webauthn keys, passwords |
| `/manage/admin/users` | Admins | User listing, search, enable/disable |
| `/manage/admin/users/{id}` | Admins | Individual user management |
| `/manage/admin/clients` | Admins | RP/client management |
| `/manage/admin/invites` | Admins | Create/manage magic links |
The management app self-registers as a client of the OP during startup. It uses Authorization Code flow with PKCE. Admin routes are gated by group membership (`admin` group).
## Data Model
### User
| Field | Type | Notes |
|-------|------|-------|
| id | internal | Auto-increment (SQLite) or ObjectId (MongoDB) |
| userid | str | 32-bit proquint (e.g., `lusab-bansen`), unique. Used as OIDC `sub` claim |
| username | str | Unique, required. Login identifier |
| preferred_username | str? | Optional display name |
| given_name | str? | OIDC standard claim |
| family_name | str? | OIDC standard claim |
| nickname | str? | OIDC standard claim |
| email | str? | Optional |
| email_verified | bool | Default false |
| phone_number | str? | Optional |
| phone_number_verified | bool | Default false |
| picture | str? | URL |
| locale | str? | |
| active | bool | Default true |
| created_at | datetime | |
| updated_at | datetime | |
| groups | list[str] | e.g., `["admin", "users"]` |
### Credential (polymorphic)
| Field | Type | Notes |
|-------|------|-------|
| id | internal | |
| user_id | FK → User | |
| type | enum | `webauthn` or `password` |
| created_at | datetime | |
**WebAuthn fields:**
- credential_id: bytes
- public_key: bytes
- sign_count: int
- device_name: str
**Password fields:**
- password_hash: str (argon2)
### MagicLink
| Field | Type | Notes |
|-------|------|-------|
| id | internal | |
| token | str | Unique, URL-safe (secrets.token_urlsafe) |
| username | str | Username being registered |
| expires_at | datetime | |
| used | bool | |
| created_by | str? | userid of admin, or "cli" |
| note | str? | Optional label |
### OIDC State Entities
| Entity | Purpose |
|--------|---------|
| OIDCGrant | Authorization grants (grant_id, data, expires_at) |
| OIDCSession | User sessions (session_id, user_id, data, expires_at) |
| OIDCClient | Registered RPs (client_id, client_info, timestamps) |
## Repository Layer
Protocol-based repository pattern with two implementations.
```python
class UserRepository(Protocol):
async def create(self, user: User) -> User: ...
async def get_by_id(self, user_id: str) -> User | None: ...
async def get_by_userid(self, userid: str) -> User | None: ...
async def get_by_username(self, username: str) -> User | None: ...
async def update(self, user: User) -> User: ...
async def list(self, offset: int, limit: int) -> list[User]: ...
class CredentialRepository(Protocol):
async def create(self, credential: Credential) -> Credential: ...
async def get_by_user(self, user_id, type?) -> list[Credential]: ...
async def get_webauthn_by_credential_id(self, cred_id: bytes) -> Credential | None: ...
async def update(self, credential: Credential) -> Credential: ...
async def delete(self, credential_id) -> bool: ...
```
Similar protocols for MagicLinkRepository, OIDCGrantRepository, OIDCSessionRepository, OIDCClientRepository.
**SQLite**: `aiosqlite` with raw SQL.
**MongoDB**: `motor` async driver.
Storage backend selected at startup via configuration.
## Authentication Flows
### Login (OP-side)
1. RP redirects user to `/authorization`
2. OP shows login page at `/login`
3. User chooses method:
**WebAuthn (primary):**
- User enters username
- Browser calls `navigator.credentials.get()` with server-provided options
- HTMX POSTs assertion to `/login/webauthn/verify`
- Server verifies against stored credential
- Success → OP creates session, redirects back to `/authorization`
**Password (fallback):**
- User enters username + password
- HTMX POSTs to `/login/password/verify`
- Server verifies argon2 hash
- Success → same redirect flow
### Registration (Magic Link)
1. Admin (UI) or operator (CLI) creates invite: `fastapi-oidc-op create-invite <username>`
2. Token stored in DB with expiry (configurable TTL, default 24h)
3. Link delivered via pluggable channel (CLI prints it initially)
4. User visits `/register/<token>`
5. Token validated, registration form shown with pre-set username
6. User registers webauthn credential or sets password
7. User account created with `users` group
8. Magic link marked as used
9. Redirect to management self-service (triggers OP login)
### Management Auth (RP-side)
1. User visits `/manage/*`
2. Session middleware checks for valid RP session
3. No session → redirect to OP `/authorization` (Authorization Code + PKCE)
4. After login → callback at `/manage/callback`
5. RP exchanges code for tokens, validates ID token
6. RP session created with user info + groups
7. Admin routes check for `admin` group membership
## Project Structure
```
fastapi-oidc-op/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── src/
│ └── fastapi_oidc_op/
│ ├── __init__.py
│ ├── app.py # FastAPI app factory
│ ├── config.py # Pydantic Settings
│ ├── cli.py # CLI commands (create-invite, init-admin, etc.)
│ ├── models.py # Pydantic models
│ ├── dependencies.py # FastAPI dependency injection
│ │
│ ├── oidc/ # OIDC Provider integration
│ │ ├── __init__.py
│ │ ├── provider.py # idpy-oidc server setup
│ │ ├── endpoints.py # FastAPI routes wrapping idpy-oidc
│ │ └── claims.py # User model → OIDC claims mapping
│ │
│ ├── authn/ # Authentication methods
│ │ ├── __init__.py
│ │ ├── webauthn.py # WebAuthn (python-fido2)
│ │ ├── password.py # Argon2 hashing (argon2-cffi)
│ │ └── routes.py # Login/register endpoints
│ │
│ ├── manage/ # Management RP
│ │ ├── __init__.py
│ │ ├── rp.py # OIDC RP client logic
│ │ ├── routes.py # Self-service routes
│ │ ├── middleware.py # RP session/auth middleware
│ │ └── admin.py # Admin routes
│ │
│ ├── store/ # Data access layer
│ │ ├── __init__.py
│ │ ├── protocols.py # Repository Protocol definitions
│ │ ├── mongodb/
│ │ │ ├── __init__.py
│ │ │ └── repositories.py
│ │ └── sqlite/
│ │ ├── __init__.py
│ │ └── repositories.py
│ │
│ ├── invite/ # Magic link system
│ │ ├── __init__.py
│ │ ├── service.py # Create/validate magic links
│ │ └── delivery.py # Pluggable delivery (CLI first)
│ │
│ └── templates/ # Jinja2 templates
│ ├── base.html
│ ├── login.html
│ ├── register.html
│ ├── manage/
│ │ ├── dashboard.html
│ │ ├── profile.html
│ │ ├── credentials.html
│ │ └── admin/
│ │ ├── users.html
│ │ ├── user_detail.html
│ │ └── clients.html
│ └── themes/
│ ├── default.css
│ └── dark.css
├── static/
│ ├── htmx.min.js
│ └── webauthn.js # Browser-side WebAuthn helpers
├── tests/
│ ├── conftest.py
│ ├── test_oidc/
│ ├── test_authn/
│ ├── test_manage/
│ └── test_store/
└── docs/
└── plans/
```
## Dependencies
| Package | Purpose |
|---------|---------|
| fastapi | Web framework |
| uvicorn | ASGI server |
| idpyoidc | OIDC Provider implementation |
| pydantic-settings | Configuration |
| jinja2 | Template rendering |
| fido2 | WebAuthn server-side (python-fido2) |
| argon2-cffi | Argon2 password hashing |
| motor | Async MongoDB driver |
| aiosqlite | Async SQLite driver |
| proquint | Human-readable user identifiers |
| python-multipart | Form data parsing |
| httpx | HTTP client (RP token exchange) |
### Development
| Package | Purpose |
|---------|---------|
| pytest | Testing |
| pytest-asyncio | Async test support |
## Configuration
Environment-based via Pydantic Settings. Prefix: `OIDC_OP_`.
| Variable | Default | Description |
|----------|---------|-------------|
| `OIDC_OP_ISSUER` | `http://localhost:8000` | OP issuer URL |
| `OIDC_OP_STORAGE_BACKEND` | `sqlite` | `sqlite` or `mongodb` |
| `OIDC_OP_SQLITE_PATH` | `./data/oidc_op.db` | SQLite database path |
| `OIDC_OP_MONGODB_URI` | `mongodb://localhost:27017` | MongoDB connection URI |
| `OIDC_OP_MONGODB_DATABASE` | `oidc_op` | MongoDB database name |
| `OIDC_OP_MANAGE_CLIENT_ID` | `manage-app` | Management RP client ID |
| `OIDC_OP_INVITE_TTL` | `86400` | Magic link TTL (seconds) |
| `OIDC_OP_THEME` | `default` | Active theme name |
## Docker
### Dockerfile
```dockerfile
FROM python:3.13-slim
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY src/ src/
COPY static/ static/
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "fastapi_oidc_op.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000", "--proxy-headers"]
```
### docker-compose.yml
MongoDB deployment with OP service. SQLite deployments need only the OP service with a volume mount.
## Security
- CSRF tokens on all HTMX POST forms
- Session cookies: HttpOnly, SameSite=Lax, Secure (behind HTTPS proxy)
- Rate limiting on login attempts (per IP/username)
- WebAuthn origin validation enforced
- Argon2-cffi defaults (OWASP-aligned)
- Magic links: single-use, time-limited, cryptographically random
- All input validated via Pydantic models
- No TLS handling (reverse proxy responsibility)
- Proxy headers trusted (X-Forwarded-For, X-Forwarded-Proto)
## Testing
- SQLite as default test backend (no external dependencies)
- Unit tests: repositories, credential verification, claims mapping
- Integration tests: full OIDC flows via httpx.AsyncClient
- WebAuthn tests: mock FIDO2 operations with fixtures
## Future: OIDC Federation
Deferred to a later phase. The architecture supports adding:
- `/.well-known/openid-federation` endpoint
- Federation signing keys (separate from OIDC signing keys)
- `authority_hints` configuration
- Trust chain building and metadata policy
- Federation endpoints (fetch, list, resolve)
- Leaf entity or intermediate entity configuration
idpy-oidc has built-in federation support, so integration will wrap its federation server functionality into FastAPI endpoints when ready.