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

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
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)
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)

  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
  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

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.