rename package directory fastapi_oidc_op → porchlight
This commit is contained in:
parent
32b75cf92d
commit
c5a80b51de
49 changed files with 7332 additions and 0 deletions
350
docs/plans/2026-02-11-oidc-op-design.md
Normal file
350
docs/plans/2026-02-11-oidc-op-design.md
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue