12 KiB
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 |
| 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.
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)
- RP redirects user to
/authorization - OP shows login page at
/login - 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)
- Admin (UI) or operator (CLI) creates invite:
fastapi-oidc-op create-invite <username> - Token stored in DB with expiry (configurable TTL, default 24h)
- Link delivered via pluggable channel (CLI prints it initially)
- User visits
/register/<token> - Token validated, registration form shown with pre-set username
- User registers webauthn credential or sets password
- User account created with
usersgroup - Magic link marked as used
- Redirect to management self-service (triggers OP login)
Management Auth (RP-side)
- User visits
/manage/* - Session middleware checks for valid RP session
- No session → redirect to OP
/authorization(Authorization Code + PKCE) - After login → callback at
/manage/callback - RP exchanges code for tokens, validates ID token
- RP session created with user info + groups
- Admin routes check for
admingroup 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
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-federationendpoint- Federation signing keys (separate from OIDC signing keys)
authority_hintsconfiguration- 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.