docs: add auth routes design document (Phase 4)

This commit is contained in:
Johan Lundberg 2026-02-13 15:45:18 +01:00
parent 6d8019464f
commit f7ed2cf54d
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1

View file

@ -0,0 +1,226 @@
# Authentication Routes — Design & Session State
> **For Claude:** This document captures the design decisions for Phase 4: authentication routes.
> Resume from "Next Steps" section.
## Project State (as of 2026-02-13)
### Git
- **Branch:** `main`
- **HEAD:** `6d80194 docs: update roadmap to reflect completed auth services`
- **Working tree:** Clean (untracked: `.idea/`, `README.md`, `docs/`)
- **86 tests passing**, all quality checks green
### What Exists (built in previous phases)
| Component | Status |
|-----------|--------|
| SQLite repositories (User, Credential, MagicLink) | Complete |
| PasswordService (argon2 hash/verify) | Complete |
| WebAuthnService (fido2 registration/authentication) | Complete |
| MagicLinkService (token create/validate/cleanup) | Complete |
| FastAPI app factory with lifespan, DI, `/health` | Complete |
| Models (User, WebAuthnCredential, PasswordCredential, MagicLink) | Complete |
| Config (Settings with env prefix `OIDC_OP_`) | Complete |
### What Does NOT Exist Yet
- No routes beyond `/health`
- No templates
- No static files (HTMX, WebAuthn JS)
- No session middleware
- No CLI module (referenced in pyproject.toml but not created)
- No OIDC provider integration (Phase 5)
---
## Design Decisions (confirmed by user)
### 1. Standalone Auth Routes First
Build login/register routes that work independently, without OIDC context. They authenticate
the user and create a simple session. Phase 5 wires them into the OIDC authorization flow.
This allows incremental development and testing.
### 2. Session Mechanism: Starlette SessionMiddleware
Use Starlette's built-in `SessionMiddleware` with signed cookies. After successful authentication,
the session stores `{"userid": "<userid>", "username": "<username>"}`. This is a temporary
mechanism that Phase 5 (OIDC integration) will replace with proper OIDC sessions.
WebAuthn challenge state is also stored in the session between begin/complete steps.
### 3. Templates: Jinja2 + HTMX + Minimal CSS
Server-rendered Jinja2 templates with HTMX for interactive form submissions. A small JavaScript
helper (`webauthn.js`) wraps the browser's `navigator.credentials` API to bridge HTMX and WebAuthn.
Minimal CSS (no framework).
### 4. Registration Supports Both WebAuthn and Password
The registration page offers both WebAuthn and password credential creation. User picks one (or both).
This matches the "WebAuthn-first" philosophy while providing a password fallback.
### 5. Credential Management is Shared
Credential add/remove is NOT tied to the registration flow. Instead:
- `/register/{token}` creates the user account, auto-logs in, redirects to `/manage/credentials?setup=1`
- `/manage/credentials` is the shared credential management page for both new and existing users
- `?setup=1` query param shows a "Welcome! Set up your first credential" banner
This avoids duplicating credential management UI between registration and self-service.
### 6. Credential Management Lives at /manage/credentials
Per the design doc, `/manage` is the management app prefix. Self-service credential management
fits here. In Phase 5, `/manage` routes will authenticate via the OIDC RP flow. For now (Phase 4),
they just check the simple session.
---
## Endpoint Structure
### Login/Logout (authn/routes.py)
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/login` | No | Render login page |
| POST | `/login/webauthn/begin` | No | Start WebAuthn authentication (returns challenge) |
| POST | `/login/webauthn/complete` | No | Verify WebAuthn assertion, create session |
| POST | `/login/password` | No | Verify username+password, create session |
| POST | `/logout` | Yes | Clear session |
| GET | `/register/{token}` | No | Validate magic link, create user, auto-login, redirect |
### Credential Management (manage/routes.py)
| Method | Path | Auth | Purpose |
|--------|------|------|---------|
| GET | `/manage/credentials` | Yes | Render credential management page |
| POST | `/manage/credentials/webauthn/begin` | Yes | Start WebAuthn registration |
| POST | `/manage/credentials/webauthn/complete` | Yes | Complete WebAuthn registration |
| DELETE | `/manage/credentials/webauthn/{credential_id}` | Yes | Remove WebAuthn credential |
| POST | `/manage/credentials/password` | Yes | Set or change password |
| DELETE | `/manage/credentials/password` | Yes | Remove password credential |
---
## Data Flows
### Password Login
1. User visits `/login` -> server renders `login.html` with username+password form and WebAuthn button
2. User submits username+password via HTMX POST to `/login/password`
3. Server looks up user by username, fetches password credential from repo
4. `PasswordService.verify()` checks the hash
5. Success -> set `session["userid"]` and `session["username"]`, return HX-Redirect
6. Failure -> return error HTML fragment (HTMX swaps into the form)
### WebAuthn Login
1. User enters username, clicks "Sign in with security key"
2. HTMX POST to `/login/webauthn/begin` with username
3. Server looks up user's WebAuthn credentials from repo, calls `WebAuthnService.begin_authentication()`
4. Server stores state in session, returns options JSON
5. `webauthn.js` calls `navigator.credentials.get()` with the options
6. Browser prompts user for security key
7. JS serializes the response, HTMX POST to `/login/webauthn/complete`
8. Server calls `WebAuthnService.complete_authentication()` with session state + response
9. Server updates `sign_count` on the credential
10. Success -> set session, return HX-Redirect
11. Failure -> return error fragment
### Registration
1. User visits `/register/{token}`
2. Server calls `MagicLinkService.validate(token)` -- if invalid/expired/used -> error page
3. Server creates user: `generate_unique_userid()`, create `User(userid=..., username=link.username, groups=["users"])`, save via `UserRepository.create()`
4. Server calls `MagicLinkService.mark_used(token)`
5. Server sets session (`userid`, `username`)
6. Redirect to `/manage/credentials?setup=1`
### Credential Management
**Page load (`GET /manage/credentials`):**
1. Requires authenticated session
2. Fetches user's WebAuthn credentials via `CredentialRepository.get_webauthn_by_user()`
3. Fetches password credential via `CredentialRepository.get_password_by_user()`
4. Renders page showing existing credentials with remove buttons, plus forms to add new ones
5. If `?setup=1`, shows welcome banner
**Add WebAuthn credential (begin/complete):**
1. HTMX POST to `/manage/credentials/webauthn/begin` with optional device name
2. Server calls `WebAuthnService.begin_registration()` with user's ID, existing credential IDs (to exclude)
3. Stores state in session, returns options JSON
4. `webauthn.js` calls `navigator.credentials.create()`, POSTs result to `/complete`
5. Server completes registration, stores `WebAuthnCredential` in repo
6. Returns updated credential list HTML fragment (HTMX swaps)
**Set password:**
1. HTMX POST to `/manage/credentials/password` with new password (+confirmation)
2. Server calls `PasswordService.hash()`, stores/updates `PasswordCredential` in repo
3. Returns updated credential section fragment
**Remove credentials:**
- DELETE requests via HTMX, server deletes from repo, returns updated list fragment
- Guard against removing the last credential (user must keep at least one)
---
## Files to Create/Modify
| File | Action | Purpose |
|------|--------|---------|
| `src/fastapi_oidc_op/authn/routes.py` | Create | Login/logout/register routes |
| `src/fastapi_oidc_op/manage/routes.py` | Create | Credential management routes |
| `src/fastapi_oidc_op/templates/base.html` | Create | Base template (HTMX, minimal CSS) |
| `src/fastapi_oidc_op/templates/login.html` | Create | Login page |
| `src/fastapi_oidc_op/templates/manage/credentials.html` | Create | Credential management page |
| `src/fastapi_oidc_op/static/webauthn.js` | Create | Browser WebAuthn helper |
| `src/fastapi_oidc_op/static/htmx.min.js` | Create | HTMX library (vendored) |
| `src/fastapi_oidc_op/static/style.css` | Create | Minimal CSS |
| `src/fastapi_oidc_op/app.py` | Modify | Mount routers, add SessionMiddleware, configure templates/static |
| `src/fastapi_oidc_op/dependencies.py` | Modify | Add session/auth dependencies |
---
## Error Handling
- Invalid/expired/used magic link -> render error page (not redirect, to avoid loops)
- Wrong password -> HTMX error fragment swapped into form
- WebAuthn failure -> HTMX error fragment
- Not authenticated -> redirect to `/login`
- Attempt to remove last credential -> HTMX error fragment with explanation
- User not found during login -> same error as wrong password (no username enumeration)
---
## Next Steps
Design is validated through Section 4 (registration & credential management flows).
Remaining before implementation:
1. **Write implementation plan** — Break into tasks with TDD steps, file contents, test cases
2. **Implementation** — Use subagent-driven development skill
### Brainstorming Status
All design questions answered:
- [x] Standalone auth routes (not coupled to OIDC)
- [x] Starlette SessionMiddleware for sessions
- [x] Jinja2 + HTMX + minimal CSS
- [x] Both WebAuthn and password during registration
- [x] WebAuthn challenge state in server-side session
- [x] Credential management shared, not tied to registration
- [x] Credential management at /manage/credentials
- [x] Registration redirects to /manage/credentials?setup=1
---
## Process Notes
- User prefers to be asked design questions before implementation begins
- Use **brainstorming** skill: one question at a time, present design in 200-300 word sections, validate each
- Use **subagent-driven development** for implementation
- Plans live in `docs/plans/`
- Quality gate: `./scripts/check.sh` (ruff format, ruff check, ty check, pytest)