diff --git a/docs/plans/2026-02-13-auth-routes-design.md b/docs/plans/2026-02-13-auth-routes-design.md new file mode 100644 index 0000000..00423d4 --- /dev/null +++ b/docs/plans/2026-02-13-auth-routes-design.md @@ -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": "", "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)