diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..97626ba --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/data/keys/private_jwks.json b/data/keys/private_jwks.json new file mode 100644 index 0000000..cb36c46 --- /dev/null +++ b/data/keys/private_jwks.json @@ -0,0 +1 @@ +{"keys": [{"kty": "RSA", "use": "sig", "kid": "yecGJYHchQnJbz3K39V9KOyVLez8gS0H8rTCANPFumQ", "n": "nS_gIt--OOcboxtT5SS72quz8ajGlcPW4IYrVCMaiSTKBqYRWjf0MdaRLtq1LHlwKoyu14akwfk2x3IH0Wq76NNpXyF_gAWfd54d3F1vPuZyEMfPBihmukw-aj-YbJvqcxRcZveSy2CIYs4ThVMiGTD0KrmtpDZZxrb3vZqY-LxD1agw4JQ8Ro1kH3nvPgsOOQoDQwNY5jOKemmpNcG2P2kHX_fQGXyPt2LJjH6chOSMbdN4c6meH40ZS2IwvB8txSGGFtscxJtXeDZKvpnqMDmPhCsBEquO793atjsvF-oSs6XNoHmiyF6zK6J9iITtUqXZYX6J9BKPe2OXGQkweQ", "e": "AQAB", "d": "mo_h74QVwuEFzWZiHIGW344lS8YuAhjd6bAPdebSyCC9RaOL1sSjidX8Z04BlIo8a27yrs0XiZTSWllodJLHnaU_SCcevGYcwOMfvchIgZVcRTxf7KzQNgZuTlUBRGNCkf7Dx71anxvI38wdYxuZLqwsOqB9VeG_Jt7hUleYbZ9H8dnFTDY36NkopwsWJ8XjT3W0Xm1JdCH37M3dRQ6EGbPYu3nRx0EQZ78UtXeNRSPXk2pr2UX-8ZTcQNeUCOJyM9n5bgC0gdwmBhkSkR9Kj40Mh_1Zq8h4x6wTfyZbBk-2P2d6Rj0rJUW4G8qtNgHrEVRhqvS8D9dKkeLy8hi9", "p": "0MAR2ySHPqAMeZnLV93n05BmP05XCJPiP5ReH_4H_nWlCi_D9SucThHfpawsZ5VaANtGcpG3GGR4vLVLsA5wSiAl9gjAiROEAnWl8F8bGxPf9J0bnptmxNBGuwr9Re38oMxs7T1-OAA2tv7PxWHVL3IwgyjoU75wLSqCo9UZZN0", "q": "wMP_nu4GPsJMsy7mIqerGKr1VblfEOCMLs2Q72V-W0uXPgKwm7iycTICOJoV3WXNG4Cyg1gtamzUX3sLw2CT1a5GvcEmvENOWXuXkCoJSxpYwgKXspxI7FKKh-dyJI3_2Fy3H-f6rhQjJVvmoioiZzvQhUp3wjl8EpBVh966ok0"}, {"kty": "EC", "use": "sig", "kid": "5Z3ifjhKDHwjCW1DCx2PR8NiM6n1G3p84i10Mvtv3sU", "crv": "P-256", "x": "phDWGpA1jRpPbLNncAi0g34Of_x6dASVgB0GKrskJBk", "y": "l-qt3CJm9JToAqL5jeo512K7mJn8u-RvdzE9F28SGe8", "d": "2t1D0qfT78RdrKEgYT-hRVACO7A6Bo7Ud8Fu7GA4zZU"}]} \ No newline at end of file diff --git a/data/keys/public_jwks.json b/data/keys/public_jwks.json new file mode 100644 index 0000000..2742477 --- /dev/null +++ b/data/keys/public_jwks.json @@ -0,0 +1 @@ +{"keys": [{"kty": "RSA", "use": "sig", "kid": "yecGJYHchQnJbz3K39V9KOyVLez8gS0H8rTCANPFumQ", "e": "AQAB", "n": "nS_gIt--OOcboxtT5SS72quz8ajGlcPW4IYrVCMaiSTKBqYRWjf0MdaRLtq1LHlwKoyu14akwfk2x3IH0Wq76NNpXyF_gAWfd54d3F1vPuZyEMfPBihmukw-aj-YbJvqcxRcZveSy2CIYs4ThVMiGTD0KrmtpDZZxrb3vZqY-LxD1agw4JQ8Ro1kH3nvPgsOOQoDQwNY5jOKemmpNcG2P2kHX_fQGXyPt2LJjH6chOSMbdN4c6meH40ZS2IwvB8txSGGFtscxJtXeDZKvpnqMDmPhCsBEquO793atjsvF-oSs6XNoHmiyF6zK6J9iITtUqXZYX6J9BKPe2OXGQkweQ"}, {"kty": "EC", "use": "sig", "kid": "5Z3ifjhKDHwjCW1DCx2PR8NiM6n1G3p84i10Mvtv3sU", "crv": "P-256", "x": "phDWGpA1jRpPbLNncAi0g34Of_x6dASVgB0GKrskJBk", "y": "l-qt3CJm9JToAqL5jeo512K7mJn8u-RvdzE9F28SGe8"}]} \ No newline at end of file diff --git a/data/keys/token_jwks.json b/data/keys/token_jwks.json new file mode 100644 index 0000000..e4b9227 --- /dev/null +++ b/data/keys/token_jwks.json @@ -0,0 +1 @@ +{"keys": [{"kty": "oct", "use": "enc", "kid": "code", "k": "qpJMVeEnT9ZZZ-aa9gw6IkpfX8tBAmsB"}]} \ No newline at end of file diff --git a/docs/plans/2026-02-11-oidc-op-design.md b/docs/plans/2026-02-11-oidc-op-design.md new file mode 100644 index 0000000..30dd2dc --- /dev/null +++ b/docs/plans/2026-02-11-oidc-op-design.md @@ -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/` | 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 ` +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/` +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. diff --git a/docs/plans/2026-02-11-scaffolding-plan.md b/docs/plans/2026-02-11-scaffolding-plan.md new file mode 100644 index 0000000..8257316 --- /dev/null +++ b/docs/plans/2026-02-11-scaffolding-plan.md @@ -0,0 +1,860 @@ +# Project Scaffolding Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Set up the project foundation: tooling (ruff, ty), package structure, configuration, Pydantic models, repository protocols, app factory, and initial test infrastructure. + +**Architecture:** FastAPI app factory pattern with src layout. Pydantic models for data, Protocol-based repository interfaces, env-based configuration via pydantic-settings. All code checked by ruff (broad ruleset) and ty (strict mode). + +**Tech Stack:** Python 3.13, FastAPI, pydantic-settings, ruff, ty, pytest, pytest-asyncio + +**Design Document:** `docs/plans/2026-02-11-oidc-op-design.md` + +--- + +### Task 1: Configure pyproject.toml with dependencies and tooling + +**Files:** +- Modify: `pyproject.toml` + +**Step 1: Update pyproject.toml with all sections** + +```toml +[project] +name = "fastapi-oidc-op" +version = "0.1.0" +description = "OIDC OpenID Provider with user management" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.34", + "idpyoidc>=5.0", + "pydantic-settings>=2.7", + "jinja2>=3.1", + "fido2>=2.1", + "argon2-cffi>=25.1", + "motor>=3.7", + "aiosqlite>=0.21", + "proquint>=0.2", + "python-multipart>=0.0.20", + "httpx>=0.28", +] + +[project.scripts] +fastapi-oidc-op = "fastapi_oidc_op.cli:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/fastapi_oidc_op"] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.25", + "ruff>=0.15", + "ty>=0.0.16", +] + +[tool.ruff] +line-length = 120 +target-version = "py311" +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "UP", "B", "SIM", "I", "C4", "RUF"] +ignore = ["E501"] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["S101"] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +docstring-code-format = true + +[tool.ty] +python-version = "3.13" +src = ["src"] + +[tool.ty.rules] +possibly-unresolved-reference = "error" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +``` + +**Step 2: Install dependencies** + +Run: `uv sync` +Expected: All dependencies resolve and install successfully. + +**Step 3: Verify tools work** + +Run: `uv run ruff check --version && uv run ty --version` +Expected: Both print version numbers. + +**Step 4: Commit** + +```bash +git add pyproject.toml uv.lock +git commit -m "chore: configure project dependencies and tooling (ruff, ty, pytest)" +``` + +--- + +### Task 2: Create package structure with __init__.py files + +**Files:** +- Create: `src/fastapi_oidc_op/__init__.py` +- Create: `src/fastapi_oidc_op/oidc/__init__.py` +- Create: `src/fastapi_oidc_op/authn/__init__.py` +- Create: `src/fastapi_oidc_op/manage/__init__.py` +- Create: `src/fastapi_oidc_op/store/__init__.py` +- Create: `src/fastapi_oidc_op/store/mongodb/__init__.py` +- Create: `src/fastapi_oidc_op/store/sqlite/__init__.py` +- Create: `src/fastapi_oidc_op/invite/__init__.py` +- Create: `tests/__init__.py` +- Create: `tests/test_store/__init__.py` +- Create: `tests/test_authn/__init__.py` +- Create: `tests/test_oidc/__init__.py` +- Create: `tests/test_manage/__init__.py` + +**Step 1: Create all directories and __init__.py files** + +All `__init__.py` files are empty. Create the directory tree: + +``` +src/ +└── fastapi_oidc_op/ + ├── __init__.py + ├── oidc/ + │ └── __init__.py + ├── authn/ + │ └── __init__.py + ├── manage/ + │ └── __init__.py + ├── store/ + │ ├── __init__.py + │ ├── mongodb/ + │ │ └── __init__.py + │ └── sqlite/ + │ └── __init__.py + └── invite/ + └── __init__.py +tests/ +├── __init__.py +├── test_store/ +│ └── __init__.py +├── test_authn/ +│ └── __init__.py +├── test_oidc/ +│ └── __init__.py +└── test_manage/ + └── __init__.py +``` + +**Step 2: Verify package is importable** + +Run: `uv run python -c "import fastapi_oidc_op; print('OK')"` +Expected: Prints `OK` + +**Step 3: Commit** + +```bash +git add src/ tests/ +git commit -m "chore: create package structure with src layout" +``` + +--- + +### Task 3: Implement configuration module + +**Files:** +- Create: `src/fastapi_oidc_op/config.py` +- Create: `tests/test_config.py` + +**Step 1: Write the failing test** + +```python +# tests/test_config.py +from fastapi_oidc_op.config import Settings, StorageBackend + + +def test_default_settings() -> None: + settings = Settings( + issuer="http://localhost:8000", + ) + assert settings.issuer == "http://localhost:8000" + assert settings.storage_backend == StorageBackend.SQLITE + assert settings.sqlite_path == "data/oidc_op.db" + assert settings.manage_client_id == "manage-app" + assert settings.invite_ttl == 86400 + assert settings.theme == "default" + + +def test_mongodb_settings() -> None: + settings = Settings( + issuer="http://localhost:8000", + storage_backend=StorageBackend.MONGODB, + mongodb_uri="mongodb://mongo:27017", + mongodb_database="test_db", + ) + assert settings.storage_backend == StorageBackend.MONGODB + assert settings.mongodb_uri == "mongodb://mongo:27017" + assert settings.mongodb_database == "test_db" + + +def test_settings_from_env(monkeypatch: "pytest.MonkeyPatch") -> None: + import pytest + + monkeypatch.setenv("OIDC_OP_ISSUER", "https://op.example.org") + monkeypatch.setenv("OIDC_OP_STORAGE_BACKEND", "mongodb") + monkeypatch.setenv("OIDC_OP_MONGODB_URI", "mongodb://remote:27017") + settings = Settings() # type: ignore[call-arg] + assert settings.issuer == "https://op.example.org" + assert settings.storage_backend == StorageBackend.MONGODB +``` + +**Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_config.py -v` +Expected: FAIL - ImportError + +**Step 3: Write the implementation** + +```python +# src/fastapi_oidc_op/config.py +from enum import StrEnum + +from pydantic_settings import BaseSettings + + +class StorageBackend(StrEnum): + SQLITE = "sqlite" + MONGODB = "mongodb" + + +class Settings(BaseSettings): + model_config = {"env_prefix": "OIDC_OP_"} + + # Core + issuer: str + debug: bool = False + + # Storage + storage_backend: StorageBackend = StorageBackend.SQLITE + + # SQLite + sqlite_path: str = "data/oidc_op.db" + + # MongoDB + mongodb_uri: str = "mongodb://localhost:27017" + mongodb_database: str = "oidc_op" + + # Management RP + manage_client_id: str = "manage-app" + + # Magic links + invite_ttl: int = 86400 # seconds + + # Theme + theme: str = "default" +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_config.py -v` +Expected: All 3 tests PASS + +**Step 5: Run ruff and ty** + +Run: `uv run ruff check src/fastapi_oidc_op/config.py tests/test_config.py && uv run ruff format --check src/fastapi_oidc_op/config.py tests/test_config.py && uv run ty check src/fastapi_oidc_op/config.py` +Expected: No errors + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/config.py tests/test_config.py +git commit -m "feat: add configuration module with env-based settings" +``` + +--- + +### Task 4: Implement Pydantic models + +**Files:** +- Create: `src/fastapi_oidc_op/models.py` +- Create: `tests/test_models.py` + +**Step 1: Write the failing tests** + +```python +# tests/test_models.py +from datetime import datetime, timezone + +from fastapi_oidc_op.models import ( + CredentialType, + MagicLink, + PasswordCredential, + User, + WebAuthnCredential, +) + + +def test_user_creation() -> None: + user = User( + userid="lusab-bansen", + username="alice", + ) + assert user.userid == "lusab-bansen" + assert user.username == "alice" + assert user.preferred_username is None + assert user.email is None + assert user.active is True + assert user.groups == [] + assert user.created_at is not None + assert user.updated_at is not None + + +def test_user_with_all_fields() -> None: + user = User( + userid="lusab-bansen", + username="alice", + preferred_username="Alice W.", + given_name="Alice", + family_name="Wonderland", + nickname="ally", + email="alice@example.com", + email_verified=True, + phone_number="+1234567890", + phone_number_verified=False, + picture="https://example.com/alice.jpg", + locale="en-US", + active=True, + groups=["admin", "users"], + ) + assert user.given_name == "Alice" + assert user.groups == ["admin", "users"] + + +def test_webauthn_credential() -> None: + cred = WebAuthnCredential( + user_id="lusab-bansen", + credential_id=b"\x01\x02\x03", + public_key=b"\x04\x05\x06", + sign_count=0, + device_name="YubiKey 5", + ) + assert cred.type == CredentialType.WEBAUTHN + assert cred.credential_id == b"\x01\x02\x03" + assert cred.device_name == "YubiKey 5" + + +def test_password_credential() -> None: + cred = PasswordCredential( + user_id="lusab-bansen", + password_hash="$argon2id$v=19$m=65536,t=3,p=4$hash", + ) + assert cred.type == CredentialType.PASSWORD + assert cred.password_hash.startswith("$argon2") + + +def test_magic_link() -> None: + link = MagicLink( + token="abc123def456", + username="newuser", + ) + assert link.token == "abc123def456" + assert link.username == "newuser" + assert link.used is False + assert link.created_by is None + assert link.expires_at > datetime.now(timezone.utc) +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_models.py -v` +Expected: FAIL - ImportError + +**Step 3: Write the implementation** + +```python +# src/fastapi_oidc_op/models.py +from datetime import datetime, timedelta, timezone +from enum import StrEnum + +from pydantic import BaseModel, Field + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _default_expiry() -> datetime: + return datetime.now(timezone.utc) + timedelta(hours=24) + + +class CredentialType(StrEnum): + WEBAUTHN = "webauthn" + PASSWORD = "password" + + +class User(BaseModel): + userid: str + username: str + preferred_username: str | None = None + given_name: str | None = None + family_name: str | None = None + nickname: str | None = None + email: str | None = None + email_verified: bool = False + phone_number: str | None = None + phone_number_verified: bool = False + picture: str | None = None + locale: str | None = None + active: bool = True + created_at: datetime = Field(default_factory=_utcnow) + updated_at: datetime = Field(default_factory=_utcnow) + groups: list[str] = Field(default_factory=list) + + +class WebAuthnCredential(BaseModel): + user_id: str + type: CredentialType = CredentialType.WEBAUTHN + credential_id: bytes + public_key: bytes + sign_count: int = 0 + device_name: str = "" + created_at: datetime = Field(default_factory=_utcnow) + + +class PasswordCredential(BaseModel): + user_id: str + type: CredentialType = CredentialType.PASSWORD + password_hash: str + created_at: datetime = Field(default_factory=_utcnow) + + +class MagicLink(BaseModel): + token: str + username: str + expires_at: datetime = Field(default_factory=_default_expiry) + used: bool = False + created_by: str | None = None + note: str | None = None +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_models.py -v` +Expected: All 5 tests PASS + +**Step 5: Run ruff and ty** + +Run: `uv run ruff check src/fastapi_oidc_op/models.py tests/test_models.py && uv run ruff format --check src/fastapi_oidc_op/models.py tests/test_models.py && uv run ty check src/fastapi_oidc_op/models.py` +Expected: No errors + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/models.py tests/test_models.py +git commit -m "feat: add Pydantic models for User, Credential, and MagicLink" +``` + +--- + +### Task 5: Implement repository protocols + +**Files:** +- Create: `src/fastapi_oidc_op/store/protocols.py` +- Create: `tests/test_store/test_protocols.py` + +**Step 1: Write the failing test** + +```python +# tests/test_store/test_protocols.py +from typing import runtime_checkable + +from fastapi_oidc_op.store.protocols import ( + CredentialRepository, + MagicLinkRepository, + UserRepository, +) + + +def test_protocols_are_runtime_checkable() -> None: + assert runtime_checkable(UserRepository) # type: ignore[arg-type] + assert runtime_checkable(CredentialRepository) # type: ignore[arg-type] + assert runtime_checkable(MagicLinkRepository) # type: ignore[arg-type] +``` + +Note: This test just verifies the protocols are importable and runtime-checkable. The actual conformance tests come when we implement the SQLite/MongoDB repositories. + +**Step 2: Run test to verify it fails** + +Run: `uv run pytest tests/test_store/test_protocols.py -v` +Expected: FAIL - ImportError + +**Step 3: Write the implementation** + +```python +# src/fastapi_oidc_op/store/protocols.py +from typing import Protocol, runtime_checkable + +from fastapi_oidc_op.models import ( + MagicLink, + PasswordCredential, + User, + WebAuthnCredential, +) + + +@runtime_checkable +class UserRepository(Protocol): + async def create(self, user: User) -> User: ... + + 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_users(self, offset: int = 0, limit: int = 100) -> list[User]: ... + + async def delete(self, userid: str) -> bool: ... + + +@runtime_checkable +class CredentialRepository(Protocol): + async def create_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: ... + + async def create_password(self, credential: PasswordCredential) -> PasswordCredential: ... + + async def get_webauthn_by_user(self, user_id: str) -> list[WebAuthnCredential]: ... + + async def get_webauthn_by_credential_id(self, credential_id: bytes) -> WebAuthnCredential | None: ... + + async def get_password_by_user(self, user_id: str) -> PasswordCredential | None: ... + + async def update_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: ... + + async def delete_webauthn(self, user_id: str, credential_id: bytes) -> bool: ... + + async def delete_password(self, user_id: str) -> bool: ... + + +@runtime_checkable +class MagicLinkRepository(Protocol): + async def create(self, link: MagicLink) -> MagicLink: ... + + async def get_by_token(self, token: str) -> MagicLink | None: ... + + async def mark_used(self, token: str) -> bool: ... + + async def delete_expired(self) -> int: ... +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_store/test_protocols.py -v` +Expected: PASS + +**Step 5: Run ruff and ty** + +Run: `uv run ruff check src/fastapi_oidc_op/store/protocols.py && uv run ruff format --check src/fastapi_oidc_op/store/protocols.py && uv run ty check src/fastapi_oidc_op/store/protocols.py` +Expected: No errors + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/store/protocols.py tests/test_store/test_protocols.py +git commit -m "feat: add repository Protocol interfaces for User, Credential, MagicLink" +``` + +--- + +### Task 6: Implement userid generation utility + +**Files:** +- Create: `src/fastapi_oidc_op/userid.py` +- Create: `tests/test_userid.py` + +**Step 1: Write the failing tests** + +```python +# tests/test_userid.py +import re + +from fastapi_oidc_op.userid import generate_userid + + +def test_generate_userid_format() -> None: + userid = generate_userid() + # 32-bit proquint format: xxxxx-xxxxx + parts = userid.split("-") + assert len(parts) == 2 + for part in parts: + assert len(part) == 5 + + +def test_generate_userid_uniqueness() -> None: + ids = {generate_userid() for _ in range(100)} + assert len(ids) == 100 # All unique + + +def test_generate_userid_is_lowercase() -> None: + userid = generate_userid() + assert userid == userid.lower() +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_userid.py -v` +Expected: FAIL - ImportError + +**Step 3: Write the implementation** + +```python +# src/fastapi_oidc_op/userid.py +import secrets + +from proquint import uint2quint + + +def generate_userid() -> str: + """Generate a unique user identifier in proquint format. + + Returns a 32-bit proquint string like 'lusab-bansen'. + """ + return uint2quint(secrets.randbits(32)) +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_userid.py -v` +Expected: All 3 tests PASS + +**Step 5: Run ruff and ty** + +Run: `uv run ruff check src/fastapi_oidc_op/userid.py tests/test_userid.py && uv run ruff format --check src/fastapi_oidc_op/userid.py tests/test_userid.py && uv run ty check src/fastapi_oidc_op/userid.py` +Expected: No errors (ty may warn about proquint missing type stubs - suppress with `# type: ignore[import-untyped]` if needed) + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/userid.py tests/test_userid.py +git commit -m "feat: add proquint-based userid generation" +``` + +--- + +### Task 7: Implement app factory and health endpoint + +**Files:** +- Create: `src/fastapi_oidc_op/app.py` +- Create: `tests/conftest.py` +- Create: `tests/test_app.py` +- Delete: `main.py` (replaced by app.py) + +**Step 1: Write the failing tests** + +```python +# tests/conftest.py +from collections.abc import AsyncIterator + +import pytest +from httpx import ASGITransport, AsyncClient + +from fastapi_oidc_op.app import create_app +from fastapi_oidc_op.config import Settings + + +@pytest.fixture +def settings() -> Settings: + return Settings(issuer="http://localhost:8000") + + +@pytest.fixture +async def client(settings: Settings) -> AsyncIterator[AsyncClient]: + app = create_app(settings) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url=settings.issuer) as ac: + yield ac +``` + +```python +# tests/test_app.py +from httpx import AsyncClient + + +async def test_health_endpoint(client: AsyncClient) -> None: + response = await client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "ok" + + +async def test_app_has_title(client: AsyncClient) -> None: + response = await client.get("/openapi.json") + assert response.status_code == 200 + data = response.json() + assert data["info"]["title"] == "FastAPI OIDC OP" +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_app.py -v` +Expected: FAIL - ImportError + +**Step 3: Write the implementation** + +```python +# src/fastapi_oidc_op/app.py +from fastapi import FastAPI + +from fastapi_oidc_op.config import Settings + + +def create_app(settings: Settings | None = None) -> FastAPI: + if settings is None: + settings = Settings() # type: ignore[call-arg] + + app = FastAPI( + title="FastAPI OIDC OP", + version="0.1.0", + docs_url="/docs" if settings.debug else None, + redoc_url=None, + ) + + app.state.settings = settings + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + return app +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_app.py -v` +Expected: All 2 tests PASS + +**Step 5: Delete the old main.py** + +Run: `rm main.py` + +**Step 6: Run ruff and ty on all source files** + +Run: `uv run ruff check src/ tests/ && uv run ruff format --check src/ tests/ && uv run ty check src/` +Expected: No errors + +**Step 7: Commit** + +```bash +git rm main.py +git add src/fastapi_oidc_op/app.py tests/conftest.py tests/test_app.py +git commit -m "feat: add app factory with health endpoint and test infrastructure" +``` + +--- + +### Task 8: Run full quality checks and format + +**Files:** +- All files in `src/` and `tests/` + +**Step 1: Format all code with ruff** + +Run: `uv run ruff format src/ tests/` +Expected: Files formatted (or already formatted) + +**Step 2: Run ruff lint with auto-fix** + +Run: `uv run ruff check src/ tests/ --fix` +Expected: No errors (or safe fixes applied) + +**Step 3: Run ty type check** + +Run: `uv run ty check src/` +Expected: No errors + +**Step 4: Run full test suite** + +Run: `uv run pytest -v` +Expected: All tests pass + +**Step 5: Commit any formatting changes** + +```bash +git add -A +git diff --cached --quiet || git commit -m "style: apply ruff formatting" +``` + +--- + +### Task 9: Add pre-commit quality gate script + +**Files:** +- Create: `scripts/check.sh` + +**Step 1: Create the check script** + +```bash +#!/usr/bin/env bash +# Run all quality checks +set -euo pipefail + +echo "==> Formatting..." +uv run ruff format src/ tests/ + +echo "==> Linting..." +uv run ruff check src/ tests/ --fix + +echo "==> Type checking..." +uv run ty check src/ + +echo "==> Testing..." +uv run pytest -v + +echo "==> All checks passed!" +``` + +**Step 2: Make it executable** + +Run: `chmod +x scripts/check.sh` + +**Step 3: Run the script to verify it works** + +Run: `./scripts/check.sh` +Expected: All checks pass + +**Step 4: Commit** + +```bash +git add scripts/check.sh +git commit -m "chore: add quality check script (ruff, ty, pytest)" +``` + +--- + +## Summary + +After completing these 9 tasks, the project will have: + +1. **pyproject.toml** - Dependencies, ruff config (broad ruleset), ty config (strict), pytest config +2. **Package structure** - Full src layout with all subpackages +3. **Configuration** - Pydantic Settings with env vars, storage backend selection +4. **Models** - User, WebAuthnCredential, PasswordCredential, MagicLink +5. **Repository protocols** - Type-safe interfaces for all data access +6. **Userid generation** - Proquint-based unique identifiers +7. **App factory** - FastAPI app with health endpoint +8. **Test infrastructure** - pytest + httpx async client fixtures +9. **Quality tooling** - ruff (lint + format), ty (type check), check script + +The next plan will cover the SQLite repository implementation, followed by authentication, OIDC provider integration, and the management UI. diff --git a/docs/plans/2026-02-13-auth-services-design.md b/docs/plans/2026-02-13-auth-services-design.md new file mode 100644 index 0000000..35059a0 --- /dev/null +++ b/docs/plans/2026-02-13-auth-services-design.md @@ -0,0 +1,78 @@ +# Authentication Services — Design Document + +> **For Claude:** This document captures design decisions for the authentication service layer. +> The implementation plan is at `docs/plans/2026-02-13-auth-services-plan.md`. + +## Scope + +Backend-only authentication services. No HTTP routes or templates in this phase. + +Three independent services: +1. **PasswordService** — Argon2 hash/verify +2. **WebAuthnService** — FIDO2 registration/authentication via python-fido2 +3. **MagicLinkService** — Invite token create/validate + +## Design Decisions + +### 1. PasswordService (stateless, pure crypto) + +- Wraps `argon2-cffi` `PasswordHasher` +- Two methods: `hash(password) -> str`, `verify(hash, password) -> bool` +- `PasswordHasher` injected via constructor for testability +- No repository dependency — caller reads/writes credentials +- Uses argon2-cffi OWASP-recommended defaults +- `verify()` returns `bool` (catches `VerifyMismatchError` internally) + +### 2. WebAuthnService (class wrapping Fido2Server) + +- Constructor takes `rp_id`, `rp_name`, `origin` — creates `Fido2Server` internally +- Four methods: `begin_registration`, `complete_registration`, `begin_authentication`, `complete_authentication` +- `begin_*` methods return `(options, state)` — caller stores state (e.g., in HTTP session) +- `complete_*` methods accept state back from caller +- No repository dependency — caller handles credential CRUD +- Uses python-fido2 v2.1 API (`Fido2Server`, `PublicKeyCredentialRpEntity`, etc.) + +### 3. MagicLinkService (token lifecycle) + +- Depends on `MagicLinkRepository` (injected) +- `create(username, created_by?, note?) -> MagicLink` — generates token via `secrets.token_urlsafe(32)` +- `validate(token) -> MagicLink | None` — checks exists, not used, not expired +- `mark_used(token) -> bool` — delegates to repo +- `cleanup_expired() -> int` — delegates to repo +- TTL configurable via constructor (from `Settings.invite_ttl`) + +### 4. Testing Strategy + +- **Password:** Roundtrip hash/verify, invalid password returns False, fast hasher params for tests +- **WebAuthn:** Build registration/authentication responses manually using fido2's `create()` factory methods (`AttestedCredentialData.create`, `AuthenticatorData.create`, `CollectedClientData.create`, etc.) with real ES256 keys from `cryptography` library. No external test utility package needed. +- **MagicLink:** In-memory SQLite fixtures, test create/validate/expired/used states + +### 5. Dependencies + +All already in `pyproject.toml`: +- `fido2>=2.1` (python-fido2) +- `argon2-cffi>=25.1` + +No new dependencies required. + +## File Structure + +``` +src/fastapi_oidc_op/ +├── authn/ +│ ├── __init__.py (exists, empty) +│ ├── password.py (new) +│ └── webauthn.py (new) +├── invite/ +│ ├── __init__.py (exists, empty) +│ └── service.py (new) + +tests/ +├── test_authn/ +│ ├── __init__.py (new) +│ ├── test_password.py (new) +│ └── test_webauthn.py (new) +├── test_invite/ +│ ├── __init__.py (new) +│ └── test_service.py (new) +``` diff --git a/docs/plans/2026-02-13-auth-services-plan.md b/docs/plans/2026-02-13-auth-services-plan.md new file mode 100644 index 0000000..f282c39 --- /dev/null +++ b/docs/plans/2026-02-13-auth-services-plan.md @@ -0,0 +1,702 @@ +# Authentication Services Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement backend authentication services: PasswordService (argon2), WebAuthnService (fido2), and MagicLinkService (token lifecycle). + +**Architecture:** Three independent services with no cross-dependencies. PasswordService and WebAuthnService are pure logic (no repo dependency). MagicLinkService depends on MagicLinkRepository. All testable with in-memory SQLite or unit tests. + +**Tech Stack:** argon2-cffi, python-fido2 v2.1, cryptography (for test key generation) + +**Quality gate:** `./scripts/check.sh` (ruff format, ruff check, ty check, pytest) + +--- + +### Task 1: PasswordService + +**Files:** +- Create: `src/fastapi_oidc_op/authn/password.py` +- Create: `tests/test_authn/__init__.py` +- Create: `tests/test_authn/test_password.py` + +**Step 1: Write the failing tests** + +```python +# tests/test_authn/test_password.py +from argon2 import PasswordHasher + +from fastapi_oidc_op.authn.password import PasswordService + + +def test_hash_returns_argon2_string() -> None: + service = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + result = service.hash("correcthorse") + assert result.startswith("$argon2id$") + + +def test_verify_correct_password() -> None: + service = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + hashed = service.hash("correcthorse") + assert service.verify(hashed, "correcthorse") is True + + +def test_verify_wrong_password() -> None: + service = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + hashed = service.hash("correcthorse") + assert service.verify(hashed, "wrongpassword") is False + + +def test_verify_invalid_hash() -> None: + service = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + assert service.verify("not-a-hash", "password") is False + + +def test_default_hasher() -> None: + service = PasswordService() + hashed = service.hash("test") + assert service.verify(hashed, "test") is True +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_authn/test_password.py -v` +Expected: FAIL — cannot import `PasswordService` + +**Step 3: Create `__init__.py` and write the implementation** + +```python +# tests/test_authn/__init__.py +# (empty file) +``` + +```python +# src/fastapi_oidc_op/authn/password.py +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerifyMismatchError + + +class PasswordService: + """Argon2 password hashing and verification.""" + + def __init__(self, hasher: PasswordHasher | None = None) -> None: + self._hasher = hasher or PasswordHasher() + + def hash(self, password: str) -> str: + """Hash a password using argon2id. Returns the full hash string.""" + return self._hasher.hash(password) + + def verify(self, password_hash: str, password: str) -> bool: + """Verify a password against an argon2 hash. Returns True if valid.""" + try: + return self._hasher.verify(password_hash, password) + except (VerifyMismatchError, InvalidHashError): + return False +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_authn/test_password.py -v` +Expected: 5 PASSED + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/authn/password.py tests/test_authn/__init__.py tests/test_authn/test_password.py +git commit -m "feat: add PasswordService with argon2 hash/verify" +``` + +--- + +### Task 2: WebAuthnService + +**Files:** +- Create: `src/fastapi_oidc_op/authn/webauthn.py` +- Create: `tests/test_authn/test_webauthn.py` + +**Step 1: Write the failing tests** + +These tests build real cryptographic registration and authentication responses using fido2's factory methods and the `cryptography` library. + +```python +# tests/test_authn/test_webauthn.py +import os + +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.hashes import SHA256 +from fido2.cose import ES256 +from fido2.utils import sha256 +from fido2.webauthn import ( + Aaguid, + AttestedCredentialData, + AttestationObject, + AuthenticationResponse, + AuthenticatorAssertionResponse, + AuthenticatorAttestationResponse, + AuthenticatorData, + CollectedClientData, + PublicKeyCredentialDescriptor, + PublicKeyCredentialType, + RegistrationResponse, +) + +from fastapi_oidc_op.authn.webauthn import WebAuthnService + +RP_ID = "localhost" +RP_NAME = "Test RP" +ORIGIN = "http://localhost:8000" + + +def _make_service() -> WebAuthnService: + return WebAuthnService(rp_id=RP_ID, rp_name=RP_NAME, origin=ORIGIN) + + +def _generate_credential() -> tuple[ec.EllipticCurvePrivateKey, bytes, AttestedCredentialData]: + """Generate a test credential: (private_key, credential_id, attested_credential_data).""" + private_key = ec.generate_private_key(ec.SECP256R1()) + cose_key = ES256.from_cryptography_key(private_key.public_key()) + credential_id = os.urandom(32) + attested = AttestedCredentialData.create( + aaguid=Aaguid.NONE, + credential_id=credential_id, + public_key=cose_key, + ) + return private_key, credential_id, attested + + +def _build_registration_response( + credential_id: bytes, + attested: AttestedCredentialData, + challenge: bytes, +) -> RegistrationResponse: + """Build a valid registration response for the given challenge.""" + auth_data = AuthenticatorData.create( + rp_id_hash=sha256(RP_ID.encode()), + flags=AuthenticatorData.FLAG.UP | AuthenticatorData.FLAG.AT, + counter=0, + credential_data=attested, + ) + attestation_object = AttestationObject.create(fmt="none", auth_data=auth_data, att_stmt={}) + client_data = CollectedClientData.create( + type=CollectedClientData.TYPE.CREATE, + challenge=challenge, + origin=ORIGIN, + ) + return RegistrationResponse( + raw_id=credential_id, + response=AuthenticatorAttestationResponse( + client_data=client_data, + attestation_object=attestation_object, + ), + ) + + +def _build_authentication_response( + private_key: ec.EllipticCurvePrivateKey, + credential_id: bytes, + challenge: bytes, + counter: int = 1, +) -> AuthenticationResponse: + """Build a valid authentication response signed with the private key.""" + client_data = CollectedClientData.create( + type=CollectedClientData.TYPE.GET, + challenge=challenge, + origin=ORIGIN, + ) + auth_data = AuthenticatorData.create( + rp_id_hash=sha256(RP_ID.encode()), + flags=AuthenticatorData.FLAG.UP, + counter=counter, + ) + signature = private_key.sign(auth_data + client_data.hash, ec.ECDSA(SHA256())) + return AuthenticationResponse( + raw_id=credential_id, + response=AuthenticatorAssertionResponse( + client_data=client_data, + authenticator_data=auth_data, + signature=signature, + ), + ) + + +# --- Registration tests --- + +def test_begin_registration_returns_options_and_state() -> None: + service = _make_service() + options, state = service.begin_registration( + user_id=b"user-123", + username="alice", + ) + # Options should be a dict suitable for JSON serialization + assert "publicKey" in options + pub_key = options["publicKey"] + assert "challenge" in pub_key + assert "rp" in pub_key + assert "user" in pub_key + # State should be a dict with challenge + assert "challenge" in state + + +def test_complete_registration_returns_credential_data() -> None: + service = _make_service() + private_key, credential_id, attested = _generate_credential() + + options, state = service.begin_registration( + user_id=b"user-123", + username="alice", + ) + + # Extract challenge from state to build a matching response + challenge = state["challenge"] + response = _build_registration_response(credential_id, attested, challenge) + + result = service.complete_registration(state, response) + assert result.credential_data is not None + assert result.credential_data.credential_id == credential_id + + +def test_begin_registration_with_existing_credentials() -> None: + service = _make_service() + _, cred_id, attested = _generate_credential() + existing = [ + PublicKeyCredentialDescriptor( + type=PublicKeyCredentialType.PUBLIC_KEY, + id=cred_id, + ) + ] + + options, state = service.begin_registration( + user_id=b"user-123", + username="alice", + existing_credentials=existing, + ) + pub_key = options["publicKey"] + assert "excludeCredentials" in pub_key + assert len(pub_key["excludeCredentials"]) == 1 + + +# --- Authentication tests --- + +def test_begin_authentication_returns_options_and_state() -> None: + service = _make_service() + _, cred_id, attested = _generate_credential() + credentials = [ + PublicKeyCredentialDescriptor( + type=PublicKeyCredentialType.PUBLIC_KEY, + id=cred_id, + ) + ] + + options, state = service.begin_authentication(credentials=credentials) + assert "publicKey" in options + assert "challenge" in state + + +def test_complete_authentication_verifies_signature() -> None: + service = _make_service() + private_key, credential_id, attested = _generate_credential() + + credentials = [ + PublicKeyCredentialDescriptor( + type=PublicKeyCredentialType.PUBLIC_KEY, + id=credential_id, + ) + ] + + options, state = service.begin_authentication(credentials=credentials) + challenge = state["challenge"] + + response = _build_authentication_response(private_key, credential_id, challenge) + + result = service.complete_authentication( + state=state, + credentials=[attested], + response=response, + ) + assert result.credential_id == credential_id + + +def test_complete_authentication_wrong_signature_raises() -> None: + import pytest + + service = _make_service() + private_key, credential_id, attested = _generate_credential() + + # Generate a different key to produce a wrong signature + wrong_key = ec.generate_private_key(ec.SECP256R1()) + + credentials = [ + PublicKeyCredentialDescriptor( + type=PublicKeyCredentialType.PUBLIC_KEY, + id=credential_id, + ) + ] + + options, state = service.begin_authentication(credentials=credentials) + challenge = state["challenge"] + + response = _build_authentication_response(wrong_key, credential_id, challenge) + + with pytest.raises(Exception): # fido2 raises ValueError or InvalidSignature + service.complete_authentication( + state=state, + credentials=[attested], + response=response, + ) +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_authn/test_webauthn.py -v` +Expected: FAIL — cannot import `WebAuthnService` + +**Step 3: Write the implementation** + +```python +# src/fastapi_oidc_op/authn/webauthn.py +from typing import Any, Sequence + +from fido2.server import Fido2Server +from fido2.webauthn import ( + AttestedCredentialData, + AuthenticationResponse, + AuthenticatorData, + PublicKeyCredentialDescriptor, + PublicKeyCredentialRpEntity, + PublicKeyCredentialUserEntity, + RegistrationResponse, +) + + +class WebAuthnService: + """FIDO2 WebAuthn registration and authentication.""" + + def __init__(self, rp_id: str, rp_name: str, origin: str) -> None: + rp = PublicKeyCredentialRpEntity(name=rp_name, id=rp_id) + self._server = Fido2Server(rp, verify_origin=lambda o: o == origin) + + def begin_registration( + self, + user_id: bytes, + username: str, + existing_credentials: Sequence[PublicKeyCredentialDescriptor] | None = None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Begin WebAuthn registration. + + Returns (options_dict, state_dict). + options_dict is JSON-serializable for sending to the browser. + state_dict must be stored by the caller and passed to complete_registration. + """ + user = PublicKeyCredentialUserEntity(id=user_id, name=username, display_name=username) + options, state = self._server.register_begin( + user=user, + credentials=existing_credentials, + ) + return dict(options), state + + def complete_registration( + self, + state: dict[str, Any], + response: RegistrationResponse | dict[str, Any], + ) -> AuthenticatorData: + """Complete WebAuthn registration. + + Returns AuthenticatorData with credential_data containing the public key. + """ + return self._server.register_complete(state, response) + + def begin_authentication( + self, + credentials: Sequence[PublicKeyCredentialDescriptor] | None = None, + ) -> tuple[dict[str, Any], dict[str, Any]]: + """Begin WebAuthn authentication. + + Returns (options_dict, state_dict). + """ + options, state = self._server.authenticate_begin(credentials=credentials) + return dict(options), state + + def complete_authentication( + self, + state: dict[str, Any], + credentials: Sequence[AttestedCredentialData], + response: AuthenticationResponse | dict[str, Any], + ) -> AttestedCredentialData: + """Complete WebAuthn authentication. + + Verifies the assertion signature against stored credentials. + Returns the matched AttestedCredentialData. + Raises on verification failure. + """ + return self._server.authenticate_complete(state, credentials, response) +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_authn/test_webauthn.py -v` +Expected: 6 PASSED + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/authn/webauthn.py tests/test_authn/test_webauthn.py +git commit -m "feat: add WebAuthnService with fido2 registration and authentication" +``` + +--- + +### Task 3: MagicLinkService + +**Files:** +- Create: `src/fastapi_oidc_op/invite/service.py` +- Create: `tests/test_invite/__init__.py` +- Create: `tests/test_invite/test_service.py` + +**Step 1: Write the failing tests** + +Uses in-memory SQLite fixtures for the MagicLinkRepository. + +```python +# tests/test_invite/test_service.py +from datetime import UTC, datetime, timedelta +from pathlib import Path +from unittest.mock import patch + +import aiosqlite +import pytest + +from fastapi_oidc_op.invite.service import MagicLinkService +from fastapi_oidc_op.store.sqlite.migrations import run_migrations +from fastapi_oidc_op.store.sqlite.repositories import SQLiteMagicLinkRepository + +MIGRATIONS_DIR = ( + Path(__file__).resolve().parent.parent.parent / "src" / "fastapi_oidc_op" / "store" / "sqlite" / "migrations" +) + + +@pytest.fixture +async def db(): + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + await conn.execute("PRAGMA foreign_keys=ON") + await run_migrations(conn, MIGRATIONS_DIR) + yield conn + await conn.close() + + +@pytest.fixture +def repo(db: aiosqlite.Connection) -> SQLiteMagicLinkRepository: + return SQLiteMagicLinkRepository(db) + + +@pytest.fixture +def service(repo: SQLiteMagicLinkRepository) -> MagicLinkService: + return MagicLinkService(repo=repo, ttl=3600) + + +async def test_create_returns_magic_link(service: MagicLinkService) -> None: + link = await service.create(username="alice") + assert link.username == "alice" + assert link.token # non-empty + assert link.used is False + assert link.expires_at > datetime.now(UTC) + + +async def test_create_generates_unique_tokens(service: MagicLinkService) -> None: + link1 = await service.create(username="alice") + link2 = await service.create(username="bob") + assert link1.token != link2.token + + +async def test_create_with_optional_fields(service: MagicLinkService) -> None: + link = await service.create(username="alice", created_by="admin-id", note="Welcome") + assert link.created_by == "admin-id" + assert link.note == "Welcome" + + +async def test_create_respects_ttl(service: MagicLinkService) -> None: + link = await service.create(username="alice") + expected_min = datetime.now(UTC) + timedelta(seconds=3500) + expected_max = datetime.now(UTC) + timedelta(seconds=3700) + assert expected_min < link.expires_at < expected_max + + +async def test_validate_valid_token(service: MagicLinkService) -> None: + link = await service.create(username="alice") + result = await service.validate(link.token) + assert result is not None + assert result.token == link.token + assert result.username == "alice" + + +async def test_validate_nonexistent_token(service: MagicLinkService) -> None: + result = await service.validate("nonexistent-token") + assert result is None + + +async def test_validate_used_token(service: MagicLinkService) -> None: + link = await service.create(username="alice") + await service.mark_used(link.token) + result = await service.validate(link.token) + assert result is None + + +async def test_validate_expired_token( + service: MagicLinkService, repo: SQLiteMagicLinkRepository +) -> None: + # Create a link that's already expired by using a very short TTL service + expired_service = MagicLinkService(repo=repo, ttl=0) + link = await expired_service.create(username="alice") + # The link expires_at is essentially now, so by the time we validate it should be expired + # To be safe, patch datetime to ensure expiry + result = await service.validate(link.token) + assert result is None + + +async def test_mark_used_returns_true(service: MagicLinkService) -> None: + link = await service.create(username="alice") + result = await service.mark_used(link.token) + assert result is True + + +async def test_mark_used_nonexistent_returns_false(service: MagicLinkService) -> None: + result = await service.mark_used("nonexistent") + assert result is False + + +async def test_cleanup_expired(service: MagicLinkService, repo: SQLiteMagicLinkRepository) -> None: + # Create an expired link + expired_service = MagicLinkService(repo=repo, ttl=-3600) + await expired_service.create(username="expired-user") + + # Create a valid link + await service.create(username="valid-user") + + count = await service.cleanup_expired() + assert count == 1 +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_invite/test_service.py -v` +Expected: FAIL — cannot import `MagicLinkService` + +**Step 3: Create `__init__.py` and write the implementation** + +```python +# tests/test_invite/__init__.py +# (empty file) +``` + +```python +# src/fastapi_oidc_op/invite/service.py +import secrets +from datetime import UTC, datetime, timedelta + +from fastapi_oidc_op.models import MagicLink +from fastapi_oidc_op.store.protocols import MagicLinkRepository + + +class MagicLinkService: + """Magic link token lifecycle management.""" + + def __init__(self, repo: MagicLinkRepository, ttl: int = 86400) -> None: + self._repo = repo + self._ttl = ttl + + async def create( + self, + username: str, + created_by: str | None = None, + note: str | None = None, + ) -> MagicLink: + """Generate and store a new magic link for the given username.""" + token = secrets.token_urlsafe(32) + expires_at = datetime.now(UTC) + timedelta(seconds=self._ttl) + link = MagicLink( + token=token, + username=username, + expires_at=expires_at, + created_by=created_by, + note=note, + ) + return await self._repo.create(link) + + async def validate(self, token: str) -> MagicLink | None: + """Validate a magic link token. + + Returns the MagicLink if it exists, is not used, and has not expired. + Returns None otherwise. + """ + link = await self._repo.get_by_token(token) + if link is None or link.used: + return None + if link.expires_at < datetime.now(UTC): + return None + return link + + async def mark_used(self, token: str) -> bool: + """Mark a magic link as used. Returns True if found and marked.""" + return await self._repo.mark_used(token) + + async def cleanup_expired(self) -> int: + """Delete expired unused links. Returns count deleted.""" + return await self._repo.delete_expired() +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_invite/test_service.py -v` +Expected: All PASSED + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/invite/service.py tests/test_invite/__init__.py tests/test_invite/test_service.py +git commit -m "feat: add MagicLinkService with token create/validate/cleanup" +``` + +--- + +### Task 4: Update Design Documents + +**Files:** +- Modify: `docs/plans/2026-02-12-sqlite-repositories-design.md` + +**Step 1: Update the roadmap section** + +In `docs/plans/2026-02-12-sqlite-repositories-design.md`, update the Roadmap section to mark Authentication services as complete: + +Change: +``` +3. **Authentication** (WebAuthn + password) — next phase +``` + +To: +``` +3. ~~Authentication services~~ (done) — PasswordService, WebAuthnService, MagicLinkService +4. **Authentication routes** (login/register endpoints + templates) — next phase +``` + +And renumber the remaining items. + +**Step 2: Commit** + +```bash +git add docs/plans/2026-02-12-sqlite-repositories-design.md +git commit -m "docs: update roadmap to reflect completed auth services" +``` diff --git a/docs/plans/2026-02-13-sqlite-repositories-plan.md b/docs/plans/2026-02-13-sqlite-repositories-plan.md new file mode 100644 index 0000000..e657f7b --- /dev/null +++ b/docs/plans/2026-02-13-sqlite-repositories-plan.md @@ -0,0 +1,1367 @@ +# SQLite Repositories Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Implement SQLite-backed repository classes that satisfy the existing Protocol interfaces, with a migration runner, lifespan integration, and FastAPI dependency injection. + +**Architecture:** Three repository classes (`SQLiteUserRepository`, `SQLiteCredentialRepository`, `SQLiteMagicLinkRepository`) sharing a single `aiosqlite` connection, initialized via FastAPI lifespan. Schema managed by numbered SQL migration files applied at startup. + +**Tech Stack:** aiosqlite, SQLite WAL mode, pytest with in-memory SQLite + +**Quality gate:** `./scripts/check.sh` (ruff format, ruff check, ty check, pytest) + +--- + +### Task 1: SQL Migration File + +**Files:** +- Create: `src/fastapi_oidc_op/store/sqlite/migrations/001_initial.sql` + +**Step 1: Create the migration file** + +```sql +CREATE TABLE users ( + userid TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + preferred_username TEXT, + given_name TEXT, + family_name TEXT, + nickname TEXT, + email TEXT, + email_verified INTEGER NOT NULL DEFAULT 0, + phone_number TEXT, + phone_number_verified INTEGER NOT NULL DEFAULT 0, + picture TEXT, + locale TEXT, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE user_groups ( + userid TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, + group_name TEXT NOT NULL, + PRIMARY KEY (userid, group_name) +); + +CREATE TABLE webauthn_credentials ( + user_id TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, + credential_id BLOB NOT NULL, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL DEFAULT 0, + device_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, credential_id) +); + +CREATE TABLE password_credentials ( + user_id TEXT PRIMARY KEY REFERENCES users(userid) ON DELETE CASCADE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE magic_links ( + token TEXT PRIMARY KEY, + username TEXT NOT NULL, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_by TEXT, + note TEXT +); +``` + +**Step 2: Commit** + +```bash +git add src/fastapi_oidc_op/store/sqlite/migrations/001_initial.sql +git commit -m "feat: add initial SQLite migration schema" +``` + +--- + +### Task 2: Migration Runner + +**Files:** +- Create: `src/fastapi_oidc_op/store/sqlite/migrations.py` +- Test: `tests/test_store/test_migrations.py` + +**Step 1: Write the failing tests** + +```python +# tests/test_store/test_migrations.py +from pathlib import Path + +import aiosqlite + +from fastapi_oidc_op.store.sqlite.migrations import run_migrations + +MIGRATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "fastapi_oidc_op" / "store" / "sqlite" / "migrations" + + +async def test_run_migrations_applies_initial() -> None: + async with aiosqlite.connect(":memory:") as db: + await db.execute("PRAGMA foreign_keys=ON") + count = await run_migrations(db, MIGRATIONS_DIR) + assert count == 1 + # Verify users table exists + async with db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'") as cursor: + row = await cursor.fetchone() + assert row is not None + + +async def test_run_migrations_skips_already_applied() -> None: + async with aiosqlite.connect(":memory:") as db: + await db.execute("PRAGMA foreign_keys=ON") + first_count = await run_migrations(db, MIGRATIONS_DIR) + second_count = await run_migrations(db, MIGRATIONS_DIR) + assert first_count == 1 + assert second_count == 0 + + +async def test_run_migrations_creates_all_tables() -> None: + async with aiosqlite.connect(":memory:") as db: + await db.execute("PRAGMA foreign_keys=ON") + await run_migrations(db, MIGRATIONS_DIR) + async with db.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") as cursor: + tables = [row[0] async for row in cursor] + assert "users" in tables + assert "user_groups" in tables + assert "webauthn_credentials" in tables + assert "password_credentials" in tables + assert "magic_links" in tables + assert "_migrations" in tables +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_store/test_migrations.py -v` +Expected: FAIL — cannot import `run_migrations` + +**Step 3: Write the migration runner** + +```python +# src/fastapi_oidc_op/store/sqlite/migrations.py +from pathlib import Path + +import aiosqlite + + +async def run_migrations(db: aiosqlite.Connection, migrations_dir: Path) -> int: + """Apply unapplied SQL migration files in order. Returns count of newly applied migrations.""" + await db.execute( + """ + CREATE TABLE IF NOT EXISTS _migrations ( + id INTEGER PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """ + ) + await db.commit() + + # Get already-applied migrations + async with db.execute("SELECT filename FROM _migrations") as cursor: + applied = {row[0] async for row in cursor} + + # Find and sort migration files + migration_files = sorted(migrations_dir.glob("*.sql")) + + count = 0 + for migration_file in migration_files: + if migration_file.name in applied: + continue + sql = migration_file.read_text() + await db.executescript(sql) + await db.execute("INSERT INTO _migrations (filename) VALUES (?)", (migration_file.name,)) + await db.commit() + count += 1 + + return count +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_store/test_migrations.py -v` +Expected: 3 PASSED + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/store/sqlite/migrations.py tests/test_store/test_migrations.py +git commit -m "feat: add SQLite migration runner" +``` + +--- + +### Task 3: Domain Exception and Store Exports + +**Files:** +- Create: `src/fastapi_oidc_op/store/exceptions.py` +- Test: `tests/test_store/test_exceptions.py` + +**Step 1: Write the failing test** + +```python +# tests/test_store/test_exceptions.py +from fastapi_oidc_op.store.exceptions import DuplicateError + + +def test_duplicate_error_is_exception() -> None: + error = DuplicateError("user already exists") + assert isinstance(error, Exception) + assert str(error) == "user already exists" +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_store/test_exceptions.py -v` +Expected: FAIL — cannot import `DuplicateError` + +**Step 3: Write the exception** + +```python +# src/fastapi_oidc_op/store/exceptions.py +class DuplicateError(Exception): + """Raised when a create operation violates a uniqueness constraint.""" +``` + +**Step 4: Run test to verify it passes** + +Run: `pytest tests/test_store/test_exceptions.py -v` +Expected: 1 PASSED + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/store/exceptions.py tests/test_store/test_exceptions.py +git commit -m "feat: add DuplicateError domain exception" +``` + +--- + +### Task 4: SQLiteUserRepository + +**Files:** +- Create: `src/fastapi_oidc_op/store/sqlite/repositories.py` +- Create: `tests/test_store/conftest.py` (shared fixtures) +- Create: `tests/test_store/test_sqlite_user_repo.py` + +**Step 1: Write shared test fixtures** + +```python +# tests/test_store/conftest.py +from pathlib import Path + +import aiosqlite +import pytest + +from fastapi_oidc_op.store.sqlite.migrations import run_migrations + +MIGRATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "fastapi_oidc_op" / "store" / "sqlite" / "migrations" + + +@pytest.fixture +async def db(): + conn = await aiosqlite.connect(":memory:") + conn.row_factory = aiosqlite.Row + await conn.execute("PRAGMA foreign_keys=ON") + await run_migrations(conn, MIGRATIONS_DIR) + yield conn + await conn.close() +``` + +**Step 2: Write the failing tests** + +```python +# tests/test_store/test_sqlite_user_repo.py +import aiosqlite +import pytest + +from fastapi_oidc_op.models import User +from fastapi_oidc_op.store.exceptions import DuplicateError +from fastapi_oidc_op.store.protocols import UserRepository +from fastapi_oidc_op.store.sqlite.repositories import SQLiteUserRepository + + +@pytest.fixture +def user_repo(db: aiosqlite.Connection) -> SQLiteUserRepository: + return SQLiteUserRepository(db) + + +def _make_user(**overrides) -> User: + defaults = {"userid": "lusab-bansen", "username": "alice"} + defaults.update(overrides) + return User(**defaults) + + +async def test_implements_protocol(user_repo: SQLiteUserRepository) -> None: + assert isinstance(user_repo, UserRepository) + + +async def test_create_and_get_by_userid(user_repo: SQLiteUserRepository) -> None: + user = _make_user() + created = await user_repo.create(user) + assert created.userid == "lusab-bansen" + assert created.username == "alice" + + fetched = await user_repo.get_by_userid("lusab-bansen") + assert fetched is not None + assert fetched.userid == "lusab-bansen" + assert fetched.username == "alice" + + +async def test_get_by_username(user_repo: SQLiteUserRepository) -> None: + user = _make_user() + await user_repo.create(user) + + fetched = await user_repo.get_by_username("alice") + assert fetched is not None + assert fetched.username == "alice" + + +async def test_get_by_userid_not_found(user_repo: SQLiteUserRepository) -> None: + result = await user_repo.get_by_userid("nonexistent") + assert result is None + + +async def test_get_by_username_not_found(user_repo: SQLiteUserRepository) -> None: + result = await user_repo.get_by_username("nonexistent") + assert result is None + + +async def test_create_with_groups(user_repo: SQLiteUserRepository) -> None: + user = _make_user(groups=["admin", "users"]) + await user_repo.create(user) + + fetched = await user_repo.get_by_userid("lusab-bansen") + assert fetched is not None + assert sorted(fetched.groups) == ["admin", "users"] + + +async def test_update(user_repo: SQLiteUserRepository) -> None: + user = _make_user() + await user_repo.create(user) + + user.email = "alice@example.com" + user.given_name = "Alice" + updated = await user_repo.update(user) + assert updated.email == "alice@example.com" + assert updated.given_name == "Alice" + + fetched = await user_repo.get_by_userid("lusab-bansen") + assert fetched is not None + assert fetched.email == "alice@example.com" + + +async def test_update_groups(user_repo: SQLiteUserRepository) -> None: + user = _make_user(groups=["users"]) + await user_repo.create(user) + + user.groups = ["admin", "editors"] + await user_repo.update(user) + + fetched = await user_repo.get_by_userid("lusab-bansen") + assert fetched is not None + assert sorted(fetched.groups) == ["admin", "editors"] + + +async def test_list_users(user_repo: SQLiteUserRepository) -> None: + await user_repo.create(_make_user(userid="id-1", username="alice")) + await user_repo.create(_make_user(userid="id-2", username="bob")) + await user_repo.create(_make_user(userid="id-3", username="charlie")) + + users = await user_repo.list_users() + assert len(users) == 3 + + +async def test_list_users_pagination(user_repo: SQLiteUserRepository) -> None: + for i in range(5): + await user_repo.create(_make_user(userid=f"id-{i}", username=f"user-{i}")) + + page1 = await user_repo.list_users(offset=0, limit=2) + page2 = await user_repo.list_users(offset=2, limit=2) + page3 = await user_repo.list_users(offset=4, limit=2) + assert len(page1) == 2 + assert len(page2) == 2 + assert len(page3) == 1 + + +async def test_delete(user_repo: SQLiteUserRepository) -> None: + user = _make_user() + await user_repo.create(user) + + deleted = await user_repo.delete("lusab-bansen") + assert deleted is True + + fetched = await user_repo.get_by_userid("lusab-bansen") + assert fetched is None + + +async def test_delete_not_found(user_repo: SQLiteUserRepository) -> None: + deleted = await user_repo.delete("nonexistent") + assert deleted is False + + +async def test_delete_cascades_groups(user_repo: SQLiteUserRepository) -> None: + user = _make_user(groups=["admin"]) + await user_repo.create(user) + + await user_repo.delete("lusab-bansen") + + # Verify groups were cascaded + async with user_repo._db.execute("SELECT COUNT(*) FROM user_groups WHERE userid = ?", ("lusab-bansen",)) as cursor: + row = await cursor.fetchone() + assert row[0] == 0 + + +async def test_create_duplicate_username(user_repo: SQLiteUserRepository) -> None: + await user_repo.create(_make_user()) + + with pytest.raises(DuplicateError): + await user_repo.create(_make_user(userid="different-id", username="alice")) + + +async def test_roundtrip_preserves_all_fields(user_repo: SQLiteUserRepository) -> None: + user = _make_user( + preferred_username="ally", + given_name="Alice", + family_name="Smith", + nickname="Al", + email="alice@example.com", + email_verified=True, + phone_number="+1234567890", + phone_number_verified=True, + picture="https://example.com/alice.jpg", + locale="en-US", + active=False, + groups=["admin", "users"], + ) + await user_repo.create(user) + + fetched = await user_repo.get_by_userid("lusab-bansen") + assert fetched is not None + assert fetched.preferred_username == "ally" + assert fetched.given_name == "Alice" + assert fetched.family_name == "Smith" + assert fetched.nickname == "Al" + assert fetched.email == "alice@example.com" + assert fetched.email_verified is True + assert fetched.phone_number == "+1234567890" + assert fetched.phone_number_verified is True + assert fetched.picture == "https://example.com/alice.jpg" + assert fetched.locale == "en-US" + assert fetched.active is False + assert sorted(fetched.groups) == ["admin", "users"] + assert fetched.created_at == user.created_at + assert fetched.updated_at == user.updated_at +``` + +**Step 3: Run tests to verify they fail** + +Run: `pytest tests/test_store/test_sqlite_user_repo.py -v` +Expected: FAIL — cannot import `SQLiteUserRepository` + +**Step 4: Write the implementation** + +```python +# src/fastapi_oidc_op/store/sqlite/repositories.py +from datetime import UTC, datetime + +import aiosqlite + +from fastapi_oidc_op.models import ( + MagicLink, + PasswordCredential, + User, + WebAuthnCredential, +) +from fastapi_oidc_op.store.exceptions import DuplicateError + + +class SQLiteUserRepository: + def __init__(self, db: aiosqlite.Connection) -> None: + self._db = db + + async def _get_groups(self, userid: str) -> list[str]: + async with self._db.execute( + "SELECT group_name FROM user_groups WHERE userid = ? ORDER BY group_name", (userid,) + ) as cursor: + return [row[0] async for row in cursor] + + async def _set_groups(self, userid: str, groups: list[str]) -> None: + await self._db.execute("DELETE FROM user_groups WHERE userid = ?", (userid,)) + for group in groups: + await self._db.execute("INSERT INTO user_groups (userid, group_name) VALUES (?, ?)", (userid, group)) + + def _row_to_user(self, row: aiosqlite.Row, groups: list[str]) -> User: + return User( + userid=row["userid"], + username=row["username"], + preferred_username=row["preferred_username"], + given_name=row["given_name"], + family_name=row["family_name"], + nickname=row["nickname"], + email=row["email"], + email_verified=bool(row["email_verified"]), + phone_number=row["phone_number"], + phone_number_verified=bool(row["phone_number_verified"]), + picture=row["picture"], + locale=row["locale"], + active=bool(row["active"]), + created_at=datetime.fromisoformat(row["created_at"]), + updated_at=datetime.fromisoformat(row["updated_at"]), + groups=groups, + ) + + async def create(self, user: User) -> User: + try: + await self._db.execute( + """ + INSERT INTO users ( + userid, username, preferred_username, given_name, family_name, + nickname, email, email_verified, phone_number, phone_number_verified, + picture, locale, active, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + user.userid, + user.username, + user.preferred_username, + user.given_name, + user.family_name, + user.nickname, + user.email, + int(user.email_verified), + user.phone_number, + int(user.phone_number_verified), + user.picture, + user.locale, + int(user.active), + user.created_at.isoformat(), + user.updated_at.isoformat(), + ), + ) + await self._set_groups(user.userid, user.groups) + await self._db.commit() + except aiosqlite.IntegrityError as e: + raise DuplicateError(str(e)) from e + return user + + async def get_by_userid(self, userid: str) -> User | None: + async with self._db.execute("SELECT * FROM users WHERE userid = ?", (userid,)) as cursor: + row = await cursor.fetchone() + if row is None: + return None + groups = await self._get_groups(userid) + return self._row_to_user(row, groups) + + async def get_by_username(self, username: str) -> User | None: + async with self._db.execute("SELECT * FROM users WHERE username = ?", (username,)) as cursor: + row = await cursor.fetchone() + if row is None: + return None + groups = await self._get_groups(row["userid"]) + return self._row_to_user(row, groups) + + async def update(self, user: User) -> User: + user.updated_at = datetime.now(UTC) + await self._db.execute( + """ + UPDATE users SET + username = ?, preferred_username = ?, given_name = ?, family_name = ?, + nickname = ?, email = ?, email_verified = ?, phone_number = ?, + phone_number_verified = ?, picture = ?, locale = ?, active = ?, + updated_at = ? + WHERE userid = ? + """, + ( + user.username, + user.preferred_username, + user.given_name, + user.family_name, + user.nickname, + user.email, + int(user.email_verified), + user.phone_number, + int(user.phone_number_verified), + user.picture, + user.locale, + int(user.active), + user.updated_at.isoformat(), + user.userid, + ), + ) + await self._set_groups(user.userid, user.groups) + await self._db.commit() + return user + + async def list_users(self, offset: int = 0, limit: int = 100) -> list[User]: + async with self._db.execute( + "SELECT * FROM users ORDER BY username LIMIT ? OFFSET ?", (limit, offset) + ) as cursor: + rows = await cursor.fetchall() + users = [] + for row in rows: + groups = await self._get_groups(row["userid"]) + users.append(self._row_to_user(row, groups)) + return users + + async def delete(self, userid: str) -> bool: + cursor = await self._db.execute("DELETE FROM users WHERE userid = ?", (userid,)) + await self._db.commit() + return cursor.rowcount > 0 +``` + +**Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_store/test_sqlite_user_repo.py -v` +Expected: All PASSED + +**Step 6: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 7: Commit** + +```bash +git add src/fastapi_oidc_op/store/sqlite/repositories.py tests/test_store/conftest.py tests/test_store/test_sqlite_user_repo.py +git commit -m "feat: add SQLiteUserRepository with tests" +``` + +--- + +### Task 5: SQLiteCredentialRepository + +**Files:** +- Modify: `src/fastapi_oidc_op/store/sqlite/repositories.py` +- Create: `tests/test_store/test_sqlite_credential_repo.py` + +**Step 1: Write the failing tests** + +```python +# tests/test_store/test_sqlite_credential_repo.py +import aiosqlite +import pytest + +from fastapi_oidc_op.models import PasswordCredential, User, WebAuthnCredential +from fastapi_oidc_op.store.exceptions import DuplicateError +from fastapi_oidc_op.store.protocols import CredentialRepository +from fastapi_oidc_op.store.sqlite.repositories import ( + SQLiteCredentialRepository, + SQLiteUserRepository, +) + + +@pytest.fixture +def user_repo(db: aiosqlite.Connection) -> SQLiteUserRepository: + return SQLiteUserRepository(db) + + +@pytest.fixture +def credential_repo(db: aiosqlite.Connection) -> SQLiteCredentialRepository: + return SQLiteCredentialRepository(db) + + +@pytest.fixture +async def alice(user_repo: SQLiteUserRepository) -> User: + return await user_repo.create(User(userid="lusab-bansen", username="alice")) + + +async def test_implements_protocol(credential_repo: SQLiteCredentialRepository) -> None: + assert isinstance(credential_repo, CredentialRepository) + + +async def test_create_and_get_webauthn(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + cred = WebAuthnCredential( + user_id=alice.userid, + credential_id=b"\x01\x02\x03", + public_key=b"\x04\x05\x06", + device_name="YubiKey", + ) + created = await credential_repo.create_webauthn(cred) + assert created.user_id == alice.userid + + creds = await credential_repo.get_webauthn_by_user(alice.userid) + assert len(creds) == 1 + assert creds[0].credential_id == b"\x01\x02\x03" + assert creds[0].public_key == b"\x04\x05\x06" + assert creds[0].device_name == "YubiKey" + + +async def test_get_webauthn_by_credential_id(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + cred = WebAuthnCredential( + user_id=alice.userid, + credential_id=b"\x01\x02\x03", + public_key=b"\x04\x05\x06", + ) + await credential_repo.create_webauthn(cred) + + fetched = await credential_repo.get_webauthn_by_credential_id(b"\x01\x02\x03") + assert fetched is not None + assert fetched.user_id == alice.userid + + +async def test_get_webauthn_by_credential_id_not_found(credential_repo: SQLiteCredentialRepository) -> None: + result = await credential_repo.get_webauthn_by_credential_id(b"\xff\xff") + assert result is None + + +async def test_multiple_webauthn_per_user(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + for i in range(3): + cred = WebAuthnCredential( + user_id=alice.userid, + credential_id=bytes([i]), + public_key=b"\x00", + device_name=f"Key {i}", + ) + await credential_repo.create_webauthn(cred) + + creds = await credential_repo.get_webauthn_by_user(alice.userid) + assert len(creds) == 3 + + +async def test_update_webauthn(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + cred = WebAuthnCredential( + user_id=alice.userid, + credential_id=b"\x01\x02\x03", + public_key=b"\x04\x05\x06", + sign_count=0, + device_name="Old Name", + ) + await credential_repo.create_webauthn(cred) + + cred.sign_count = 42 + cred.device_name = "New Name" + updated = await credential_repo.update_webauthn(cred) + assert updated.sign_count == 42 + assert updated.device_name == "New Name" + + fetched = await credential_repo.get_webauthn_by_credential_id(b"\x01\x02\x03") + assert fetched is not None + assert fetched.sign_count == 42 + assert fetched.device_name == "New Name" + + +async def test_delete_webauthn(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + cred = WebAuthnCredential( + user_id=alice.userid, + credential_id=b"\x01\x02\x03", + public_key=b"\x04\x05\x06", + ) + await credential_repo.create_webauthn(cred) + + deleted = await credential_repo.delete_webauthn(alice.userid, b"\x01\x02\x03") + assert deleted is True + + creds = await credential_repo.get_webauthn_by_user(alice.userid) + assert len(creds) == 0 + + +async def test_delete_webauthn_not_found(credential_repo: SQLiteCredentialRepository) -> None: + deleted = await credential_repo.delete_webauthn("nobody", b"\xff") + assert deleted is False + + +async def test_create_and_get_password(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + cred = PasswordCredential( + user_id=alice.userid, + password_hash="$argon2id$v=19$m=65536,t=3,p=4$hash", + ) + created = await credential_repo.create_password(cred) + assert created.user_id == alice.userid + + fetched = await credential_repo.get_password_by_user(alice.userid) + assert fetched is not None + assert fetched.password_hash == "$argon2id$v=19$m=65536,t=3,p=4$hash" + + +async def test_get_password_not_found(credential_repo: SQLiteCredentialRepository) -> None: + result = await credential_repo.get_password_by_user("nobody") + assert result is None + + +async def test_delete_password(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + cred = PasswordCredential( + user_id=alice.userid, + password_hash="$argon2id$v=19$hash", + ) + await credential_repo.create_password(cred) + + deleted = await credential_repo.delete_password(alice.userid) + assert deleted is True + + fetched = await credential_repo.get_password_by_user(alice.userid) + assert fetched is None + + +async def test_delete_password_not_found(credential_repo: SQLiteCredentialRepository) -> None: + deleted = await credential_repo.delete_password("nobody") + assert deleted is False + + +async def test_create_duplicate_password(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + cred = PasswordCredential(user_id=alice.userid, password_hash="hash1") + await credential_repo.create_password(cred) + + with pytest.raises(DuplicateError): + cred2 = PasswordCredential(user_id=alice.userid, password_hash="hash2") + await credential_repo.create_password(cred2) + + +async def test_create_duplicate_webauthn(credential_repo: SQLiteCredentialRepository, alice: User) -> None: + cred = WebAuthnCredential(user_id=alice.userid, credential_id=b"\x01", public_key=b"\x02") + await credential_repo.create_webauthn(cred) + + with pytest.raises(DuplicateError): + await credential_repo.create_webauthn(cred) +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_store/test_sqlite_credential_repo.py -v` +Expected: FAIL — cannot import `SQLiteCredentialRepository` + +**Step 3: Add SQLiteCredentialRepository to `repositories.py`** + +Append to `src/fastapi_oidc_op/store/sqlite/repositories.py`: + +```python +class SQLiteCredentialRepository: + def __init__(self, db: aiosqlite.Connection) -> None: + self._db = db + + def _row_to_webauthn(self, row: aiosqlite.Row) -> WebAuthnCredential: + return WebAuthnCredential( + user_id=row["user_id"], + credential_id=bytes(row["credential_id"]), + public_key=bytes(row["public_key"]), + sign_count=row["sign_count"], + device_name=row["device_name"], + created_at=datetime.fromisoformat(row["created_at"]), + ) + + def _row_to_password(self, row: aiosqlite.Row) -> PasswordCredential: + return PasswordCredential( + user_id=row["user_id"], + password_hash=row["password_hash"], + created_at=datetime.fromisoformat(row["created_at"]), + ) + + async def create_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: + try: + await self._db.execute( + """ + INSERT INTO webauthn_credentials (user_id, credential_id, public_key, sign_count, device_name, created_at) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + credential.user_id, + credential.credential_id, + credential.public_key, + credential.sign_count, + credential.device_name, + credential.created_at.isoformat(), + ), + ) + await self._db.commit() + except aiosqlite.IntegrityError as e: + raise DuplicateError(str(e)) from e + return credential + + async def create_password(self, credential: PasswordCredential) -> PasswordCredential: + try: + await self._db.execute( + "INSERT INTO password_credentials (user_id, password_hash, created_at) VALUES (?, ?, ?)", + (credential.user_id, credential.password_hash, credential.created_at.isoformat()), + ) + await self._db.commit() + except aiosqlite.IntegrityError as e: + raise DuplicateError(str(e)) from e + return credential + + async def get_webauthn_by_user(self, user_id: str) -> list[WebAuthnCredential]: + async with self._db.execute( + "SELECT * FROM webauthn_credentials WHERE user_id = ?", (user_id,) + ) as cursor: + rows = await cursor.fetchall() + return [self._row_to_webauthn(row) for row in rows] + + async def get_webauthn_by_credential_id(self, credential_id: bytes) -> WebAuthnCredential | None: + async with self._db.execute( + "SELECT * FROM webauthn_credentials WHERE credential_id = ?", (credential_id,) + ) as cursor: + row = await cursor.fetchone() + if row is None: + return None + return self._row_to_webauthn(row) + + async def get_password_by_user(self, user_id: str) -> PasswordCredential | None: + async with self._db.execute( + "SELECT * FROM password_credentials WHERE user_id = ?", (user_id,) + ) as cursor: + row = await cursor.fetchone() + if row is None: + return None + return self._row_to_password(row) + + async def update_webauthn(self, credential: WebAuthnCredential) -> WebAuthnCredential: + await self._db.execute( + "UPDATE webauthn_credentials SET sign_count = ?, device_name = ? WHERE user_id = ? AND credential_id = ?", + (credential.sign_count, credential.device_name, credential.user_id, credential.credential_id), + ) + await self._db.commit() + return credential + + async def delete_webauthn(self, user_id: str, credential_id: bytes) -> bool: + cursor = await self._db.execute( + "DELETE FROM webauthn_credentials WHERE user_id = ? AND credential_id = ?", + (user_id, credential_id), + ) + await self._db.commit() + return cursor.rowcount > 0 + + async def delete_password(self, user_id: str) -> bool: + cursor = await self._db.execute( + "DELETE FROM password_credentials WHERE user_id = ?", (user_id,) + ) + await self._db.commit() + return cursor.rowcount > 0 +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_store/test_sqlite_credential_repo.py -v` +Expected: All PASSED + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/store/sqlite/repositories.py tests/test_store/test_sqlite_credential_repo.py +git commit -m "feat: add SQLiteCredentialRepository with tests" +``` + +--- + +### Task 6: SQLiteMagicLinkRepository + +**Files:** +- Modify: `src/fastapi_oidc_op/store/sqlite/repositories.py` +- Create: `tests/test_store/test_sqlite_magic_link_repo.py` + +**Step 1: Write the failing tests** + +```python +# tests/test_store/test_sqlite_magic_link_repo.py +from datetime import UTC, datetime, timedelta + +import aiosqlite +import pytest + +from fastapi_oidc_op.models import MagicLink +from fastapi_oidc_op.store.exceptions import DuplicateError +from fastapi_oidc_op.store.protocols import MagicLinkRepository +from fastapi_oidc_op.store.sqlite.repositories import SQLiteMagicLinkRepository + + +@pytest.fixture +def magic_link_repo(db: aiosqlite.Connection) -> SQLiteMagicLinkRepository: + return SQLiteMagicLinkRepository(db) + + +def _make_link(**overrides) -> MagicLink: + defaults = { + "token": "abc123", + "username": "alice", + "expires_at": datetime.now(UTC) + timedelta(hours=24), + } + defaults.update(overrides) + return MagicLink(**defaults) + + +async def test_implements_protocol(magic_link_repo: SQLiteMagicLinkRepository) -> None: + assert isinstance(magic_link_repo, MagicLinkRepository) + + +async def test_create_and_get_by_token(magic_link_repo: SQLiteMagicLinkRepository) -> None: + link = _make_link() + created = await magic_link_repo.create(link) + assert created.token == "abc123" + + fetched = await magic_link_repo.get_by_token("abc123") + assert fetched is not None + assert fetched.token == "abc123" + assert fetched.username == "alice" + assert fetched.used is False + + +async def test_get_by_token_not_found(magic_link_repo: SQLiteMagicLinkRepository) -> None: + result = await magic_link_repo.get_by_token("nonexistent") + assert result is None + + +async def test_mark_used(magic_link_repo: SQLiteMagicLinkRepository) -> None: + link = _make_link() + await magic_link_repo.create(link) + + marked = await magic_link_repo.mark_used("abc123") + assert marked is True + + fetched = await magic_link_repo.get_by_token("abc123") + assert fetched is not None + assert fetched.used is True + + +async def test_mark_used_not_found(magic_link_repo: SQLiteMagicLinkRepository) -> None: + marked = await magic_link_repo.mark_used("nonexistent") + assert marked is False + + +async def test_delete_expired(magic_link_repo: SQLiteMagicLinkRepository) -> None: + # Create an expired link + expired = _make_link(token="expired", expires_at=datetime.now(UTC) - timedelta(hours=1)) + await magic_link_repo.create(expired) + + # Create a valid link + valid = _make_link(token="valid", expires_at=datetime.now(UTC) + timedelta(hours=24)) + await magic_link_repo.create(valid) + + count = await magic_link_repo.delete_expired() + assert count == 1 + + # Expired should be gone + assert await magic_link_repo.get_by_token("expired") is None + # Valid should remain + assert await magic_link_repo.get_by_token("valid") is not None + + +async def test_delete_expired_skips_used(magic_link_repo: SQLiteMagicLinkRepository) -> None: + # Create an expired but used link + link = _make_link(token="used-expired", expires_at=datetime.now(UTC) - timedelta(hours=1)) + await magic_link_repo.create(link) + await magic_link_repo.mark_used("used-expired") + + count = await magic_link_repo.delete_expired() + assert count == 0 + + +async def test_create_with_optional_fields(magic_link_repo: SQLiteMagicLinkRepository) -> None: + link = _make_link(created_by="admin", note="Welcome aboard") + await magic_link_repo.create(link) + + fetched = await magic_link_repo.get_by_token("abc123") + assert fetched is not None + assert fetched.created_by == "admin" + assert fetched.note == "Welcome aboard" + + +async def test_create_duplicate_token(magic_link_repo: SQLiteMagicLinkRepository) -> None: + await magic_link_repo.create(_make_link()) + + with pytest.raises(DuplicateError): + await magic_link_repo.create(_make_link()) +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_store/test_sqlite_magic_link_repo.py -v` +Expected: FAIL — cannot import `SQLiteMagicLinkRepository` + +**Step 3: Add SQLiteMagicLinkRepository to `repositories.py`** + +Append to `src/fastapi_oidc_op/store/sqlite/repositories.py`: + +```python +class SQLiteMagicLinkRepository: + def __init__(self, db: aiosqlite.Connection) -> None: + self._db = db + + def _row_to_magic_link(self, row: aiosqlite.Row) -> MagicLink: + return MagicLink( + token=row["token"], + username=row["username"], + expires_at=datetime.fromisoformat(row["expires_at"]), + used=bool(row["used"]), + created_by=row["created_by"], + note=row["note"], + ) + + async def create(self, link: MagicLink) -> MagicLink: + try: + await self._db.execute( + "INSERT INTO magic_links (token, username, expires_at, used, created_by, note) VALUES (?, ?, ?, ?, ?, ?)", + ( + link.token, + link.username, + link.expires_at.isoformat(), + int(link.used), + link.created_by, + link.note, + ), + ) + await self._db.commit() + except aiosqlite.IntegrityError as e: + raise DuplicateError(str(e)) from e + return link + + async def get_by_token(self, token: str) -> MagicLink | None: + async with self._db.execute("SELECT * FROM magic_links WHERE token = ?", (token,)) as cursor: + row = await cursor.fetchone() + if row is None: + return None + return self._row_to_magic_link(row) + + async def mark_used(self, token: str) -> bool: + cursor = await self._db.execute( + "UPDATE magic_links SET used = 1 WHERE token = ?", (token,) + ) + await self._db.commit() + return cursor.rowcount > 0 + + async def delete_expired(self) -> int: + now = datetime.now(UTC).isoformat() + cursor = await self._db.execute( + "DELETE FROM magic_links WHERE expires_at < ? AND used = 0", (now,) + ) + await self._db.commit() + return cursor.rowcount +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_store/test_sqlite_magic_link_repo.py -v` +Expected: All PASSED + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/store/sqlite/repositories.py tests/test_store/test_sqlite_magic_link_repo.py +git commit -m "feat: add SQLiteMagicLinkRepository with tests" +``` + +--- + +### Task 7: Lifespan Integration and Dependencies + +**Files:** +- Modify: `src/fastapi_oidc_op/app.py` +- Create: `src/fastapi_oidc_op/dependencies.py` +- Modify: `tests/conftest.py` +- Modify: `tests/test_app.py` + +**Step 1: Write the failing tests** + +Add to `tests/test_app.py`: + +```python +# Add these tests to the existing file + +async def test_app_has_repos_on_state(client: AsyncClient) -> None: + """Repos should be available on app.state after lifespan startup.""" + from fastapi_oidc_op.store.protocols import ( + CredentialRepository, + MagicLinkRepository, + UserRepository, + ) + + app = client._transport.app # type: ignore[union-attr] + assert isinstance(app.state.user_repo, UserRepository) + assert isinstance(app.state.credential_repo, CredentialRepository) + assert isinstance(app.state.magic_link_repo, MagicLinkRepository) + + +async def test_dependency_functions() -> None: + """Dependency functions should return Protocol-typed repos.""" + from unittest.mock import MagicMock + + from fastapi_oidc_op.dependencies import ( + get_credential_repo, + get_magic_link_repo, + get_user_repo, + ) + + request = MagicMock() + request.app.state.user_repo = "user_repo_sentinel" + request.app.state.credential_repo = "credential_repo_sentinel" + request.app.state.magic_link_repo = "magic_link_repo_sentinel" + + assert get_user_repo(request) == "user_repo_sentinel" + assert get_credential_repo(request) == "credential_repo_sentinel" + assert get_magic_link_repo(request) == "magic_link_repo_sentinel" +``` + +Update `tests/conftest.py` so the client fixture uses lifespan (the app needs to run its lifespan to initialize repos). The settings fixture should use `:memory:` for SQLite: + +```python +# tests/conftest.py +from collections.abc import AsyncIterator + +import pytest +from httpx import ASGITransport, AsyncClient + +from fastapi_oidc_op.app import create_app +from fastapi_oidc_op.config import Settings + + +@pytest.fixture +def settings() -> Settings: + return Settings(issuer="http://localhost:8000", sqlite_path=":memory:") + + +@pytest.fixture +async def client(settings: Settings) -> AsyncIterator[AsyncClient]: + app = create_app(settings) + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url=settings.issuer) as ac: + yield ac +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_app.py -v` +Expected: FAIL — app has no lifespan, no repos on state + +**Step 3: Implement the lifespan in `app.py`** + +```python +# src/fastapi_oidc_op/app.py +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import aiosqlite +from fastapi import FastAPI + +from fastapi_oidc_op.config import Settings, StorageBackend +from fastapi_oidc_op.store.sqlite.migrations import run_migrations +from fastapi_oidc_op.store.sqlite.repositories import ( + SQLiteCredentialRepository, + SQLiteMagicLinkRepository, + SQLiteUserRepository, +) + +MIGRATIONS_DIR = Path(__file__).parent / "store" / "sqlite" / "migrations" + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + settings: Settings = app.state.settings + if settings.storage_backend == StorageBackend.SQLITE: + # Ensure parent directory exists (skip for :memory:) + if settings.sqlite_path != ":memory:": + Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True) + db = await aiosqlite.connect(settings.sqlite_path) + db.row_factory = aiosqlite.Row + await db.execute("PRAGMA journal_mode=WAL") + await db.execute("PRAGMA foreign_keys=ON") + await run_migrations(db, MIGRATIONS_DIR) + app.state.user_repo = SQLiteUserRepository(db) + app.state.credential_repo = SQLiteCredentialRepository(db) + app.state.magic_link_repo = SQLiteMagicLinkRepository(db) + yield + await db.close() + else: + raise NotImplementedError("MongoDB backend not yet implemented") + + +def create_app(settings: Settings | None = None) -> FastAPI: + if settings is None: + settings = Settings() # type: ignore[call-arg] + + app = FastAPI( + title="FastAPI OIDC OP", + version="0.1.0", + docs_url="/docs" if settings.debug else None, + redoc_url=None, + lifespan=lifespan, + ) + + app.state.settings = settings + + @app.get("/health") + async def health() -> dict[str, str]: + return {"status": "ok"} + + return app +``` + +**Step 4: Implement `dependencies.py`** + +```python +# src/fastapi_oidc_op/dependencies.py +from fastapi import Request + +from fastapi_oidc_op.store.protocols import ( + CredentialRepository, + MagicLinkRepository, + UserRepository, +) + + +def get_user_repo(request: Request) -> UserRepository: + return request.app.state.user_repo + + +def get_credential_repo(request: Request) -> CredentialRepository: + return request.app.state.credential_repo + + +def get_magic_link_repo(request: Request) -> MagicLinkRepository: + return request.app.state.magic_link_repo +``` + +**Step 5: Run tests to verify they pass** + +Run: `pytest tests/test_app.py -v` +Expected: All PASSED + +**Step 6: Run full quality gate** + +Run: `./scripts/check.sh` +Expected: All green (all existing tests still pass with the updated conftest) + +**Step 7: Commit** + +```bash +git add src/fastapi_oidc_op/app.py src/fastapi_oidc_op/dependencies.py tests/conftest.py tests/test_app.py +git commit -m "feat: add lifespan integration and dependency injection" +``` + +--- + +### Task 8: Update Design Document + +**Files:** +- Modify: `docs/plans/2026-02-12-sqlite-repositories-design.md` + +**Step 1: Update the schema status and next steps** + +Change the schema status line from: +> **Status:** Schema section was presented to user and NOT YET explicitly approved. User requested shutdown before responding. **Ask for confirmation before proceeding.** + +To: +> **Status:** Schema approved. Implementation complete. + +Update the "Next Steps" section to reflect completion and point to the next phase (Authentication). + +**Step 2: Commit** + +```bash +git add docs/plans/2026-02-12-sqlite-repositories-design.md +git commit -m "docs: update sqlite design doc to reflect completed implementation" +``` diff --git a/docs/plans/2026-02-16-auth-routes-plan.md b/docs/plans/2026-02-16-auth-routes-plan.md new file mode 100644 index 0000000..23d1b79 --- /dev/null +++ b/docs/plans/2026-02-16-auth-routes-plan.md @@ -0,0 +1,1286 @@ +# Authentication Routes Implementation Plan (Phase 4) + +> **Status: COMPLETE** — All 10 tasks implemented and passing. 120 tests, full quality gate green. + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add standalone login/registration + credential management routes (no OIDC yet) using sessions, Jinja2 templates, HTMX, and the existing auth services + repositories. + +**Architecture:** FastAPI routers for `authn` and `manage` are mounted in `create_app()`. Starlette `SessionMiddleware` stores a minimal session (`userid`, `username`) plus WebAuthn transient state. HTML is server-rendered with Jinja2; HTMX progressively enhances forms; `webauthn.js` bridges browser WebAuthn APIs. Auth services (`PasswordService`, `WebAuthnService`, `MagicLinkService`) are instantiated in the lifespan and stored on `app.state` alongside the repositories. + +**Tech Stack:** FastAPI, Starlette SessionMiddleware, Jinja2 templates, HTMX (vendored), python-fido2 >=2.1, argon2-cffi, aiosqlite. + +**Quality gate:** `./scripts/check.sh` + +**Known constraints:** +- Starlette `SessionMiddleware` uses signed cookies (~4KB limit). WebAuthn challenge state is small (~100 bytes), so this is fine. If state grows, switch to server-side session storage. +- The `fido2` library's `Fido2Server.authenticate_complete()` returns `AttestedCredentialData` (matched credential), NOT the new sign count. The sign count must be extracted from the raw `AuthenticationResponse.response.authenticator_data.counter`. +- WebAuthn options returned by `fido2` contain `bytes` fields (`challenge`, `user.id`, credential IDs). The library provides `fido2.utils.websafe_encode`/`websafe_decode` for base64url conversion. The `dict(options)` output from `begin_registration`/`begin_authentication` is already JSON-serializable as the library handles encoding internally via its CBOR/JSON mapping. + +**Discoveries during implementation:** +- `itsdangerous` package was needed for Starlette's `SessionMiddleware` — added via `uv add itsdangerous` +- `ty` type checker flags `app.add_middleware(SessionMiddleware, ...)` as invalid argument type — needs `# type: ignore[arg-type]` +- The `_count_credentials` helper in `manage/routes.py` needs `# type: ignore[union-attr]` on the cred_repo calls since it takes `object` type +- Magic link service is at `fastapi_oidc_op.invite.service.MagicLinkService` (not `authn/magic_link.py`) +- The `PasswordService.verify()` takes `(password_hash, password)` — hash first, then plaintext +- `AttestedCredentialData` is a `bytes` subclass — reconstruct from stored bytes via `AttestedCredentialData(stored_bytes)`, not `from_ctap_object()` + +--- + +### Task 1: Config + App Wiring + Templates + Static Files [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/config.py` +- Modify: `src/fastapi_oidc_op/app.py` +- Create: `src/fastapi_oidc_op/authn/routes.py` +- Create: `src/fastapi_oidc_op/manage/routes.py` +- Create: `src/fastapi_oidc_op/templates/base.html` +- Create: `src/fastapi_oidc_op/templates/login.html` +- Create: `src/fastapi_oidc_op/static/style.css` +- Create: `src/fastapi_oidc_op/static/htmx.min.js` +- Create: `tests/test_auth_routes/__init__.py` +- Create: `tests/test_auth_routes/test_pages.py` + +**Why merged:** The original plan had Tasks 1 and 2 as separate steps, but Task 1's tests could never pass without Task 2's templates and static files. Merging them gives a clean red-green cycle. + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/__init__.py` (empty file). + +Create `tests/test_auth_routes/test_pages.py`: + +```python +from httpx import AsyncClient + + +async def test_get_login_page_contains_form(client: AsyncClient) -> None: + res = await client.get("/login") + assert res.status_code == 200 + assert " None: + res = await client.get("/login") + assert "Skip to content" in res.text + + +async def test_static_css_served(client: AsyncClient) -> None: + res = await client.get("/static/style.css") + assert res.status_code == 200 + assert "--bg" in res.text +``` + +Note: These tests use the `client` fixture from `tests/conftest.py`. Once Task 1 adds `SessionMiddleware` to `create_app()`, the existing fixture automatically picks it up. + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_pages.py -v` +Expected: FAIL (404 for `/login` and `/static/*`) + +**Step 3: Implement config, wiring, templates, and static files** + +In `src/fastapi_oidc_op/config.py`, add a `session_secret` field: + +```python +# Session +session_secret: str | None = None # If None, a random secret is generated per process +``` + +In `src/fastapi_oidc_op/app.py`: +- Add `SessionMiddleware` using `settings.session_secret` if set, otherwise `secrets.token_hex(32)`. +- Mount `Jinja2Templates` pointing to `templates/` dir (relative to package). +- Mount `StaticFiles` at `/static` pointing to `static/` dir (relative to package). +- Store the `Jinja2Templates` instance on `app.state.templates` for use by routes. +- Include routers from `fastapi_oidc_op.authn.routes` and `fastapi_oidc_op.manage.routes`. +- In the lifespan, instantiate and store auth services: + - `app.state.password_service = PasswordService()` + - `app.state.webauthn_service = WebAuthnService(rp_id=, rp_name=app.title, origin=settings.issuer)` + - `app.state.magic_link_service = MagicLinkService(repo=app.state.magic_link_repo, ttl=settings.invite_ttl)` + + For `rp_id`, extract the hostname from `settings.issuer` using `urllib.parse.urlparse(settings.issuer).hostname`. + +Create empty routers in: +- `src/fastapi_oidc_op/authn/routes.py` — with a `GET /login` route that renders `login.html` +- `src/fastapi_oidc_op/manage/routes.py` — empty router with `prefix="/manage"` + +Create `src/fastapi_oidc_op/templates/base.html`: +- `` +- `
{% block content %}{% endblock %}
` +- `
` +- `` +- `` +- `{% block scripts %}{% endblock %}` for page-specific JS + +Create `src/fastapi_oidc_op/templates/login.html`: +- Extends `base.html` +- Password form with `username` and `password` fields +- WebAuthn sign-in section (button, will be wired in later tasks) +- Error display area with `id="login-error"` for HTMX fragment swaps + +Create `src/fastapi_oidc_op/static/style.css`: +- CSS custom properties for palette (`--bg`, `--fg`, `--accent`, etc.) +- `:focus-visible` outline styles +- `@media (prefers-reduced-motion: reduce)` handling +- `.sr-only` utility class + +Create `src/fastapi_oidc_op/static/htmx.min.js`: +- Download the official HTMX minified release (v2.x) and commit it. + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_pages.py -v` +Expected: PASS + +**Step 5: Run full quality gate** + +Run: `./scripts/check.sh` +Expected: All green (existing 86 tests still pass) + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/config.py src/fastapi_oidc_op/app.py \ + src/fastapi_oidc_op/authn/routes.py src/fastapi_oidc_op/manage/routes.py \ + src/fastapi_oidc_op/templates/ src/fastapi_oidc_op/static/ \ + tests/test_auth_routes/ +git commit -m "feat: add app wiring, templates, static files, and session middleware" +``` + +--- + +### Task 2: Session/Auth Dependencies [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/dependencies.py` +- Create: `tests/test_auth_routes/test_session_deps.py` + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/test_session_deps.py`: + +```python +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from fastapi_oidc_op.dependencies import get_session_user, require_session_user + + +def test_get_session_user_none_when_missing() -> None: + request = MagicMock() + request.session = {} + assert get_session_user(request) is None + + +def test_get_session_user_returns_tuple() -> None: + request = MagicMock() + request.session = {"userid": "u1", "username": "alice"} + assert get_session_user(request) == ("u1", "alice") + + +def test_get_session_user_none_when_partial() -> None: + request = MagicMock() + request.session = {"userid": "u1"} # missing username + assert get_session_user(request) is None + + +def test_require_session_user_raises_when_missing() -> None: + request = MagicMock() + request.session = {} + with pytest.raises(HTTPException) as exc_info: + require_session_user(request) + assert exc_info.value.status_code == 401 + + +def test_require_session_user_returns_tuple() -> None: + request = MagicMock() + request.session = {"userid": "u1", "username": "alice"} + assert require_session_user(request) == ("u1", "alice") +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_session_deps.py -v` +Expected: FAIL (`ImportError` — functions don't exist) + +**Step 3: Implement session helpers** + +In `src/fastapi_oidc_op/dependencies.py`, add: + +```python +def get_session_user(request: Request) -> tuple[str, str] | None: + """Extract (userid, username) from session, or None if not logged in.""" + userid = request.session.get("userid") + username = request.session.get("username") + if userid and username: + return (userid, username) + return None + + +def require_session_user(request: Request) -> tuple[str, str]: + """Like get_session_user but raises HTTPException(401) if not logged in. + + Routes that need a redirect-to-login behavior should catch this or + use get_session_user and redirect manually. + """ + result = get_session_user(request) + if result is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return result +``` + +These are plain functions that accept `Request`. Routes use them directly (e.g. `user = get_session_user(request)`) rather than through `Depends()`, because the session-based redirect logic varies per route (authn routes return error fragments, manage routes redirect to `/login`). Making them `Depends()` callables would require either a shared exception handler or separate dependency variants, adding complexity for no benefit at this stage. + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_session_deps.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/dependencies.py tests/test_auth_routes/test_session_deps.py +git commit -m "feat: add session user dependency helpers" +``` + +--- + +### Task 3: Password Login + Logout Routes [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/authn/routes.py` +- Create: `tests/test_auth_routes/test_password_login.py` + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/test_password_login.py`: + +```python +from datetime import UTC, datetime + +from argon2 import PasswordHasher +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User + + +async def test_password_login_unknown_user_returns_error_fragment(client: AsyncClient) -> None: + res = await client.post( + "/login/password", + data={"username": "nobody", "password": "wrong"}, + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + assert "Invalid username or password" in res.text + assert 'role="alert"' in res.text + + +async def test_password_login_wrong_password_returns_error_fragment(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC)) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("correct"))) + + res = await client.post( + "/login/password", + data={"username": "alice", "password": "wrong"}, + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + assert "Invalid username or password" in res.text + + +async def test_password_login_success_sets_session_and_hx_redirect(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC)) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("correct"))) + + res = await client.post( + "/login/password", + data={"username": "alice", "password": "correct"}, + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + assert res.headers.get("HX-Redirect") == "/manage/credentials" + + +async def test_logout_clears_session_and_redirects(client: AsyncClient) -> None: + res = await client.post("/logout", headers={"HX-Request": "true"}) + assert res.status_code == 200 + assert res.headers.get("HX-Redirect") == "/login" +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_password_login.py -v` +Expected: FAIL (routes not implemented) + +**Step 3: Implement password login + logout** + +In `src/fastapi_oidc_op/authn/routes.py`: + +- `POST /login/password` (accepts form data: `username`, `password`): + 1. Look up user by username via `request.app.state.user_repo.get_by_username(username)` + 2. If user not found -> return error fragment (same message as wrong password to prevent username enumeration) + 3. Fetch password credential via `request.app.state.credential_repo.get_password_by_user(user.userid)` + 4. If no credential -> return error fragment + 5. Verify with `request.app.state.password_service.verify(credential.password_hash, password)` + 6. On success: set `request.session["userid"] = user.userid`, `request.session["username"] = user.username`, return `Response` with `HX-Redirect: /manage/credentials` header + 7. On failure: return HTML fragment `
Invalid username or password
` + +- `POST /logout`: + 1. `request.session.clear()` + 2. Return `Response` with `HX-Redirect: /login` header + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_password_login.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/authn/routes.py tests/test_auth_routes/test_password_login.py +git commit -m "feat: add password login and logout endpoints" +``` + +--- + +### Task 4: Registration via Magic Link (`/register/{token}`) [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/authn/routes.py` +- Create: `tests/test_auth_routes/test_register_magic_link.py` + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/test_register_magic_link.py`: + +```python +from datetime import UTC, datetime, timedelta + +from httpx import AsyncClient + +from fastapi_oidc_op.models import MagicLink + + +async def test_register_invalid_token_returns_error_page(client: AsyncClient) -> None: + res = await client.get("/register/nope", follow_redirects=False) + assert res.status_code == 400 + assert "Invalid or expired" in res.text + + +async def test_register_expired_token_returns_error_page(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + repo = app.state.magic_link_repo + await repo.create( + MagicLink( + token="expired", + username="newuser", + expires_at=datetime.now(UTC) - timedelta(hours=1), + ) + ) + + res = await client.get("/register/expired", follow_redirects=False) + assert res.status_code == 400 + assert "Invalid or expired" in res.text + + +async def test_register_valid_token_creates_user_and_redirects(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + magic_link_repo = app.state.magic_link_repo + user_repo = app.state.user_repo + + await magic_link_repo.create( + MagicLink( + token="t1", + username="newuser", + expires_at=datetime.now(UTC) + timedelta(hours=1), + ) + ) + + res = await client.get("/register/t1", follow_redirects=False) + assert res.status_code in (302, 303) + assert "/manage/credentials" in res.headers["location"] + assert "setup=1" in res.headers["location"] + + # Token should be marked used + link = await magic_link_repo.get_by_token("t1") + assert link is not None + assert link.used is True + + # User should exist + user = await user_repo.get_by_username("newuser") + assert user is not None + assert "users" in user.groups + + +async def test_register_used_token_returns_error(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + repo = app.state.magic_link_repo + await repo.create( + MagicLink( + token="used", + username="newuser", + expires_at=datetime.now(UTC) + timedelta(hours=1), + used=True, + ) + ) + + res = await client.get("/register/used", follow_redirects=False) + assert res.status_code == 400 +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_register_magic_link.py -v` +Expected: FAIL + +**Step 3: Implement `/register/{token}`** + +In `src/fastapi_oidc_op/authn/routes.py`: + +- `GET /register/{token}`: + 1. Get `magic_link_service` from `request.app.state.magic_link_service` + 2. Call `magic_link_service.validate(token)` — returns `MagicLink | None` + 3. If `None` -> return error page with status 400 containing "Invalid or expired" + 4. Generate unique userid via `generate_unique_userid(request.app.state.user_repo)` + 5. Create `User(userid=userid, username=link.username, groups=["users"])` + 6. Save via `request.app.state.user_repo.create(user)` + 7. Mark token used via `magic_link_service.mark_used(token)` + 8. Set session: `request.session["userid"] = user.userid`, `request.session["username"] = user.username` + 9. Return `RedirectResponse("/manage/credentials?setup=1", status_code=303)` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_register_magic_link.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/authn/routes.py tests/test_auth_routes/test_register_magic_link.py +git commit -m "feat: add magic link registration endpoint" +``` + +--- + +### Task 5: Credential Management Page (GET) [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/manage/routes.py` +- Create: `src/fastapi_oidc_op/templates/manage/credentials.html` +- Create: `tests/test_auth_routes/test_manage_credentials_page.py` + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/test_manage_credentials_page.py`: + +```python +from datetime import UTC, datetime + +from argon2 import PasswordHasher +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User + + +async def _login(client: AsyncClient, username: str = "alice", password: str = "testpass") -> None: + """Helper: create user + password credential and log in via POST /login/password.""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = await user_repo.get_by_username(username) + if user is None: + user = User(userid="lusab-bansen", username=username, created_at=datetime.now(UTC), updated_at=datetime.now(UTC)) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + existing = await cred_repo.get_password_by_user(user.userid) + if existing is None: + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash(password))) + + await client.post( + "/login/password", + data={"username": username, "password": password}, + headers={"HX-Request": "true"}, + ) + + +async def test_manage_credentials_requires_login(client: AsyncClient) -> None: + res = await client.get("/manage/credentials", follow_redirects=False) + assert res.status_code in (302, 303) + assert res.headers["location"] == "/login" + + +async def test_manage_credentials_renders_for_logged_in_user(client: AsyncClient) -> None: + await _login(client) + + res = await client.get("/manage/credentials") + assert res.status_code == 200 + assert "Credentials" in res.text + + +async def test_manage_credentials_shows_setup_banner(client: AsyncClient) -> None: + await _login(client) + + res = await client.get("/manage/credentials?setup=1") + assert res.status_code == 200 + assert "Welcome" in res.text or "setup" in res.text.lower() +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_manage_credentials_page.py -v` +Expected: FAIL + +**Step 3: Implement GET route + template** + +In `src/fastapi_oidc_op/manage/routes.py`: + +- `GET /manage/credentials`: + 1. Call `get_session_user(request)` — if `None`, return `RedirectResponse("/login", status_code=303)` + 2. Load WebAuthn credentials via `request.app.state.credential_repo.get_webauthn_by_user(userid)` + 3. Load password credential via `request.app.state.credential_repo.get_password_by_user(userid)` + 4. Check `request.query_params.get("setup")` for welcome banner + 5. Render `templates/manage/credentials.html` with context + +Create `src/fastapi_oidc_op/templates/manage/credentials.html`: +- Extends `base.html` +- `{% if setup %}` welcome banner +- WebAuthn credentials section with list of existing keys + add form +- Password section showing whether password is set + set/change form +- HTMX targets for fragment swaps (`id="webauthn-list"`, `id="password-section"`) +- Each credential has a delete button (wired in later tasks) + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_manage_credentials_page.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/manage/routes.py \ + src/fastapi_oidc_op/templates/manage/credentials.html \ + tests/test_auth_routes/test_manage_credentials_page.py +git commit -m "feat: add credential management page" +``` + +--- + +### Task 6: Set/Change Password + Delete Password Credential (HTMX) [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/manage/routes.py` +- Create: `tests/test_auth_routes/test_manage_password_credential.py` + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/test_manage_password_credential.py`: + +```python +from datetime import UTC, datetime + +from argon2 import PasswordHasher +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User, WebAuthnCredential + + +async def _create_user_and_login(client: AsyncClient) -> str: + """Create user with password, log in, return userid.""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC)) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("old"))) + + await client.post( + "/login/password", + data={"username": "alice", "password": "old"}, + headers={"HX-Request": "true"}, + ) + return user.userid + + +async def test_set_password_requires_session(client: AsyncClient) -> None: + res = await client.post( + "/manage/credentials/password", + data={"password": "x", "confirm": "x"}, + follow_redirects=False, + ) + assert res.status_code in (302, 303) + + +async def test_set_password_mismatch_returns_error(client: AsyncClient) -> None: + await _create_user_and_login(client) + + res = await client.post( + "/manage/credentials/password", + data={"password": "newpassword", "confirm": "different"}, + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + assert 'role="alert"' in res.text + + +async def test_set_password_too_short_returns_error(client: AsyncClient) -> None: + await _create_user_and_login(client) + + res = await client.post( + "/manage/credentials/password", + data={"password": "short", "confirm": "short"}, + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + assert 'role="alert"' in res.text + + +async def test_set_password_creates_or_replaces_password(client: AsyncClient) -> None: + userid = await _create_user_and_login(client) + app = client._transport.app # type: ignore[union-attr] + cred_repo = app.state.credential_repo + + res = await client.post( + "/manage/credentials/password", + data={"password": "newpassword123", "confirm": "newpassword123"}, + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + assert 'role="status"' in res.text or "Password" in res.text + + updated = await cred_repo.get_password_by_user(userid) + assert updated is not None + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + assert svc.verify(updated.password_hash, "newpassword123") is True + + +async def test_delete_password_requires_session(client: AsyncClient) -> None: + res = await client.delete("/manage/credentials/password", follow_redirects=False) + assert res.status_code in (302, 303) + + +async def test_delete_password_with_other_credential(client: AsyncClient) -> None: + """User has both password and webauthn — deleting password succeeds.""" + userid = await _create_user_and_login(client) + app = client._transport.app # type: ignore[union-attr] + cred_repo = app.state.credential_repo + + # Add a webauthn credential so password is not the last one + await cred_repo.create_webauthn( + WebAuthnCredential(user_id=userid, credential_id=b"cred1", public_key=b"key1") + ) + + res = await client.delete( + "/manage/credentials/password", + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + + deleted = await cred_repo.get_password_by_user(userid) + assert deleted is None +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_manage_password_credential.py -v` +Expected: FAIL + +**Step 3: Implement POST and DELETE for password credential** + +In `src/fastapi_oidc_op/manage/routes.py`: + +- `POST /manage/credentials/password` (form data: `password`, `confirm`): + 1. Check session — if not logged in, redirect to `/login` + 2. Validate `password == confirm` — if not, return error fragment with `role="alert"` + 3. Validate `len(password) >= 8` — if not, return error fragment + 4. Hash with `request.app.state.password_service.hash(password)` + 5. Check if password exists: `cred_repo.get_password_by_user(userid)` + 6. If exists: `cred_repo.delete_password(userid)` then `cred_repo.create_password(...)` + 7. If not: `cred_repo.create_password(...)` + 8. Return HTML fragment with `role="status"` confirmation message + +- `DELETE /manage/credentials/password`: + 1. Check session — if not logged in, redirect to `/login` + 2. Count total credentials (webauthn count + password exists) + 3. If total == 1: return error fragment with `role="alert"` ("Cannot remove your last credential") + 4. Otherwise: `cred_repo.delete_password(userid)`, return updated password section fragment + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_manage_password_credential.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/manage/routes.py tests/test_auth_routes/test_manage_password_credential.py +git commit -m "feat: add set/change/delete password credential endpoints" +``` + +--- + +### Task 7: WebAuthn Credential Add (begin/complete) + Remove [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/manage/routes.py` +- Create: `src/fastapi_oidc_op/static/webauthn.js` +- Create: `tests/test_auth_routes/test_manage_webauthn_credential.py` + +**Serialization note:** The `fido2` library's `begin_registration()` returns a dict that is JSON-serializable (binary fields are already base64url-encoded internally). For `complete_registration()`, the server receives a JSON body from the browser JS. The `fido2` library accepts this as a `dict` and handles deserialization via `RegistrationResponse.from_dict()`. + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/test_manage_webauthn_credential.py`: + +The tests reuse the helper functions from `tests/test_authn/test_webauthn.py` for building valid registration responses. Extract shared helpers into `tests/conftest_webauthn.py` or import directly. For simplicity, inline the helpers or import from the existing test module. + +```python +import os +from datetime import UTC, datetime + +from argon2 import PasswordHasher +from cryptography.hazmat.primitives.asymmetric import ec +from fido2.cose import ES256 +from fido2.utils import sha256 +from fido2.webauthn import ( + Aaguid, + AttestationObject, + AttestedCredentialData, + AuthenticatorAttestationResponse, + AuthenticatorData, + CollectedClientData, + PublicKeyCredentialDescriptor, + PublicKeyCredentialType, + RegistrationResponse, +) +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User, WebAuthnCredential + +RP_ID = "localhost" +ORIGIN = "http://localhost:8000" + + +async def _create_user_and_login(client: AsyncClient) -> str: + """Create user with password, log in, return userid.""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC)) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass"))) + + await client.post( + "/login/password", + data={"username": "alice", "password": "testpass"}, + headers={"HX-Request": "true"}, + ) + return user.userid + + +def _generate_credential() -> tuple[ec.EllipticCurvePrivateKey, bytes, AttestedCredentialData]: + private_key = ec.generate_private_key(ec.SECP256R1()) + cose_key = ES256.from_cryptography_key(private_key.public_key()) + credential_id = os.urandom(32) + attested = AttestedCredentialData.create(aaguid=Aaguid.NONE, credential_id=credential_id, public_key=cose_key) + return private_key, credential_id, attested + + +def _build_registration_response(credential_id: bytes, attested: AttestedCredentialData, challenge: bytes) -> RegistrationResponse: + auth_data = AuthenticatorData.create( + rp_id_hash=sha256(RP_ID.encode()), + flags=AuthenticatorData.FLAG.UP | AuthenticatorData.FLAG.AT, + counter=0, + credential_data=attested, + ) + attestation_object = AttestationObject.create(fmt="none", auth_data=auth_data, att_stmt={}) + client_data = CollectedClientData.create(type=CollectedClientData.TYPE.CREATE, challenge=challenge, origin=ORIGIN) + return RegistrationResponse( + raw_id=credential_id, + response=AuthenticatorAttestationResponse(client_data=client_data, attestation_object=attestation_object), + ) + + +async def test_webauthn_begin_requires_session(client: AsyncClient) -> None: + res = await client.post("/manage/credentials/webauthn/begin", follow_redirects=False) + assert res.status_code in (302, 303, 401) + + +async def test_webauthn_begin_returns_options(client: AsyncClient) -> None: + await _create_user_and_login(client) + + res = await client.post("/manage/credentials/webauthn/begin") + assert res.status_code == 200 + data = res.json() + assert "publicKey" in data + assert "challenge" in data["publicKey"] + + +async def test_webauthn_complete_creates_credential(client: AsyncClient) -> None: + userid = await _create_user_and_login(client) + app = client._transport.app # type: ignore[union-attr] + cred_repo = app.state.credential_repo + + # Begin registration + res1 = await client.post("/manage/credentials/webauthn/begin") + assert res1.status_code == 200 + options = res1.json() + + # Build a valid registration response using the challenge from server + _private_key, credential_id, attested = _generate_credential() + challenge = options["publicKey"]["challenge"] + # The challenge from the server is base64url-encoded; fido2 expects raw bytes + # for CollectedClientData.create, but we need to pass the encoded challenge + # back through the RegistrationResponse which fido2 will decode internally. + # Use the webauthn_service from app.state to get the raw state instead. + # The test needs to use the state stored in the session. + # Since we can't easily extract session state in tests, we test the + # begin/complete flow by building the response with the challenge bytes + # from the fido2 state. Access the webauthn_service directly for this. + webauthn_service = app.state.webauthn_service + _options, state = webauthn_service.begin_registration( + user_id=userid.encode(), username="alice" + ) + + response = _build_registration_response(credential_id, attested, state["challenge"]) + result = webauthn_service.complete_registration(state, response) + + # Store credential directly to verify the repo works + cred = WebAuthnCredential( + user_id=userid, + credential_id=result.credential_data.credential_id, + public_key=bytes(result.credential_data), + ) + await cred_repo.create_webauthn(cred) + + creds = await cred_repo.get_webauthn_by_user(userid) + assert len(creds) == 1 + assert creds[0].credential_id == credential_id + + +async def test_delete_webauthn_credential(client: AsyncClient) -> None: + userid = await _create_user_and_login(client) + app = client._transport.app # type: ignore[union-attr] + cred_repo = app.state.credential_repo + + # User already has password credential from login. Add a webauthn credential. + await cred_repo.create_webauthn( + WebAuthnCredential(user_id=userid, credential_id=b"cred1", public_key=b"key1") + ) + + # base64url-encode the credential_id for the URL + from base64 import urlsafe_b64encode + cred_id_b64 = urlsafe_b64encode(b"cred1").decode().rstrip("=") + + res = await client.delete( + f"/manage/credentials/webauthn/{cred_id_b64}", + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + + creds = await cred_repo.get_webauthn_by_user(userid) + assert len(creds) == 0 +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_manage_webauthn_credential.py -v` +Expected: FAIL + +**Step 3: Implement endpoints + JS helper** + +In `src/fastapi_oidc_op/manage/routes.py`: + +- `POST /manage/credentials/webauthn/begin`: + 1. Check session — redirect if not logged in + 2. Load existing WebAuthn credentials, build `PublicKeyCredentialDescriptor` list for exclude + 3. Call `request.app.state.webauthn_service.begin_registration(user_id=userid.encode(), username=username, existing_credentials=descriptors)` + 4. Store `state` in `request.session["webauthn_register_state"]` + 5. Return `JSONResponse(options)` + +- `POST /manage/credentials/webauthn/complete` (JSON body): + 1. Check session + 2. Pop `webauthn_register_state` from session + 3. Call `webauthn_service.complete_registration(state, response_body)` + 4. Extract `credential_id` and `public_key` from `result.credential_data` + 5. Create `WebAuthnCredential(user_id=userid, credential_id=..., public_key=bytes(result.credential_data))` + 6. Save via `cred_repo.create_webauthn(...)` + 7. Return updated credential list HTML fragment + +- `DELETE /manage/credentials/webauthn/{credential_id}` (credential_id is base64url-encoded): + 1. Check session + 2. Decode `credential_id` from base64url + 3. Count total credentials; if last one, return error fragment + 4. Delete via `cred_repo.delete_webauthn(userid, credential_id_bytes)` + 5. Return updated credential list fragment + +Create `src/fastapi_oidc_op/static/webauthn.js`: +- `base64urlToBytes(s)` and `bytesToBase64url(bytes)` helpers +- `async function beginRegistration()`: POST to `/manage/credentials/webauthn/begin`, call `navigator.credentials.create()`, POST result to `/manage/credentials/webauthn/complete` +- `async function beginAuthentication(username)`: POST to `/login/webauthn/begin`, call `navigator.credentials.get()`, POST result to `/login/webauthn/complete` +- Integrate with HTMX via `htmx.trigger()` or direct DOM updates +- No forced animations; respect `prefers-reduced-motion` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_manage_webauthn_credential.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/manage/routes.py src/fastapi_oidc_op/static/webauthn.js \ + tests/test_auth_routes/test_manage_webauthn_credential.py +git commit -m "feat: add webauthn credential registration and removal" +``` + +--- + +### Task 8: WebAuthn Login (begin/complete) + Sign Count Update [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/authn/routes.py` +- Create: `tests/test_auth_routes/test_webauthn_login.py` + +**Implementation detail — sign count:** `Fido2Server.authenticate_complete()` returns the matched `AttestedCredentialData`, not the new sign count. To update sign_count, extract it from the raw response: parse `AuthenticationResponse` from the client payload, then read `response.response.authenticator_data.counter`. Update the credential in the repo with this new counter value. + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/test_webauthn_login.py`: + +```python +import os +from datetime import UTC, datetime + +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.hashes import SHA256 +from fido2.cose import ES256 +from fido2.utils import sha256 +from fido2.webauthn import ( + Aaguid, + AttestationObject, + AttestedCredentialData, + AuthenticationResponse, + AuthenticatorAssertionResponse, + AuthenticatorAttestationResponse, + AuthenticatorData, + CollectedClientData, + PublicKeyCredentialDescriptor, + PublicKeyCredentialType, + RegistrationResponse, +) +from httpx import AsyncClient + +from fastapi_oidc_op.models import User, WebAuthnCredential + +RP_ID = "localhost" +ORIGIN = "http://localhost:8000" + + +def _generate_credential() -> tuple[ec.EllipticCurvePrivateKey, bytes, AttestedCredentialData]: + private_key = ec.generate_private_key(ec.SECP256R1()) + cose_key = ES256.from_cryptography_key(private_key.public_key()) + credential_id = os.urandom(32) + attested = AttestedCredentialData.create(aaguid=Aaguid.NONE, credential_id=credential_id, public_key=cose_key) + return private_key, credential_id, attested + + +async def _setup_user_with_webauthn(client: AsyncClient) -> tuple[str, ec.EllipticCurvePrivateKey, bytes, AttestedCredentialData]: + """Create a user with a WebAuthn credential in the repo. Returns (userid, private_key, credential_id, attested).""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + private_key, credential_id, attested = _generate_credential() + + user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC)) + await user_repo.create(user) + + await cred_repo.create_webauthn( + WebAuthnCredential( + user_id=user.userid, + credential_id=credential_id, + public_key=bytes(attested), + sign_count=0, + ) + ) + + return user.userid, private_key, credential_id, attested + + +async def test_webauthn_login_begin_returns_options(client: AsyncClient) -> None: + _userid, _pk, _cid, _att = await _setup_user_with_webauthn(client) + + res = await client.post( + "/login/webauthn/begin", + data={"username": "alice"}, + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + data = res.json() + assert "publicKey" in data + + +async def test_webauthn_login_begin_unknown_user(client: AsyncClient) -> None: + res = await client.post( + "/login/webauthn/begin", + data={"username": "nobody"}, + headers={"HX-Request": "true"}, + ) + # Should return error, not crash + assert res.status_code == 200 + assert 'role="alert"' in res.text or "not found" in res.text.lower() or "Invalid" in res.text + + +async def test_webauthn_login_complete_sets_session(client: AsyncClient) -> None: + userid, private_key, credential_id, attested = await _setup_user_with_webauthn(client) + app = client._transport.app # type: ignore[union-attr] + webauthn_service = app.state.webauthn_service + cred_repo = app.state.credential_repo + + # Begin authentication directly via service (to get raw state for building response) + descriptors = [PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=credential_id)] + options, state = webauthn_service.begin_authentication(credentials=descriptors) + + # Build authentication response + challenge = state["challenge"] + client_data = CollectedClientData.create(type=CollectedClientData.TYPE.GET, challenge=challenge, origin=ORIGIN) + auth_data = AuthenticatorData.create(rp_id_hash=sha256(RP_ID.encode()), flags=AuthenticatorData.FLAG.UP, counter=5) + signature = private_key.sign(auth_data + client_data.hash, ec.ECDSA(SHA256())) + + # We need to POST to /login/webauthn/begin first to set session state, + # then POST to /login/webauthn/complete with the response. + # For the integration test, use the actual begin endpoint: + res1 = await client.post("/login/webauthn/begin", data={"username": "alice"}) + assert res1.status_code == 200 + + # The challenge is now in the server session. Since we can't easily extract + # it, this test verifies the full flow works by using the service directly + # for the crypto part and trusting the route integration. + # A full end-to-end test would require extracting the session cookie. + + # Verify sign_count can be updated via the repo directly + stored = await cred_repo.get_webauthn_by_credential_id(credential_id) + assert stored is not None + stored.sign_count = 5 + await cred_repo.update_webauthn(stored) + updated = await cred_repo.get_webauthn_by_credential_id(credential_id) + assert updated is not None + assert updated.sign_count == 5 +``` + +Note: Full end-to-end testing of WebAuthn begin/complete through HTTP is inherently difficult because the challenge must round-trip through the session cookie, and building a valid `AuthenticationResponse` requires the exact challenge bytes from the session. The tests above verify: (1) the begin endpoint returns valid options, (2) the service-level crypto works, (3) sign_count can be updated. The route-level `complete` integration is best verified manually or with a dedicated integration test that extracts the session. + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_webauthn_login.py -v` +Expected: FAIL + +**Step 3: Implement endpoints** + +In `src/fastapi_oidc_op/authn/routes.py`: + +- `POST /login/webauthn/begin` (form data: `username`): + 1. Look up user by username — if not found, return error fragment (same as password to prevent enumeration) + 2. Fetch WebAuthn credentials from repo + 3. Build `PublicKeyCredentialDescriptor` list from stored credentials + 4. Reconstruct `AttestedCredentialData` from stored `public_key` bytes for each credential + 5. Call `webauthn_service.begin_authentication(credentials=descriptors)` + 6. Store `state` in `request.session["webauthn_login_state"]` + 7. Also store `userid` temporarily in `request.session["webauthn_login_userid"]` + 8. Return `JSONResponse(options)` + +- `POST /login/webauthn/complete` (JSON body: the browser's credential response): + 1. Pop `webauthn_login_state` and `webauthn_login_userid` from session + 2. Fetch user's WebAuthn credentials from repo + 3. Reconstruct `AttestedCredentialData` list from stored `public_key` bytes + 4. Call `webauthn_service.complete_authentication(state, credentials, response_body)` + 5. On failure: return error fragment + 6. Extract new sign count from the response: parse `AuthenticationResponse.from_dict(response_body)`, read `response.response.authenticator_data.counter` + 7. Update sign count: find the matching credential in repo, set `sign_count = new_counter`, call `cred_repo.update_webauthn(credential)` + 8. Set session: `request.session["userid"] = user.userid`, `request.session["username"] = user.username` + 9. Return response with `HX-Redirect: /manage/credentials` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_webauthn_login.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/authn/routes.py tests/test_auth_routes/test_webauthn_login.py +git commit -m "feat: add webauthn login begin/complete endpoints" +``` + +--- + +### Task 9: Guardrails (cannot remove last credential) [DONE] + +**Files:** +- Modify: `src/fastapi_oidc_op/manage/routes.py` +- Create: `tests/test_auth_routes/test_last_credential_guard.py` + +**Step 1: Write the failing tests** + +Create `tests/test_auth_routes/test_last_credential_guard.py`: + +```python +from base64 import urlsafe_b64encode +from datetime import UTC, datetime + +from argon2 import PasswordHasher +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User, WebAuthnCredential + + +async def _create_user_and_login(client: AsyncClient) -> str: + """Create user with password credential, log in, return userid.""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC)) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass"))) + + await client.post( + "/login/password", + data={"username": "alice", "password": "testpass"}, + headers={"HX-Request": "true"}, + ) + return user.userid + + +async def test_cannot_delete_last_password_credential(client: AsyncClient) -> None: + """User has only a password — cannot delete it.""" + await _create_user_and_login(client) + + res = await client.delete( + "/manage/credentials/password", + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + assert 'role="alert"' in res.text + assert "last credential" in res.text.lower() or "Cannot remove" in res.text + + # Password should still exist + app = client._transport.app # type: ignore[union-attr] + cred = await app.state.credential_repo.get_password_by_user("lusab-bansen") + assert cred is not None + + +async def test_cannot_delete_last_webauthn_credential(client: AsyncClient) -> None: + """User has only one webauthn credential (password was removed) — cannot delete it.""" + userid = await _create_user_and_login(client) + app = client._transport.app # type: ignore[union-attr] + cred_repo = app.state.credential_repo + + # Add webauthn, then delete password (so webauthn is the only credential) + await cred_repo.create_webauthn( + WebAuthnCredential(user_id=userid, credential_id=b"cred1", public_key=b"key1") + ) + await cred_repo.delete_password(userid) + + cred_id_b64 = urlsafe_b64encode(b"cred1").decode().rstrip("=") + res = await client.delete( + f"/manage/credentials/webauthn/{cred_id_b64}", + headers={"HX-Request": "true"}, + ) + assert res.status_code == 200 + assert 'role="alert"' in res.text + + # Credential should still exist + creds = await cred_repo.get_webauthn_by_user(userid) + assert len(creds) == 1 +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_auth_routes/test_last_credential_guard.py -v` +Expected: FAIL (delete endpoints exist from Tasks 6-7 but don't enforce the guardrail yet) + +**Step 3: Implement guardrails** + +In `src/fastapi_oidc_op/manage/routes.py`: + +Add a helper function used by both DELETE routes: + +```python +async def _count_credentials(cred_repo, userid: str) -> int: + """Count total credentials (password + webauthn) for a user.""" + webauthn = await cred_repo.get_webauthn_by_user(userid) + password = await cred_repo.get_password_by_user(userid) + return len(webauthn) + (1 if password else 0) +``` + +In `DELETE /manage/credentials/password` and `DELETE /manage/credentials/webauthn/{credential_id}`: +- Before deleting, call `_count_credentials()` +- If count == 1, return error fragment: `
Cannot remove your last credential
` +- Otherwise proceed with deletion + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_auth_routes/test_last_credential_guard.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/fastapi_oidc_op/manage/routes.py tests/test_auth_routes/test_last_credential_guard.py +git commit -m "fix: prevent removing the last credential" +``` + +--- + +### Task 10: Full Quality Gate [DONE] + +**Files:** +- All touched + +**Step 1: Run full quality checks** + +Run: `./scripts/check.sh` +Expected: All green (formatting, linting, type checking, all tests pass) + +**Step 2: Fix any issues** + +If ruff format or ruff check made changes, review them. If ty reports type errors, fix them. + +**Step 3: Commit any fixes** + +```bash +git add -A +git diff --cached --quiet || git commit -m "style: apply formatting and fix lint issues" +``` diff --git a/docs/plans/2026-02-16-e2e-tests-plan.md b/docs/plans/2026-02-16-e2e-tests-plan.md new file mode 100644 index 0000000..1554f40 --- /dev/null +++ b/docs/plans/2026-02-16-e2e-tests-plan.md @@ -0,0 +1,820 @@ +# E2E Playwright Tests Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Comprehensive end-to-end browser tests covering all user-facing flows in Porchlight. + +**Architecture:** Each test file covers one functional area. Tests use raw Playwright (no test framework) with a shared assertion helper pattern. Server-side state is set up via direct HTTP calls to the API where the browser UI can't reach (e.g., creating users/magic links). The existing `run.sh` handles app lifecycle. + +**Tech Stack:** Playwright (Node.js), Chromium headless, HTMX-aware testing patterns. + +--- + +## Test Inventory + +The app has these testable user flows: + +| Flow | Route(s) | Test File | +|---|---|---| +| Login page (existing) | `GET /login` | `test_login.js` (done) | +| Password auth + error states | `POST /login/password` | `test_password_auth.js` | +| Magic link registration | `GET /register/{token}` | `test_registration.js` | +| Credential management page | `GET /manage/credentials` | `test_credentials.js` | +| Set/change password (HTMX) | `POST /manage/credentials/password` | `test_credentials.js` | +| Auth guard (redirect to login) | All `/manage/*` routes | `test_auth_guard.js` | +| Health endpoint | `GET /health` | `test_health.js` | + +WebAuthn flows (`/login/webauthn/*`, `/manage/credentials/webauthn/*`) require hardware key simulation which is complex. They are excluded from this plan and noted as a future enhancement. + +--- + +## Shared Helper + +All test files duplicate the `assert()` pattern. Before writing new tests, extract a shared helper. + +--- + +### Task 1: Extract shared test helper + +**Files:** +- Create: `tests/e2e/helpers.js` +- Modify: `tests/e2e/test_login.js` + +**Step 1: Create `tests/e2e/helpers.js`** + +```javascript +// tests/e2e/helpers.js +// Shared utilities for Porchlight e2e tests. + +const { chromium } = require('playwright'); + +const TARGET_URL = process.env.TARGET_URL || 'http://localhost:8099'; + +/** + * Simple test runner with pass/fail counting. + * + * Usage: + * const { run } = require('./helpers'); + * run(async (page, assert) => { + * await page.goto(TARGET_URL + '/login'); + * assert(true, 'page loaded'); + * }); + */ +async function run(testFn) { + let passed = 0; + let failed = 0; + + function assert(condition, description) { + if (condition) { + console.log(` PASS: ${description}`); + passed++; + } else { + console.log(` FAIL: ${description}`); + failed++; + } + } + + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + + try { + await testFn(page, assert); + } finally { + await browser.close(); + } + + console.log(`\n========================================`); + console.log(`Results: ${passed} passed, ${failed} failed`); + console.log(`========================================\n`); + + process.exit(failed > 0 ? 1 : 0); +} + +module.exports = { TARGET_URL, run }; +``` + +**Step 2: Refactor `tests/e2e/test_login.js` to use the helper** + +Replace the boilerplate (chromium launch, assert function, browser.close, summary) with: + +```javascript +const { TARGET_URL, run } = require('./helpers'); + +run(async (page, assert) => { + // ... all existing test logic stays the same, just remove boilerplate ... +}); +``` + +Keep all existing assertions unchanged. Only remove duplicated setup/teardown. + +**Step 3: Run test to verify refactor is clean** + +Run: `./tests/e2e/run.sh tests/e2e/test_login.js` +Expected: 28 passed, 0 failed + +**Step 4: Commit** + +```bash +git add tests/e2e/helpers.js tests/e2e/test_login.js +git commit -m "refactor: extract shared e2e test helper with assert runner" +``` + +--- + +### Task 2: Password authentication test + +**Files:** +- Create: `tests/e2e/test_password_auth.js` + +This test needs an existing user with a password. Since there's no admin API to create users directly via the browser, we use the magic link registration flow as setup, then set a password, and test login. + +**Step 1: Create `tests/e2e/test_password_auth.js`** + +```javascript +// tests/e2e/test_password_auth.js +// Tests password login flow: successful login, invalid password, nonexistent user. + +const { TARGET_URL, run } = require('./helpers'); + +run(async (page, assert) => { + // ---- Setup: Register a user via magic link API ---- + // Create a magic link by hitting the app's internal service. + // We do this by making a direct API call to create a magic link token, + // then visiting the registration URL. + console.log('\n--- Setup: create test user ---'); + + // Use the /register endpoint with a token created via the API. + // The app uses MagicLinkService internally. Since we don't have an admin API, + // we'll use a helper endpoint approach: insert via direct DB calls isn't possible + // in e2e. Instead, we use Playwright's request context to call a setup script. + // + // Alternative: Create magic link via a Python helper script. + // For now, we use a pragmatic approach: start a Python subprocess to create a + // magic link and return the token. + + // Execute setup script that creates a user with a password and returns credentials + const { execSync } = require('child_process'); + const setupResult = execSync( + `uv run python -c " +import asyncio, json, aiosqlite +from pathlib import Path +from fastapi_oidc_op.config import Settings, StorageBackend +from fastapi_oidc_op.store.sqlite.repositories import SQLiteUserRepository, SQLiteCredentialRepository, SQLiteMagicLinkRepository +from fastapi_oidc_op.store.sqlite.migrations import run_migrations +from fastapi_oidc_op.invite.service import MagicLinkService + +async def setup(): + # Connect to the same DB the running app uses (in-memory won't work across processes) + # So we use the magic link approach: create link, return token + # Actually for :memory: DB, we must go through the running app's HTTP interface. + # Use the health endpoint to verify the app is up, then we need another approach. + # + # Best approach: just test the flows we can through the browser. + pass + +asyncio.run(setup()) +"`, + { encoding: 'utf-8' } + ); + + // REVISED APPROACH: Since the app uses :memory: SQLite, we cannot access the DB + // from outside the process. Instead, we test what we can through the browser: + // + // 1. Go to login page + // 2. Try to login with nonexistent user (should show error) + // 3. Try to login with empty fields (browser validation) + + // ---- Test: Login with nonexistent user ---- + console.log('\n--- Login with nonexistent user ---'); + await page.goto(`${TARGET_URL}/login`); + + // Fill in form and submit via HTMX + await page.fill('#username', 'nonexistent'); + await page.fill('#password', 'wrongpassword'); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + + // Wait for HTMX response + await page.waitForSelector('[role="alert"]', { timeout: 5000 }); + const errorText = await page.locator('[role="alert"]').textContent(); + assert( + errorText.includes('Invalid username or password'), + `Shows error for nonexistent user (got: "${errorText}")` + ); + + // ---- Test: Login with empty username (browser validation) ---- + console.log('\n--- Browser form validation ---'); + await page.goto(`${TARGET_URL}/login`); + + // The username field has `required` attribute, so clicking submit with empty + // fields should not send the request. We verify the field has the required attribute. + const usernameRequired = await page.locator('#username').getAttribute('required'); + assert(usernameRequired !== null, 'Username field has required attribute'); + + const passwordRequired = await page.locator('#password').getAttribute('required'); + assert(passwordRequired !== null, 'Password field has required attribute'); + + // ---- Test: HTMX attributes are correct ---- + console.log('\n--- HTMX form configuration ---'); + const hxPost = await page.locator('form[hx-post="/login/password"]').getAttribute('hx-post'); + assert(hxPost === '/login/password', `Form posts to /login/password`); + + const hxTarget = await page.locator('form[hx-post="/login/password"]').getAttribute('hx-target'); + assert(hxTarget === '#login-error', `Form targets #login-error div`); + + const hxSwap = await page.locator('form[hx-post="/login/password"]').getAttribute('hx-swap'); + assert(hxSwap === 'innerHTML', `Form swaps innerHTML`); +}); +``` + +**NOTE:** The `:memory:` SQLite database is only accessible within the running uvicorn process. E2E tests cannot seed data externally. The password auth test is therefore limited to testing error states and form behavior that doesn't require pre-existing users. Full login-success testing requires either: +- (a) A test setup endpoint (future) +- (b) Using a file-based SQLite path for e2e so a setup script can seed data +- (c) Testing through the magic link registration flow first (Task 3) + +We take approach (c): Task 3 will test the full happy path (register via magic link → set password → logout → login with password). + +**Step 2: Run the test** + +Run: `./tests/e2e/run.sh tests/e2e/test_password_auth.js` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add tests/e2e/test_password_auth.js +git commit -m "test: add e2e test for password login error states and form validation" +``` + +--- + +### Task 3: Magic link registration + full login flow + +This is the most important test — it exercises the complete user journey: +1. Visit magic link → user created, redirected to credentials page +2. Set password on credentials page +3. Logout +4. Login with the password just set + +Since we can't create magic links from outside the process, we need to change `run.sh` to use a file-based SQLite DB and add a setup script. Alternatively, we add a **test-only setup endpoint** to the app that is only active when `debug=True`. + +**Files:** +- Modify: `tests/e2e/run.sh` — use file-based SQLite in a temp directory +- Create: `tests/e2e/setup_db.py` — Python script to seed test data into the SQLite DB +- Create: `tests/e2e/test_full_flow.js` — full registration → password → logout → login test + +**Step 1: Modify `run.sh` to use file-based SQLite** + +Change the SQLite path from `:memory:` to a temp file: + +```bash +# Near the top, after SCRIPT_DIR/PROJECT_ROOT: +E2E_TMPDIR="$(mktemp -d)" +export OIDC_OP_SQLITE_PATH="${E2E_TMPDIR}/e2e_test.db" + +# In the cleanup function, add: +rm -rf "$E2E_TMPDIR" +``` + +Update the uvicorn start command to use `OIDC_OP_SQLITE_PATH` from the environment (remove the `:memory:` override). + +**Step 2: Create `tests/e2e/setup_db.py`** + +```python +#!/usr/bin/env python3 +"""Seed the e2e test database with test fixtures. + +Outputs JSON with the created test data (magic link tokens, usernames, etc.) +so the JS tests can use them. + +Requires OIDC_OP_SQLITE_PATH env var pointing to the app's SQLite DB. +""" +import asyncio +import json +import os +import sys + +import aiosqlite + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.invite.service import MagicLinkService +from fastapi_oidc_op.models import PasswordCredential, User +from fastapi_oidc_op.store.sqlite.repositories import ( + SQLiteCredentialRepository, + SQLiteMagicLinkRepository, + SQLiteUserRepository, +) + + +async def seed(): + db_path = os.environ.get("OIDC_OP_SQLITE_PATH") + if not db_path: + print("OIDC_OP_SQLITE_PATH not set", file=sys.stderr) + sys.exit(1) + + db = await aiosqlite.connect(db_path) + db.row_factory = aiosqlite.Row + + user_repo = SQLiteUserRepository(db) + cred_repo = SQLiteCredentialRepository(db) + magic_link_repo = SQLiteMagicLinkRepository(db) + password_service = PasswordService() + magic_link_service = MagicLinkService(repo=magic_link_repo) + + result = {} + + # 1. Create a magic link for registration test + link = await magic_link_service.create(username="newuser") + result["register_token"] = link.token + result["register_username"] = "newuser" + + # 2. Create a user with a password for login test + user = User(userid="test-user-01", username="testuser", groups=["users"]) + await user_repo.create(user) + password_hash = password_service.hash("testpassword123") + await cred_repo.create_password( + PasswordCredential(user_id=user.userid, password_hash=password_hash) + ) + result["login_username"] = "testuser" + result["login_password"] = "testpassword123" + + # 3. Create an expired/used magic link for negative test + expired_link = await magic_link_service.create(username="expired") + await magic_link_service.mark_used(expired_link.token) + result["used_token"] = expired_link.token + + await db.close() + print(json.dumps(result)) + + +asyncio.run(seed()) +``` + +**Step 3: Update `run.sh` to run setup script after server is healthy** + +After the "Server ready." line, add: + +```bash +# --- Seed test data --- +echo "Seeding test data..." +E2E_FIXTURES=$(uv run --directory "$PROJECT_ROOT" python tests/e2e/setup_db.py) +export E2E_FIXTURES +echo "Test fixtures: ${E2E_FIXTURES}" +``` + +**Step 4: Create `tests/e2e/test_full_flow.js`** + +```javascript +// tests/e2e/test_full_flow.js +// Full user journey: magic link registration → set password → logout → login. + +const { TARGET_URL, run } = require('./helpers'); + +run(async (page, assert) => { + const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); + assert(fixtures.register_token, 'Test fixtures loaded (register_token present)'); + + // ---- Step 1: Register via magic link ---- + console.log('\n--- Magic link registration ---'); + await page.goto(`${TARGET_URL}/register/${fixtures.register_token}`); + + // Should redirect to /manage/credentials?setup=1 + await page.waitForURL('**/manage/credentials?setup=1', { timeout: 5000 }); + assert( + page.url().includes('/manage/credentials'), + `Redirected to credentials page (url: ${page.url()})` + ); + + // Should show welcome message + const welcome = page.locator('[role="status"]'); + assert(await welcome.isVisible(), 'Welcome/setup message is visible'); + const welcomeText = await welcome.textContent(); + assert( + welcomeText.includes('Welcome'), + `Welcome message shown (got: "${welcomeText}")` + ); + + // Page title should be Porchlight + const title = await page.title(); + assert(title.includes('Porchlight'), `Credentials page title contains Porchlight`); + + // ---- Step 2: Set password ---- + console.log('\n--- Set password ---'); + const passwordInput = page.locator('#password'); + const confirmInput = page.locator('#confirm'); + assert(await passwordInput.isVisible(), 'Password input is visible'); + assert(await confirmInput.isVisible(), 'Confirm password input is visible'); + + await passwordInput.fill('mypassword123'); + await confirmInput.fill('mypassword123'); + await page.click('#password-section button[type="submit"]'); + + // Wait for HTMX response — should show success + await page.waitForSelector('[role="status"]', { timeout: 5000 }); + // Find the status message inside #password-section + const successMsg = page.locator('#password-section [role="status"]'); + await successMsg.waitFor({ timeout: 5000 }); + const successText = await successMsg.textContent(); + assert( + successText.includes('Password updated'), + `Password set successfully (got: "${successText}")` + ); + + // ---- Step 3: Logout ---- + console.log('\n--- Logout ---'); + // The app uses POST /logout with HX-Redirect. We'll call it via fetch. + const logoutResp = await page.request.post(`${TARGET_URL}/logout`); + assert(logoutResp.ok() || logoutResp.status() === 200, `Logout returns OK`); + + // Navigate to credentials — should redirect to login + await page.goto(`${TARGET_URL}/manage/credentials`); + await page.waitForURL('**/login', { timeout: 5000 }); + assert(page.url().includes('/login'), `Redirected to login after logout`); + + // ---- Step 4: Login with the password we just set ---- + console.log('\n--- Login with new password ---'); + await page.fill('#username', fixtures.register_username); + await page.fill('#password', 'mypassword123'); + + // Submit via HTMX — on success, HX-Redirect header triggers redirect + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + + // Wait for redirect to credentials page + await page.waitForURL('**/manage/credentials', { timeout: 5000 }); + assert( + page.url().includes('/manage/credentials'), + `Login succeeded, redirected to credentials (url: ${page.url()})` + ); + + // Should NOT show setup message (no ?setup=1) + const setupMsgCount = await page.locator('[role="status"]:has-text("Welcome")').count(); + assert(setupMsgCount === 0, 'No welcome/setup message on normal login'); +}); +``` + +**Step 5: Run tests** + +Run: `./tests/e2e/run.sh tests/e2e/test_full_flow.js` +Expected: All PASS + +**Step 6: Commit** + +```bash +git add tests/e2e/run.sh tests/e2e/setup_db.py tests/e2e/test_full_flow.js +git commit -m "test: add full user journey e2e test (register, set password, logout, login)" +``` + +--- + +### Task 4: Auth guard test + +**Files:** +- Create: `tests/e2e/test_auth_guard.js` + +Tests that unauthenticated users are redirected to `/login` when accessing protected pages. + +**Step 1: Create `tests/e2e/test_auth_guard.js`** + +```javascript +// tests/e2e/test_auth_guard.js +// Tests that protected routes redirect unauthenticated users to /login. + +const { TARGET_URL, run } = require('./helpers'); + +run(async (page, assert) => { + // ---- Unauthenticated access to /manage/credentials ---- + console.log('\n--- Auth guard: /manage/credentials ---'); + await page.goto(`${TARGET_URL}/manage/credentials`); + await page.waitForURL('**/login', { timeout: 5000 }); + assert(page.url().includes('/login'), `GET /manage/credentials redirects to /login`); + + // ---- Unauthenticated access to /manage/credentials?setup=1 ---- + console.log('\n--- Auth guard: /manage/credentials?setup=1 ---'); + await page.goto(`${TARGET_URL}/manage/credentials?setup=1`); + await page.waitForURL('**/login', { timeout: 5000 }); + assert(page.url().includes('/login'), `GET /manage/credentials?setup=1 redirects to /login`); +}); +``` + +**Step 2: Run test** + +Run: `./tests/e2e/run.sh tests/e2e/test_auth_guard.js` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add tests/e2e/test_auth_guard.js +git commit -m "test: add e2e auth guard test for protected routes" +``` + +--- + +### Task 5: Password login error states test + +**Files:** +- Create: `tests/e2e/test_password_auth.js` + +**Step 1: Create `tests/e2e/test_password_auth.js`** + +```javascript +// tests/e2e/test_password_auth.js +// Tests password login error states: wrong password, nonexistent user, form validation. + +const { TARGET_URL, run } = require('./helpers'); + +run(async (page, assert) => { + const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); + + // ---- Test: Nonexistent user ---- + console.log('\n--- Login: nonexistent user ---'); + await page.goto(`${TARGET_URL}/login`); + await page.fill('#username', 'nobody'); + await page.fill('#password', 'whatever'); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + + await page.waitForSelector('[role="alert"]', { timeout: 5000 }); + const error1 = await page.locator('[role="alert"]').textContent(); + assert( + error1.includes('Invalid username or password'), + `Error shown for nonexistent user (got: "${error1}")` + ); + + // ---- Test: Wrong password for existing user ---- + console.log('\n--- Login: wrong password ---'); + await page.goto(`${TARGET_URL}/login`); + await page.fill('#username', fixtures.login_username); + await page.fill('#password', 'wrongpassword'); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + + await page.waitForSelector('[role="alert"]', { timeout: 5000 }); + const error2 = await page.locator('[role="alert"]').textContent(); + assert( + error2.includes('Invalid username or password'), + `Error shown for wrong password (got: "${error2}")` + ); + + // ---- Test: Successful login ---- + console.log('\n--- Login: correct password ---'); + await page.goto(`${TARGET_URL}/login`); + await page.fill('#username', fixtures.login_username); + await page.fill('#password', fixtures.login_password); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + + await page.waitForURL('**/manage/credentials', { timeout: 5000 }); + assert( + page.url().includes('/manage/credentials'), + `Successful login redirects to credentials (url: ${page.url()})` + ); + + // ---- Test: Form validation attributes ---- + console.log('\n--- Form validation attributes ---'); + await page.goto(`${TARGET_URL}/login`); + const usernameRequired = await page.locator('#username').getAttribute('required'); + assert(usernameRequired !== null, 'Username has required attribute'); + const passwordRequired = await page.locator('#password').getAttribute('required'); + assert(passwordRequired !== null, 'Password has required attribute'); + + const usernameAutocomplete = await page.locator('#username').getAttribute('autocomplete'); + assert(usernameAutocomplete === 'username', `Username autocomplete is "username"`); + const passwordAutocomplete = await page.locator('#password').getAttribute('autocomplete'); + assert(passwordAutocomplete === 'current-password', `Password autocomplete is "current-password"`); +}); +``` + +**Step 2: Run test** + +Run: `./tests/e2e/run.sh tests/e2e/test_password_auth.js` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add tests/e2e/test_password_auth.js +git commit -m "test: add e2e password auth test with error states and successful login" +``` + +--- + +### Task 6: Credentials page test + +**Files:** +- Create: `tests/e2e/test_credentials.js` + +Tests the credential management page when authenticated: page structure, set password (validation errors), change password. + +**Step 1: Create `tests/e2e/test_credentials.js`** + +```javascript +// tests/e2e/test_credentials.js +// Tests credential management page: structure, password set/change, validation errors. + +const { TARGET_URL, run } = require('./helpers'); + +run(async (page, assert) => { + const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); + + // ---- Setup: Log in first ---- + console.log('\n--- Setup: login ---'); + await page.goto(`${TARGET_URL}/login`); + await page.fill('#username', fixtures.login_username); + await page.fill('#password', fixtures.login_password); + await page.click('form[hx-post="/login/password"] button[type="submit"]'); + await page.waitForURL('**/manage/credentials', { timeout: 5000 }); + + // ---- Page structure ---- + console.log('\n--- Credentials page structure ---'); + const title = await page.title(); + assert(title.includes('Credentials'), `Title contains "Credentials" (got: "${title}")`); + assert(title.includes('Porchlight'), `Title contains "Porchlight" (got: "${title}")`); + + const h1 = await page.locator('h1').textContent(); + assert(h1 === 'Credentials', `H1 says "Credentials" (got: "${h1}")`); + + // Security keys section + const securityKeysH2 = page.locator('h2:has-text("Security keys")'); + assert(await securityKeysH2.isVisible(), 'Security keys heading visible'); + + const registerBtn = page.locator('#webauthn-register-btn'); + assert(await registerBtn.isVisible(), 'Add security key button visible'); + + // Password section + const passwordH2 = page.locator('h2:has-text("Password")'); + assert(await passwordH2.isVisible(), 'Password heading visible'); + + const passwordStatus = page.locator('#password-section'); + assert(await passwordStatus.isVisible(), 'Password section visible'); + + // ---- Password validation: mismatch ---- + console.log('\n--- Password validation: mismatch ---'); + await page.fill('#password', 'newpassword1'); + await page.fill('#confirm', 'newpassword2'); + await page.click('#password-section button[type="submit"]'); + + await page.waitForSelector('#password-section [role="alert"]', { timeout: 5000 }); + const mismatchErr = await page.locator('#password-section [role="alert"]').textContent(); + assert( + mismatchErr.includes('do not match'), + `Shows mismatch error (got: "${mismatchErr}")` + ); + + // ---- Password validation: too short ---- + console.log('\n--- Password validation: too short ---'); + // Reload page to clear HTMX state + await page.goto(`${TARGET_URL}/manage/credentials`); + await page.fill('#password', 'short'); + await page.fill('#confirm', 'short'); + await page.click('#password-section button[type="submit"]'); + + await page.waitForSelector('#password-section [role="alert"]', { timeout: 5000 }); + const shortErr = await page.locator('#password-section [role="alert"]').textContent(); + assert( + shortErr.includes('at least 8 characters'), + `Shows too-short error (got: "${shortErr}")` + ); + + // ---- Password change: success ---- + console.log('\n--- Password change: success ---'); + await page.goto(`${TARGET_URL}/manage/credentials`); + await page.fill('#password', 'newpassword123'); + await page.fill('#confirm', 'newpassword123'); + await page.click('#password-section button[type="submit"]'); + + await page.waitForSelector('#password-section [role="status"]', { timeout: 5000 }); + const successMsg = await page.locator('#password-section [role="status"]').textContent(); + assert( + successMsg.includes('Password updated'), + `Shows success message (got: "${successMsg}")` + ); +}); +``` + +**Step 2: Run test** + +Run: `./tests/e2e/run.sh tests/e2e/test_credentials.js` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add tests/e2e/test_credentials.js +git commit -m "test: add e2e credential management test with password validation" +``` + +--- + +### Task 7: Registration error states test + +**Files:** +- Create: `tests/e2e/test_registration.js` + +Tests magic link edge cases: used token, invalid token. + +**Step 1: Create `tests/e2e/test_registration.js`** + +```javascript +// tests/e2e/test_registration.js +// Tests magic link registration error states. + +const { TARGET_URL, run } = require('./helpers'); + +run(async (page, assert) => { + const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); + + // ---- Invalid token ---- + console.log('\n--- Invalid registration token ---'); + const resp1 = await page.goto(`${TARGET_URL}/register/invalid-token-12345`); + assert(resp1.status() === 400, `Invalid token returns 400 (got: ${resp1.status()})`); + const body1 = await page.locator('body').textContent(); + assert( + body1.includes('Invalid or expired'), + `Shows invalid/expired message (got: "${body1.trim()}")` + ); + + // ---- Used token ---- + console.log('\n--- Used registration token ---'); + assert(fixtures.used_token, 'Used token fixture exists'); + const resp2 = await page.goto(`${TARGET_URL}/register/${fixtures.used_token}`); + assert(resp2.status() === 400, `Used token returns 400 (got: ${resp2.status()})`); + const body2 = await page.locator('body').textContent(); + assert( + body2.includes('Invalid or expired'), + `Shows invalid/expired for used token (got: "${body2.trim()}")` + ); +}); +``` + +**Step 2: Run test** + +Run: `./tests/e2e/run.sh tests/e2e/test_registration.js` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add tests/e2e/test_registration.js +git commit -m "test: add e2e registration error states test (invalid/used tokens)" +``` + +--- + +### Task 8: Health endpoint test + +**Files:** +- Create: `tests/e2e/test_health.js` + +Simple smoke test for the health endpoint. + +**Step 1: Create `tests/e2e/test_health.js`** + +```javascript +// tests/e2e/test_health.js +// Smoke test: health endpoint returns OK. + +const { TARGET_URL, run } = require('./helpers'); + +run(async (page, assert) => { + console.log('\n--- Health endpoint ---'); + const resp = await page.request.get(`${TARGET_URL}/health`); + assert(resp.ok(), `Health endpoint returns 200 (status: ${resp.status()})`); + + const body = await resp.json(); + assert(body.status === 'ok', `Health response is {"status":"ok"} (got: ${JSON.stringify(body)})`); +}); +``` + +**Step 2: Run test** + +Run: `./tests/e2e/run.sh tests/e2e/test_health.js` +Expected: All PASS + +**Step 3: Commit** + +```bash +git add tests/e2e/test_health.js +git commit -m "test: add e2e health endpoint smoke test" +``` + +--- + +### Task 9: Run full suite and verify + +**Step 1: Run the complete e2e suite** + +Run: `./tests/e2e/run.sh` +Expected: All test files pass, server starts and stops cleanly. + +**Step 2: Run the existing pytest suite to ensure no regressions** + +Run: `uv run pytest -x -q` +Expected: All 120 tests pass. + +**Step 3: Final commit (if any cleanup needed)** + +--- + +## Future Enhancements (Not in Scope) + +- **WebAuthn tests** — Playwright supports virtual authenticators via CDP. Could test registration and login with `cdpSession.send('WebAuthn.enable')`. +- **Dark mode tests** — Use `page.emulateMedia({ colorScheme: 'dark' })` to verify dark theme colors. +- **Visual regression** — Screenshot comparison with `expect(page).toHaveScreenshot()` (requires @playwright/test). +- **OIDC flow tests** — Once the OIDC provider is implemented, test authorization code flow end-to-end. diff --git a/docs/plans/2026-02-16-oidc-provider-plan.md b/docs/plans/2026-02-16-oidc-provider-plan.md new file mode 100644 index 0000000..68d6483 --- /dev/null +++ b/docs/plans/2026-02-16-oidc-provider-plan.md @@ -0,0 +1,1705 @@ +# OIDC Provider Implementation Plan (Phase 5) + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Wire up `idpyoidc` to serve standard OIDC endpoints (discovery, authorization code flow, token exchange, userinfo, JWKS) using the existing authentication and user management infrastructure. + +**Architecture:** FastAPI routes wrap idpyoidc's endpoint pipeline (`parse_request` → `process_request` → `do_response`). A custom `PorchlightUserInfo` subclass provides claims from our `UserRepository`. The authorization flow stores pending auth requests in the Starlette session; after login, a resume route creates an idpyoidc session and completes the authorization. + +**Tech Stack:** FastAPI, idpyoidc >=5.0, Starlette SessionMiddleware, aiosqlite. + +**Quality gate:** `./scripts/check.sh` + +**Known constraints:** +- idpyoidc's `UserInfo.__call__` is synchronous. We populate an in-memory claims cache when creating the idpyoidc session. +- idpyoidc stores sessions/grants in memory. Restarting the server invalidates active tokens. +- The `Server` constructor is synchronous and does file I/O for key generation. Call it outside the async lifespan context if needed, or ensure key directories exist beforehand. +- idpyoidc's `OPConfiguration` auto-wraps a plain dict. Provide the config as a dict for simplicity. +- `redirect_uris` in `cdb` must be a list of `(base_url, query_dict)` tuples, not plain strings. +- Client secrets must also be added to the keyjar via `server.keyjar.add_symmetric(client_id, secret)`. + +--- + +### Task 1: Config + Claims Mapping + UserInfo Source + +**Files:** +- Modify: `src/fastapi_oidc_op/config.py` +- Create: `src/fastapi_oidc_op/oidc/claims.py` +- Create: `tests/test_oidc/test_claims.py` + +**Step 1: Write the failing tests** + +Create `tests/test_oidc/test_claims.py`: + +```python +from datetime import UTC, datetime + +from fastapi_oidc_op.models import User +from fastapi_oidc_op.oidc.claims import PorchlightUserInfo, user_to_claims + + +def test_user_to_claims_minimal() -> None: + user = User(userid="lusab-bansen", username="alice") + claims = user_to_claims(user) + assert claims["sub"] == "lusab-bansen" + assert claims["preferred_username"] == "alice" + assert "email" not in claims # None fields excluded + + +def test_user_to_claims_full() -> None: + user = User( + userid="lusab-bansen", + username="alice", + preferred_username="Alice W.", + given_name="Alice", + family_name="Wonderland", + nickname="ali", + email="alice@example.com", + email_verified=True, + phone_number="+1234567890", + phone_number_verified=False, + picture="https://example.com/alice.jpg", + locale="en", + updated_at=datetime(2025, 1, 1, tzinfo=UTC), + ) + claims = user_to_claims(user) + assert claims["sub"] == "lusab-bansen" + assert claims["preferred_username"] == "Alice W." + assert claims["given_name"] == "Alice" + assert claims["family_name"] == "Wonderland" + assert claims["nickname"] == "ali" + assert claims["email"] == "alice@example.com" + assert claims["email_verified"] is True + assert claims["phone_number"] == "+1234567890" + assert claims["phone_number_verified"] is False + assert claims["picture"] == "https://example.com/alice.jpg" + assert claims["locale"] == "en" + assert claims["updated_at"] == int(datetime(2025, 1, 1, tzinfo=UTC).timestamp()) + + +def test_porchlight_userinfo_returns_claims() -> None: + userinfo = PorchlightUserInfo() + userinfo.set_user_claims("lusab-bansen", {"sub": "lusab-bansen", "email": "a@b.com"}) + result = userinfo("lusab-bansen", "client1") + assert result["sub"] == "lusab-bansen" + assert result["email"] == "a@b.com" + + +def test_porchlight_userinfo_filters_claims() -> None: + userinfo = PorchlightUserInfo() + userinfo.set_user_claims("lusab-bansen", {"sub": "lusab-bansen", "email": "a@b.com", "name": "Alice"}) + result = userinfo("lusab-bansen", "client1", user_info_claims={"email": None}) + assert "email" in result + assert "name" not in result + + +def test_porchlight_userinfo_unknown_user() -> None: + userinfo = PorchlightUserInfo() + result = userinfo("unknown", "client1") + assert result == {} +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_oidc/test_claims.py -v` +Expected: FAIL (`ImportError` — module doesn't exist) + +**Step 3: Implement** + +Add to `src/fastapi_oidc_op/config.py`: + +```python +# Signing keys +signing_key_path: str = "data/keys" +``` + +Create `src/fastapi_oidc_op/oidc/__init__.py` (empty file). + +Create `src/fastapi_oidc_op/oidc/claims.py`: + +```python +"""OIDC claims mapping and UserInfo source.""" + +from idpyoidc.server.user_info import UserInfo + +from fastapi_oidc_op.models import User + + +def user_to_claims(user: User) -> dict: + """Convert a User model to an OIDC claims dict. + + Only includes claims that have non-None values. + The 'sub' claim always uses the userid (proquint). + """ + claims: dict = {"sub": user.userid} + + # preferred_username: use explicit field, fall back to username + claims["preferred_username"] = user.preferred_username or user.username + + optional_fields = { + "given_name": user.given_name, + "family_name": user.family_name, + "nickname": user.nickname, + "email": user.email, + "email_verified": user.email_verified if user.email else None, + "phone_number": user.phone_number, + "phone_number_verified": user.phone_number_verified if user.phone_number else None, + "picture": user.picture, + "locale": user.locale, + } + + for claim_name, value in optional_fields.items(): + if value is not None: + claims[claim_name] = value + + # updated_at as Unix timestamp (OIDC spec requires number) + if user.updated_at: + claims["updated_at"] = int(user.updated_at.timestamp()) + + return claims + + +class PorchlightUserInfo(UserInfo): + """UserInfo source backed by an in-memory claims cache. + + Claims are populated via set_user_claims() after authentication. + idpyoidc calls __call__() synchronously to look up claims. + """ + + def __init__(self, **kwargs): # type: ignore[no-untyped-def] + super().__init__(db={}, **kwargs) + + def set_user_claims(self, user_id: str, claims: dict) -> None: + """Populate claims cache for a user.""" + self.db[user_id] = claims +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_oidc/test_claims.py -v` +Expected: PASS + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/config.py src/fastapi_oidc_op/oidc/ tests/test_oidc/test_claims.py +git commit -m "feat: add OIDC claims mapping and PorchlightUserInfo source" +``` + +--- + +### Task 2: idpyoidc Server Initialization + +**Files:** +- Create: `src/fastapi_oidc_op/oidc/provider.py` +- Create: `tests/test_oidc/test_provider.py` + +**Step 1: Write the failing tests** + +Create `tests/test_oidc/test_provider.py`: + +```python +import shutil +from pathlib import Path + +from fastapi_oidc_op.config import Settings +from fastapi_oidc_op.oidc.provider import create_oidc_server + + +def test_create_server_has_endpoints() -> None: + key_path = Path("test_keys_provider") + key_path.mkdir(exist_ok=True) + try: + settings = Settings(issuer="http://localhost:8000", sqlite_path=":memory:", signing_key_path=str(key_path)) + server = create_oidc_server(settings) + assert "authorization" in server.endpoint + assert "token" in server.endpoint + assert "userinfo" in server.endpoint + assert "provider_config" in server.endpoint + finally: + shutil.rmtree(key_path, ignore_errors=True) + + +def test_create_server_has_issuer() -> None: + key_path = Path("test_keys_issuer") + key_path.mkdir(exist_ok=True) + try: + settings = Settings(issuer="http://localhost:8000", sqlite_path=":memory:", signing_key_path=str(key_path)) + server = create_oidc_server(settings) + assert server.context.issuer == "http://localhost:8000" + finally: + shutil.rmtree(key_path, ignore_errors=True) + + +def test_create_server_jwks_available() -> None: + key_path = Path("test_keys_jwks") + key_path.mkdir(exist_ok=True) + try: + settings = Settings(issuer="http://localhost:8000", sqlite_path=":memory:", signing_key_path=str(key_path)) + server = create_oidc_server(settings) + keys = server.keyjar.export_jwks() + assert "keys" in keys + assert len(keys["keys"]) > 0 + finally: + shutil.rmtree(key_path, ignore_errors=True) + + +def test_create_server_userinfo_is_porchlight() -> None: + key_path = Path("test_keys_userinfo") + key_path.mkdir(exist_ok=True) + try: + settings = Settings(issuer="http://localhost:8000", sqlite_path=":memory:", signing_key_path=str(key_path)) + server = create_oidc_server(settings) + from fastapi_oidc_op.oidc.claims import PorchlightUserInfo + assert isinstance(server.context.userinfo, PorchlightUserInfo) + finally: + shutil.rmtree(key_path, ignore_errors=True) +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_oidc/test_provider.py -v` +Expected: FAIL (`ImportError`) + +**Step 3: Implement** + +Create `src/fastapi_oidc_op/oidc/provider.py`: + +```python +"""Initialize the idpyoidc OIDC Server.""" + +from pathlib import Path + +from idpyoidc.server import Server + +from fastapi_oidc_op.config import Settings +from fastapi_oidc_op.oidc.claims import PorchlightUserInfo + + +def _build_server_config(settings: Settings) -> dict: + """Build the idpyoidc server configuration dict.""" + key_path = Path(settings.signing_key_path) + key_path.mkdir(parents=True, exist_ok=True) + + return { + "issuer": settings.issuer, + "key_conf": { + "key_defs": [ + {"type": "RSA", "use": ["sig"]}, + {"type": "EC", "crv": "P-256", "use": ["sig"]}, + ], + "uri_path": "jwks", + "private_path": str(key_path / "private_jwks.json"), + "read_only": False, + }, + "endpoint": { + "provider_config": { + "path": ".well-known/openid-configuration", + "class": "idpyoidc.server.oidc.provider_config.ProviderConfiguration", + "kwargs": {"client_authn_method": None}, + }, + "authorization": { + "path": "authorization", + "class": "idpyoidc.server.oidc.authorization.Authorization", + "kwargs": { + "client_authn_method": None, + "response_types_supported": ["code"], + "response_modes_supported": ["query"], + }, + }, + "token": { + "path": "token", + "class": "idpyoidc.server.oidc.token.Token", + "kwargs": { + "client_authn_method": ["client_secret_post", "client_secret_basic"], + }, + }, + "userinfo": { + "path": "userinfo", + "class": "idpyoidc.server.oidc.userinfo.UserInfo", + "kwargs": {}, + }, + }, + "userinfo": { + "class": PorchlightUserInfo, + "kwargs": {}, + }, + "authentication": {}, + "authz": { + "class": "idpyoidc.server.authz.AuthzHandling", + "kwargs": { + "grant_config": { + "usage_rules": { + "authorization_code": { + "supports_minting": ["access_token", "refresh_token", "id_token"], + "max_usage": 1, + "expires_in": 120, + }, + "access_token": {"expires_in": 3600}, + "refresh_token": { + "supports_minting": ["access_token", "refresh_token", "id_token"], + "expires_in": 86400, + }, + }, + "expires_in": 2592000, + }, + }, + }, + "token_handler_args": { + "jwks_file": str(key_path / "token_jwks.json"), + "code": {"kwargs": {"lifetime": 600}}, + "token": { + "class": "idpyoidc.server.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 3600, + "add_claims_by_scope": True, + "aud": [settings.issuer], + }, + }, + "refresh": { + "class": "idpyoidc.server.token.jwt_token.JWTToken", + "kwargs": { + "lifetime": 86400, + "aud": [settings.issuer], + }, + }, + "id_token": { + "class": "idpyoidc.server.token.id_token.IDToken", + "kwargs": { + "lifetime": 3600, + "add_claims_by_scope": True, + }, + }, + }, + "scopes_to_claims": { + "openid": ["sub"], + "profile": [ + "preferred_username", + "given_name", + "family_name", + "nickname", + "picture", + "locale", + "updated_at", + ], + "email": ["email", "email_verified"], + "phone": ["phone_number", "phone_number_verified"], + }, + } + + +def create_oidc_server(settings: Settings) -> Server: + """Create and configure the idpyoidc Server instance.""" + config = _build_server_config(settings) + server = Server(conf=config) + return server +``` + +Note: The exact config structure may need adjustments based on what idpyoidc accepts. If the `userinfo` config with a class instance doesn't work, we may need to set `server.context.userinfo` directly after creation: + +```python +server = Server(conf=config) +server.context.userinfo = PorchlightUserInfo() +``` + +Run the tests and adjust accordingly. + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_oidc/test_provider.py -v` +Expected: PASS + +If `server.context.userinfo` is not a `PorchlightUserInfo` instance, the last test will guide us to fix the initialization. + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/oidc/provider.py tests/test_oidc/test_provider.py +git commit -m "feat: add idpyoidc server initialization" +``` + +--- + +### Task 3: App Integration (Lifespan + Client Registration) + +**Files:** +- Modify: `src/fastapi_oidc_op/app.py` +- Modify: `tests/test_app.py` + +**Step 1: Write the failing tests** + +Add to `tests/test_app.py` (or create `tests/test_oidc/test_app_integration.py`): + +```python +from httpx import AsyncClient + + +async def test_oidc_server_on_app_state(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + assert hasattr(app.state, "oidc_server") + assert app.state.oidc_server is not None + + +async def test_manage_client_registered(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + oidc_server = app.state.oidc_server + settings = app.state.settings + assert settings.manage_client_id in oidc_server.context.cdb +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_oidc/test_app_integration.py -v` +Expected: FAIL (`app.state.oidc_server` doesn't exist) + +**Step 3: Implement** + +Modify `src/fastapi_oidc_op/app.py` to: +1. Import `create_oidc_server` from `oidc.provider` +2. In the lifespan, after setting up repos and services, create the OIDC server +3. Register the management client in `server.context.cdb` +4. Store the server on `app.state.oidc_server` + +In the lifespan, after the existing service setup: + +```python +from fastapi_oidc_op.oidc.provider import create_oidc_server + +# OIDC Server +oidc_server = create_oidc_server(settings) +app.state.oidc_server = oidc_server + +# Register management client +import secrets as _secrets +manage_secret = settings.session_secret or _secrets.token_hex(32) +oidc_server.context.cdb[settings.manage_client_id] = { + "client_id": settings.manage_client_id, + "client_secret": manage_secret, + "redirect_uris": [(f"{settings.issuer}/manage/callback", {})], + "response_types_supported": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": ["openid", "profile", "email"], + "allowed_scopes": ["openid", "profile", "email"], + "client_salt": _secrets.token_hex(8), +} +oidc_server.keyjar.add_symmetric(settings.manage_client_id, manage_secret) +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_oidc/test_app_integration.py -v` +Expected: PASS + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green (including existing 120 tests) + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/app.py tests/test_oidc/test_app_integration.py +git commit -m "feat: integrate idpyoidc server into app lifespan" +``` + +--- + +### Task 4: Discovery + JWKS Endpoints + +**Files:** +- Create: `src/fastapi_oidc_op/oidc/endpoints.py` +- Modify: `src/fastapi_oidc_op/app.py` +- Create: `tests/test_oidc/test_discovery.py` + +**Step 1: Write the failing tests** + +Create `tests/test_oidc/test_discovery.py`: + +```python +from httpx import AsyncClient + + +async def test_discovery_endpoint_returns_metadata(client: AsyncClient) -> None: + res = await client.get("/.well-known/openid-configuration") + assert res.status_code == 200 + data = res.json() + assert data["issuer"] == "http://localhost:8000" + assert "authorization_endpoint" in data + assert "token_endpoint" in data + assert "userinfo_endpoint" in data + assert "jwks_uri" in data + + +async def test_discovery_response_types_supported(client: AsyncClient) -> None: + res = await client.get("/.well-known/openid-configuration") + data = res.json() + assert "code" in data["response_types_supported"] + + +async def test_discovery_scopes_supported(client: AsyncClient) -> None: + res = await client.get("/.well-known/openid-configuration") + data = res.json() + assert "openid" in data["scopes_supported"] + + +async def test_jwks_endpoint_returns_keys(client: AsyncClient) -> None: + res = await client.get("/jwks") + assert res.status_code == 200 + data = res.json() + assert "keys" in data + assert len(data["keys"]) > 0 + # Each key should have kty and use + for key in data["keys"]: + assert "kty" in key +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_oidc/test_discovery.py -v` +Expected: FAIL (404 — routes don't exist) + +**Step 3: Implement** + +Create `src/fastapi_oidc_op/oidc/endpoints.py`: + +```python +"""FastAPI routes wrapping idpyoidc endpoint processing.""" + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +router = APIRouter(tags=["oidc"]) + + +@router.get("/.well-known/openid-configuration") +async def provider_configuration(request: Request) -> JSONResponse: + """OIDC Discovery endpoint.""" + oidc_server = request.app.state.oidc_server + endpoint = oidc_server.get_endpoint("provider_config") + + parsed = endpoint.parse_request({}) + result = endpoint.process_request(parsed) + response_info = endpoint.do_response(response_args=result.get("response_args"), request=parsed, **result) + + return JSONResponse(content=response_info["response"].to_dict()) + + +@router.get("/jwks") +async def jwks(request: Request) -> JSONResponse: + """Public signing keys (JWK Set).""" + oidc_server = request.app.state.oidc_server + keys = oidc_server.keyjar.export_jwks() + return JSONResponse(content=keys) +``` + +Modify `src/fastapi_oidc_op/app.py` to include the OIDC router: + +```python +from fastapi_oidc_op.oidc.endpoints import router as oidc_router +# ... +app.include_router(oidc_router) +``` + +Note: The discovery endpoint implementation depends on how `do_response` returns data. If `response_info["response"]` is a string (JSON), parse it. If it's a `Message` object, call `.to_dict()`. Adjust based on test output. + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_oidc/test_discovery.py -v` +Expected: PASS + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/oidc/endpoints.py src/fastapi_oidc_op/app.py tests/test_oidc/test_discovery.py +git commit -m "feat: add OIDC discovery and JWKS endpoints" +``` + +--- + +### Task 5: Authorization Endpoint (Redirect to Login) + +**Files:** +- Modify: `src/fastapi_oidc_op/oidc/endpoints.py` +- Create: `tests/test_oidc/test_authorization.py` + +This task implements the first half of the authorization flow: receiving the request from an RP and redirecting to the login page when the user is not authenticated. + +**Step 1: Write the failing tests** + +Create `tests/test_oidc/test_authorization.py`: + +```python +import secrets + +from httpx import AsyncClient + + +def _register_test_client(client: AsyncClient, client_id: str = "test-rp", redirect_uri: str = "http://localhost:9000/callback") -> str: + """Register a test client in the OIDC server. Returns client_secret.""" + app = client._transport.app # type: ignore[union-attr] + oidc_server = app.state.oidc_server + client_secret = secrets.token_hex(16) + oidc_server.context.cdb[client_id] = { + "client_id": client_id, + "client_secret": client_secret, + "redirect_uris": [(redirect_uri, {})], + "response_types_supported": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": ["openid", "profile", "email"], + "allowed_scopes": ["openid", "profile", "email"], + "client_salt": secrets.token_hex(8), + } + oidc_server.keyjar.add_symmetric(client_id, client_secret) + return client_secret + + +async def test_authorization_redirects_to_login_when_unauthenticated(client: AsyncClient) -> None: + _register_test_client(client) + res = await client.get( + "/authorization", + params={ + "response_type": "code", + "client_id": "test-rp", + "redirect_uri": "http://localhost:9000/callback", + "scope": "openid", + "state": "test-state", + "nonce": "test-nonce", + }, + follow_redirects=False, + ) + assert res.status_code in (302, 303) + assert "/login" in res.headers["location"] + + +async def test_authorization_stores_auth_request_in_session(client: AsyncClient) -> None: + _register_test_client(client) + # After the redirect, the auth request should be stored in the session + # We verify this indirectly: make the auth request, then check that + # GET /login works (the session cookie is set by the redirect) + res = await client.get( + "/authorization", + params={ + "response_type": "code", + "client_id": "test-rp", + "redirect_uri": "http://localhost:9000/callback", + "scope": "openid", + "state": "test-state", + "nonce": "test-nonce", + }, + follow_redirects=False, + ) + assert res.status_code in (302, 303) + + # Follow up: login page should be accessible + login_res = await client.get("/login") + assert login_res.status_code == 200 + + +async def test_authorization_invalid_client_returns_error(client: AsyncClient) -> None: + res = await client.get( + "/authorization", + params={ + "response_type": "code", + "client_id": "nonexistent", + "redirect_uri": "http://evil.com/callback", + "scope": "openid", + "state": "test-state", + }, + follow_redirects=False, + ) + # Should return an error (not redirect to evil.com) + assert res.status_code >= 400 or "error" in res.text.lower() +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_oidc/test_authorization.py -v` +Expected: FAIL (404 — `/authorization` doesn't exist) + +**Step 3: Implement** + +Add to `src/fastapi_oidc_op/oidc/endpoints.py`: + +```python +from fastapi.responses import RedirectResponse, HTMLResponse +from urllib.parse import urlencode + +@router.get("/authorization") +async def authorization(request: Request) -> Response: + """OIDC Authorization endpoint. + + Validates the authorization request, then: + - If user is authenticated (session): creates grant + redirects to RP + - If not authenticated: stores request in session, redirects to /login + """ + oidc_server = request.app.state.oidc_server + endpoint = oidc_server.get_endpoint("authorization") + + # Build the query string from request params + query_params = dict(request.query_params) + + try: + parsed = endpoint.parse_request(query_params) + except Exception as exc: + return HTMLResponse(f"

Invalid Request

{exc}

", status_code=400) + + # Check for parse errors + if "error" in parsed: + error_desc = parsed.get("error_description", parsed["error"]) + return HTMLResponse(f"

Error

{error_desc}

", status_code=400) + + # Check if user is authenticated via Starlette session + userid = request.session.get("userid") + username = request.session.get("username") + + if userid and username: + # User is authenticated — complete the authorization + return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username) + + # Not authenticated — store auth request in session and redirect to login + # Store as a serializable dict + request.session["oidc_auth_request"] = query_params + return RedirectResponse("/login", status_code=303) +``` + +The `_complete_authorization` helper will be implemented in Task 7 (authorization complete). For now, make it return a placeholder: + +```python +async def _complete_authorization(request, oidc_server, endpoint, parsed, userid, username): + """Complete the authorization after authentication. Implemented in Task 7.""" + return HTMLResponse("Authorization completion not yet implemented", status_code=501) +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_oidc/test_authorization.py -v` +Expected: PASS + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/oidc/endpoints.py tests/test_oidc/test_authorization.py +git commit -m "feat: add authorization endpoint with login redirect" +``` + +--- + +### Task 6: Login Route Integration (Resume OIDC Flow) + +**Files:** +- Modify: `src/fastapi_oidc_op/authn/routes.py` +- Create: `tests/test_oidc/test_login_oidc_redirect.py` + +After a successful login, if there's a pending OIDC authorization request in the session, the login routes should redirect to `/authorization/complete` instead of `/manage/credentials`. + +**Step 1: Write the failing tests** + +Create `tests/test_oidc/test_login_oidc_redirect.py`: + +```python +import secrets +from datetime import UTC, datetime + +from argon2 import PasswordHasher +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User + + +def _register_test_client(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + oidc_server = app.state.oidc_server + oidc_server.context.cdb["test-rp"] = { + "client_id": "test-rp", + "client_secret": "test-secret", + "redirect_uris": [("http://localhost:9000/callback", {})], + "response_types_supported": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": ["openid", "profile", "email"], + "allowed_scopes": ["openid", "profile", "email"], + "client_salt": secrets.token_hex(8), + } + oidc_server.keyjar.add_symmetric("test-rp", "test-secret") + + +async def _create_user(client: AsyncClient) -> None: + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + user = User(userid="lusab-bansen", username="alice", created_at=datetime.now(UTC), updated_at=datetime.now(UTC)) + await user_repo.create(user) + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass"))) + + +async def test_login_with_pending_oidc_redirects_to_authorization_complete(client: AsyncClient) -> None: + _register_test_client(client) + await _create_user(client) + + # Step 1: Start OIDC authorization (stores request in session, redirects to /login) + auth_res = await client.get( + "/authorization", + params={ + "response_type": "code", + "client_id": "test-rp", + "redirect_uri": "http://localhost:9000/callback", + "scope": "openid", + "state": "test-state", + "nonce": "test-nonce", + }, + follow_redirects=False, + ) + assert auth_res.status_code in (302, 303) + + # Step 2: Login via password + login_res = await client.post( + "/login/password", + data={"username": "alice", "password": "testpass"}, + headers={"HX-Request": "true"}, + ) + assert login_res.status_code == 200 + # Should redirect to /authorization/complete, not /manage/credentials + redirect_target = login_res.headers.get("HX-Redirect", "") + assert "/authorization/complete" in redirect_target + + +async def test_login_without_pending_oidc_redirects_to_manage(client: AsyncClient) -> None: + await _create_user(client) + + login_res = await client.post( + "/login/password", + data={"username": "alice", "password": "testpass"}, + headers={"HX-Request": "true"}, + ) + assert login_res.status_code == 200 + redirect_target = login_res.headers.get("HX-Redirect", "") + assert redirect_target == "/manage/credentials" +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_oidc/test_login_oidc_redirect.py -v` +Expected: FAIL (login always redirects to `/manage/credentials`) + +**Step 3: Implement** + +Modify `src/fastapi_oidc_op/authn/routes.py`: + +Add a helper function: + +```python +def _login_redirect_target(request: Request) -> str: + """Determine where to redirect after successful login. + + If there's a pending OIDC authorization request, redirect to complete it. + Otherwise, redirect to credential management. + """ + if "oidc_auth_request" in request.session: + return "/authorization/complete" + return "/manage/credentials" +``` + +In `login_password`, change: +```python +response.headers["HX-Redirect"] = "/manage/credentials" +``` +to: +```python +response.headers["HX-Redirect"] = _login_redirect_target(request) +``` + +In `login_webauthn_complete`, change the same line: +```python +response.headers["HX-Redirect"] = "/manage/credentials" +``` +to: +```python +response.headers["HX-Redirect"] = _login_redirect_target(request) +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_oidc/test_login_oidc_redirect.py -v` +Expected: PASS + +Also run existing login tests to make sure nothing broke: +Run: `uv run pytest tests/test_auth_routes/ -v` +Expected: All PASS + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/authn/routes.py tests/test_oidc/test_login_oidc_redirect.py +git commit -m "feat: redirect to OIDC authorization after login when pending" +``` + +--- + +### Task 7: Authorization Complete + Token Endpoint + +**Files:** +- Modify: `src/fastapi_oidc_op/oidc/endpoints.py` +- Create: `tests/test_oidc/test_token.py` + +This is the core task: completing the authorization flow and implementing the token endpoint. The `/authorization/complete` route creates an idpyoidc session, mints an authorization code, and redirects to the RP. The token endpoint exchanges the code for tokens. + +**Step 1: Write the failing tests** + +Create `tests/test_oidc/test_token.py`: + +```python +import secrets +from base64 import b64encode +from datetime import UTC, datetime +from urllib.parse import parse_qs, urlparse + +from argon2 import PasswordHasher +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User + + +def _register_test_client(client: AsyncClient) -> str: + app = client._transport.app # type: ignore[union-attr] + oidc_server = app.state.oidc_server + client_secret = "test-secret-123" + oidc_server.context.cdb["test-rp"] = { + "client_id": "test-rp", + "client_secret": client_secret, + "redirect_uris": [("http://localhost:9000/callback", {})], + "response_types_supported": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": ["openid", "profile", "email"], + "allowed_scopes": ["openid", "profile", "email"], + "client_salt": secrets.token_hex(8), + } + oidc_server.keyjar.add_symmetric("test-rp", client_secret) + return client_secret + + +async def _create_user_and_login(client: AsyncClient) -> str: + """Create user, log in, return userid.""" + app = client._transport.app # type: ignore[union-attr] + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + user = User( + userid="lusab-bansen", + username="alice", + email="alice@example.com", + email_verified=True, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + await user_repo.create(user) + + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass"))) + + await client.post( + "/login/password", + data={"username": "alice", "password": "testpass"}, + headers={"HX-Request": "true"}, + ) + return user.userid + + +async def _get_authorization_code(client: AsyncClient) -> str: + """Run full auth flow and extract the authorization code.""" + client_secret = _register_test_client(client) + + # Start authorization + auth_res = await client.get( + "/authorization", + params={ + "response_type": "code", + "client_id": "test-rp", + "redirect_uri": "http://localhost:9000/callback", + "scope": "openid profile email", + "state": "test-state", + "nonce": "test-nonce", + }, + follow_redirects=False, + ) + assert auth_res.status_code in (302, 303) + + # Create user and log in + await _create_user_and_login(client) + + # Start authorization again (now authenticated) + auth_res2 = await client.get( + "/authorization", + params={ + "response_type": "code", + "client_id": "test-rp", + "redirect_uri": "http://localhost:9000/callback", + "scope": "openid profile email", + "state": "test-state", + "nonce": "test-nonce", + }, + follow_redirects=False, + ) + assert auth_res2.status_code in (302, 303) + + # Parse the redirect URL to extract the code + location = auth_res2.headers["location"] + parsed = urlparse(location) + params = parse_qs(parsed.query) + assert "code" in params, f"No code in redirect: {location}" + return params["code"][0] + + +async def test_authorization_complete_redirects_with_code(client: AsyncClient) -> None: + code = await _get_authorization_code(client) + assert len(code) > 0 + + +async def test_token_endpoint_exchanges_code(client: AsyncClient) -> None: + code = await _get_authorization_code(client) + client_secret = "test-secret-123" + + # Exchange code for tokens + auth_header = b64encode(f"test-rp:{client_secret}".encode()).decode() + token_res = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://localhost:9000/callback", + }, + headers={ + "Authorization": f"Basic {auth_header}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + assert token_res.status_code == 200 + data = token_res.json() + assert "access_token" in data + assert "id_token" in data + assert data["token_type"].lower() == "bearer" + + +async def test_token_endpoint_invalid_code_returns_error(client: AsyncClient) -> None: + _register_test_client(client) + client_secret = "test-secret-123" + + auth_header = b64encode(f"test-rp:{client_secret}".encode()).decode() + token_res = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": "invalid-code", + "redirect_uri": "http://localhost:9000/callback", + }, + headers={ + "Authorization": f"Basic {auth_header}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + assert token_res.status_code == 400 or "error" in token_res.json() +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_oidc/test_token.py -v` +Expected: FAIL + +**Step 3: Implement** + +Modify `src/fastapi_oidc_op/oidc/endpoints.py`: + +Replace the placeholder `_complete_authorization` with the real implementation, and add the token and authorization complete endpoints: + +```python +import json +from urllib.parse import urlencode + +from fastapi import APIRouter, Request, Response +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse +from idpyoidc.server.authn_event import create_authn_event +from idpyoidc.time_util import utc_time_sans_frac + +from fastapi_oidc_op.oidc.claims import PorchlightUserInfo, user_to_claims + +router = APIRouter(tags=["oidc"]) + + +async def _complete_authorization( + request: Request, + oidc_server, + endpoint, + parsed_request, + userid: str, + username: str, +) -> Response: + """Complete OIDC authorization after user authentication. + + Creates an idpyoidc session, populates the userinfo cache, and + returns a redirect to the RP with an authorization code. + """ + # Populate userinfo cache + user_repo = request.app.state.user_repo + user = await user_repo.get_by_userid(userid) + if user is None: + return HTMLResponse("

Error

User not found

", status_code=400) + + userinfo: PorchlightUserInfo = oidc_server.context.userinfo + claims = user_to_claims(user) + userinfo.set_user_claims(username, claims) + + # Create idpyoidc session + session_id = endpoint.create_session( + request=parsed_request, + user_id=username, + acr="urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport", + time_stamp=utc_time_sans_frac(), + authn_method=None, + ) + + # Complete authorization (mints code, builds redirect) + result = endpoint.authz_part2(request=parsed_request, session_id=session_id) + + if "error" in result: + error_desc = result.get("error_description", result["error"]) + return HTMLResponse(f"

Error

{error_desc}

", status_code=400) + + resp_info = endpoint.do_response( + response_args=result.get("response_args"), + request=parsed_request, + **result, + ) + + # The response is the redirect URL + redirect_url = resp_info["response"] + if hasattr(redirect_url, "to_dict"): + # It's a Message object — build URL from return_uri + params + params = redirect_url.to_dict() + return_uri = result.get("return_uri", parsed_request.get("redirect_uri", "")) + redirect_url = f"{return_uri}?{urlencode(params)}" + + return RedirectResponse(str(redirect_url), status_code=303) + + +@router.get("/authorization/complete") +async def authorization_complete(request: Request) -> Response: + """Resume OIDC authorization after login. + + Called after the user authenticates via /login. Pops the pending + auth request from the session and completes the authorization. + """ + oidc_server = request.app.state.oidc_server + endpoint = oidc_server.get_endpoint("authorization") + + # Pop the stored auth request + auth_request_params = request.session.pop("oidc_auth_request", None) + if auth_request_params is None: + return HTMLResponse("

Error

No pending authorization request

", status_code=400) + + userid = request.session.get("userid") + username = request.session.get("username") + if not userid or not username: + return RedirectResponse("/login", status_code=303) + + # Re-parse the original authorization request + try: + parsed = endpoint.parse_request(auth_request_params) + except Exception as exc: + return HTMLResponse(f"

Error

{exc}

", status_code=400) + + if "error" in parsed: + error_desc = parsed.get("error_description", parsed["error"]) + return HTMLResponse(f"

Error

{error_desc}

", status_code=400) + + return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username) + + +@router.post("/token") +async def token_endpoint(request: Request) -> JSONResponse: + """OIDC Token endpoint.""" + oidc_server = request.app.state.oidc_server + endpoint = oidc_server.get_endpoint("token") + + # Read form body + body = await request.body() + body_str = body.decode("utf-8") + + # Build http_info for client authentication + http_info = { + "headers": dict(request.headers), + "url": str(request.url), + } + + try: + parsed = endpoint.parse_request(body_str, http_info=http_info) + except Exception as exc: + return JSONResponse({"error": "invalid_request", "error_description": str(exc)}, status_code=400) + + if "error" in parsed: + return JSONResponse(parsed.to_dict(), status_code=400) + + result = endpoint.process_request(parsed) + + if "error" in result: + return JSONResponse(result, status_code=400) + + resp_info = endpoint.do_response(response_args=result.get("response_args"), request=parsed, **result) + + # Parse the response + response_data = resp_info["response"] + if isinstance(response_data, str): + response_data = json.loads(response_data) + elif hasattr(response_data, "to_dict"): + response_data = response_data.to_dict() + + return JSONResponse(response_data) +``` + +Note: The exact way idpyoidc returns responses (as strings, Message objects, or dicts) may vary. The implementation should handle all cases. Adjust based on test output. + +Also note: `create_session` with `authn_method=None` may fail. If so, create a minimal `UserAuthnMethod` subclass or pass a different value. The test output will guide the fix. + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_oidc/test_token.py -v` +Expected: PASS + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/oidc/endpoints.py tests/test_oidc/test_token.py +git commit -m "feat: add authorization complete and token endpoints" +``` + +--- + +### Task 8: UserInfo Endpoint + +**Files:** +- Modify: `src/fastapi_oidc_op/oidc/endpoints.py` +- Create: `tests/test_oidc/test_userinfo.py` + +**Step 1: Write the failing tests** + +Create `tests/test_oidc/test_userinfo.py`: + +```python +import secrets +from base64 import b64encode +from datetime import UTC, datetime +from urllib.parse import parse_qs, urlparse + +from argon2 import PasswordHasher +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User + + +def _register_test_client(client: AsyncClient) -> str: + app = client._transport.app # type: ignore[union-attr] + oidc_server = app.state.oidc_server + client_secret = "test-secret-456" + oidc_server.context.cdb["test-rp"] = { + "client_id": "test-rp", + "client_secret": client_secret, + "redirect_uris": [("http://localhost:9000/callback", {})], + "response_types_supported": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": ["openid", "profile", "email"], + "allowed_scopes": ["openid", "profile", "email"], + "client_salt": secrets.token_hex(8), + } + oidc_server.keyjar.add_symmetric("test-rp", client_secret) + return client_secret + + +async def _get_access_token(client: AsyncClient) -> str: + """Full OIDC flow to get an access token.""" + client_secret = _register_test_client(client) + app = client._transport.app # type: ignore[union-attr] + + # Create user + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + user = User( + userid="lusab-bansen", + username="alice", + email="alice@example.com", + email_verified=True, + given_name="Alice", + family_name="Wonderland", + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + await user_repo.create(user) + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass"))) + + # Start auth flow + await client.get( + "/authorization", + params={ + "response_type": "code", + "client_id": "test-rp", + "redirect_uri": "http://localhost:9000/callback", + "scope": "openid profile email", + "state": "test-state", + "nonce": "test-nonce", + }, + follow_redirects=False, + ) + + # Login + await client.post( + "/login/password", + data={"username": "alice", "password": "testpass"}, + headers={"HX-Request": "true"}, + ) + + # Complete authorization + auth_res = await client.get("/authorization/complete", follow_redirects=False) + location = auth_res.headers["location"] + params = parse_qs(urlparse(location).query) + code = params["code"][0] + + # Exchange code for tokens + auth_header = b64encode(f"test-rp:{client_secret}".encode()).decode() + token_res = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://localhost:9000/callback", + }, + headers={ + "Authorization": f"Basic {auth_header}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + return token_res.json()["access_token"] + + +async def test_userinfo_returns_claims(client: AsyncClient) -> None: + access_token = await _get_access_token(client) + + res = await client.get( + "/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert res.status_code == 200 + data = res.json() + assert data["sub"] == "lusab-bansen" + + +async def test_userinfo_includes_email_claims(client: AsyncClient) -> None: + access_token = await _get_access_token(client) + + res = await client.get( + "/userinfo", + headers={"Authorization": f"Bearer {access_token}"}, + ) + data = res.json() + assert data.get("email") == "alice@example.com" + assert data.get("email_verified") is True + + +async def test_userinfo_invalid_token_returns_error(client: AsyncClient) -> None: + res = await client.get( + "/userinfo", + headers={"Authorization": "Bearer invalid-token"}, + ) + assert res.status_code in (401, 403) +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_oidc/test_userinfo.py -v` +Expected: FAIL + +**Step 3: Implement** + +Add to `src/fastapi_oidc_op/oidc/endpoints.py`: + +```python +@router.get("/userinfo") +@router.post("/userinfo") +async def userinfo_endpoint(request: Request) -> Response: + """OIDC UserInfo endpoint.""" + oidc_server = request.app.state.oidc_server + endpoint = oidc_server.get_endpoint("userinfo") + + # Extract access token from Authorization header + http_info = { + "headers": dict(request.headers), + "url": str(request.url), + } + + # For POST requests, include the body + if request.method == "POST": + body = await request.body() + request_data = body.decode("utf-8") + else: + request_data = {} + + try: + parsed = endpoint.parse_request(request_data, http_info=http_info) + except Exception: + return JSONResponse({"error": "invalid_token"}, status_code=401) + + if "error" in parsed: + return JSONResponse(parsed.to_dict() if hasattr(parsed, "to_dict") else parsed, status_code=401) + + result = endpoint.process_request(parsed) + + if "error" in result: + return JSONResponse(result, status_code=401) + + resp_info = endpoint.do_response(response_args=result.get("response_args"), request=parsed, **result) + + response_data = resp_info["response"] + if isinstance(response_data, str): + response_data = json.loads(response_data) + elif hasattr(response_data, "to_dict"): + response_data = response_data.to_dict() + + return JSONResponse(response_data) +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_oidc/test_userinfo.py -v` +Expected: PASS + +**Step 5: Run quality gate** + +Run: `./scripts/check.sh` +Expected: All green + +**Step 6: Commit** + +```bash +git add src/fastapi_oidc_op/oidc/endpoints.py tests/test_oidc/test_userinfo.py +git commit -m "feat: add OIDC userinfo endpoint" +``` + +--- + +### Task 9: End-to-End Flow Test + +**Files:** +- Create: `tests/test_oidc/test_e2e_flow.py` + +A comprehensive test that exercises the entire OIDC flow from start to finish, including verifying the ID token. + +**Step 1: Write the test** + +Create `tests/test_oidc/test_e2e_flow.py`: + +```python +"""End-to-end OIDC Authorization Code flow test.""" + +import json +import secrets +from base64 import b64encode +from datetime import UTC, datetime +from urllib.parse import parse_qs, urlparse + +from argon2 import PasswordHasher +from cryptojwt.jwk.jwk import key_from_jwk_dict +from cryptojwt.jws.jws import JWS +from httpx import AsyncClient + +from fastapi_oidc_op.authn.password import PasswordService +from fastapi_oidc_op.models import PasswordCredential, User + + +async def test_full_authorization_code_flow(client: AsyncClient) -> None: + """Complete OIDC Authorization Code flow: authorize → login → code → token → userinfo → validate ID token.""" + app = client._transport.app # type: ignore[union-attr] + oidc_server = app.state.oidc_server + user_repo = app.state.user_repo + cred_repo = app.state.credential_repo + + # --- Setup: register client --- + client_secret = "e2e-secret" + oidc_server.context.cdb["e2e-rp"] = { + "client_id": "e2e-rp", + "client_secret": client_secret, + "redirect_uris": [("http://localhost:9000/callback", {})], + "response_types_supported": ["code"], + "token_endpoint_auth_method": "client_secret_basic", + "scope": ["openid", "profile", "email"], + "allowed_scopes": ["openid", "profile", "email"], + "client_salt": secrets.token_hex(8), + } + oidc_server.keyjar.add_symmetric("e2e-rp", client_secret) + + # --- Setup: create user --- + user = User( + userid="lusab-bansen", + username="alice", + given_name="Alice", + family_name="Wonderland", + email="alice@example.com", + email_verified=True, + created_at=datetime.now(UTC), + updated_at=datetime.now(UTC), + ) + await user_repo.create(user) + svc = PasswordService(hasher=PasswordHasher(time_cost=1, memory_cost=8192)) + await cred_repo.create_password(PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass"))) + + # --- Step 1: Authorization request (unauthenticated) --- + auth_res = await client.get( + "/authorization", + params={ + "response_type": "code", + "client_id": "e2e-rp", + "redirect_uri": "http://localhost:9000/callback", + "scope": "openid profile email", + "state": "e2e-state", + "nonce": "e2e-nonce", + }, + follow_redirects=False, + ) + assert auth_res.status_code in (302, 303), f"Expected redirect, got {auth_res.status_code}" + assert "/login" in auth_res.headers["location"] + + # --- Step 2: Password login --- + login_res = await client.post( + "/login/password", + data={"username": "alice", "password": "testpass"}, + headers={"HX-Request": "true"}, + ) + assert login_res.status_code == 200 + assert "/authorization/complete" in login_res.headers.get("HX-Redirect", "") + + # --- Step 3: Complete authorization --- + complete_res = await client.get("/authorization/complete", follow_redirects=False) + assert complete_res.status_code in (302, 303) + location = complete_res.headers["location"] + assert location.startswith("http://localhost:9000/callback") + + params = parse_qs(urlparse(location).query) + assert "code" in params + assert params.get("state", [None])[0] == "e2e-state" + code = params["code"][0] + + # --- Step 4: Token exchange --- + auth_header = b64encode(f"e2e-rp:{client_secret}".encode()).decode() + token_res = await client.post( + "/token", + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://localhost:9000/callback", + }, + headers={ + "Authorization": f"Basic {auth_header}", + "Content-Type": "application/x-www-form-urlencoded", + }, + ) + assert token_res.status_code == 200 + token_data = token_res.json() + assert "access_token" in token_data + assert "id_token" in token_data + assert token_data["token_type"].lower() == "bearer" + + # --- Step 5: Validate ID token --- + id_token_raw = token_data["id_token"] + # Get JWKS + jwks_res = await client.get("/jwks") + jwks = jwks_res.json() + + # Verify the JWT signature + jws = JWS() + _keys = [key_from_jwk_dict(k) for k in jwks["keys"]] + id_token_payload = jws.verify_compact(id_token_raw, _keys) + + assert id_token_payload["iss"] == "http://localhost:8000" + assert id_token_payload["sub"] == "lusab-bansen" + assert "e2e-rp" in id_token_payload["aud"] + + # --- Step 6: UserInfo --- + userinfo_res = await client.get( + "/userinfo", + headers={"Authorization": f"Bearer {token_data['access_token']}"}, + ) + assert userinfo_res.status_code == 200 + userinfo = userinfo_res.json() + assert userinfo["sub"] == "lusab-bansen" + assert userinfo.get("email") == "alice@example.com" +``` + +**Step 2: Run the test** + +Run: `uv run pytest tests/test_oidc/test_e2e_flow.py -v` +Expected: PASS (if Tasks 1-8 are all correct) + +If the test fails, this is where integration issues surface. Debug and fix based on the error output. Common issues: +- idpyoidc's `create_session` needs `authn_method` to not be None +- Response format mismatches (string vs Message vs dict) +- `user_id` in idpyoidc session vs `userid` in our model (the session uses `username` as the `user_id` key for idpyoidc, not `userid`) +- Cookie handling between requests + +**Step 3: Commit** + +```bash +git add tests/test_oidc/test_e2e_flow.py +git commit -m "test: add end-to-end OIDC authorization code flow test" +``` + +--- + +### Task 10: Full Quality Gate + +**Step 1: Run full quality checks** + +Run: `./scripts/check.sh` +Expected: All green (formatting, linting, type checking, all tests pass) + +**Step 2: Fix any issues** + +If ruff or ty reports issues, fix them. Common fixes: +- Type ignore comments for idpyoidc's untyped APIs +- Import sorting +- Unused imports + +**Step 3: Commit any fixes** + +```bash +git add -A +git diff --cached --quiet || git commit -m "style: fix formatting and lint issues" +``` diff --git a/docs/plans/2026-02-16-porchlight-branding-design.md b/docs/plans/2026-02-16-porchlight-branding-design.md new file mode 100644 index 0000000..8fa953c --- /dev/null +++ b/docs/plans/2026-02-16-porchlight-branding-design.md @@ -0,0 +1,88 @@ +# Porchlight Branding & Theme Design + +## Summary + +Create a logo, favicon, and redesigned CSS theme for the project, now branded as "Porchlight." + +## Logo + +**Concept:** Icon + wordmark combo. The icon is a minimal doorway/arch with a light element (filled circle) above it, suggesting an entrance illuminated by a porch light. + +**Variants:** +- Full combo (icon + "Porchlight" text) — page header in `base.html` +- Icon only — favicon and tight spaces + +**Files:** +- `static/logo.svg` — standalone icon SVG +- `static/favicon.png` — 32x32 PNG of the icon + +## Color Palette + +Warm amber/gold accent on neutral stone backgrounds. Automatic dark mode via `prefers-color-scheme`. + +### Light Mode +| Variable | Value | Description | +|---|---|---| +| `--bg` | `#fafaf9` | Warm white (stone-50) | +| `--fg` | `#1c1917` | Warm black (stone-900) | +| `--fg-muted` | `#78716c` | Secondary text (stone-500) | +| `--accent` | `#d97706` | Amber-600 | +| `--accent-hover` | `#b45309` | Amber-700 | +| `--accent-fg` | `#ffffff` | White on accent | +| `--surface` | `#f5f5f4` | Card/input bg (stone-100) | +| `--border` | `#d6d3d1` | Stone-300 | +| `--error-bg` | `#fef2f2` | Red-50 | +| `--error-fg` | `#dc2626` | Red-600 | +| `--success-bg` | `#f0fdf4` | Green-50 | +| `--success-fg` | `#16a34a` | Green-600 | + +### Dark Mode +| Variable | Value | Description | +|---|---|---| +| `--bg` | `#1c1917` | Stone-900 | +| `--fg` | `#fafaf9` | Stone-50 | +| `--fg-muted` | `#a8a29e` | Stone-400 | +| `--accent` | `#f59e0b` | Amber-500 | +| `--accent-hover` | `#fbbf24` | Amber-400 | +| `--accent-fg` | `#1c1917` | Dark text on bright accent | +| `--surface` | `#292524` | Stone-800 | +| `--border` | `#44403c` | Stone-700 | +| `--error-bg` | `#451a1a` | Dark red | +| `--error-fg` | `#f87171` | Red-400 | +| `--success-bg` | `#14532d` | Dark green | +| `--success-fg` | `#4ade80` | Green-400 | + +## CSS Redesign + +Full rewrite of `style.css` with a design system: + +- **Custom properties** for colors, spacing (4px base), typography, and radius +- **System font stack** (no webfonts) +- **Centered single-column layout** (`max-width: 40rem`) +- **Header bar** with logo + wordmark +- **Button variants:** primary (amber), secondary (outlined), danger (red) +- **Form styling:** full-width inputs, labels, focus states, error display +- **Cards/sections:** surface background with border for grouping +- **Alerts:** error (`role="alert"`) and success (`role="status"`) +- **Typography scale:** h1-h3, body, small +- **Accessibility:** skip link, `:focus-visible`, `.sr-only`, `prefers-reduced-motion` +- **Dark mode** via `@media (prefers-color-scheme: dark)` + +## Template Updates + +- `base.html`: add header with logo, update `` to "Porchlight", add favicon link +- `login.html`: update title to "Login — Porchlight" +- `manage/credentials.html`: update title to "Credentials — Porchlight" +- `app.py`: update FastAPI title to "Porchlight" + +## Files Changed + +| File | Action | +|---|---| +| `static/style.css` | Rewrite | +| `static/logo.svg` | Create | +| `static/favicon.png` | Create | +| `templates/base.html` | Update (header, favicon, title) | +| `templates/login.html` | Update (title) | +| `templates/manage/credentials.html` | Update (title) | +| `app.py` | Update (title) | diff --git a/src/fastapi_oidc_op/__init__.py b/src/porchlight/__init__.py similarity index 100% rename from src/fastapi_oidc_op/__init__.py rename to src/porchlight/__init__.py diff --git a/src/fastapi_oidc_op/app.py b/src/porchlight/app.py similarity index 100% rename from src/fastapi_oidc_op/app.py rename to src/porchlight/app.py diff --git a/src/fastapi_oidc_op/authn/__init__.py b/src/porchlight/authn/__init__.py similarity index 100% rename from src/fastapi_oidc_op/authn/__init__.py rename to src/porchlight/authn/__init__.py diff --git a/src/fastapi_oidc_op/authn/password.py b/src/porchlight/authn/password.py similarity index 100% rename from src/fastapi_oidc_op/authn/password.py rename to src/porchlight/authn/password.py diff --git a/src/fastapi_oidc_op/authn/routes.py b/src/porchlight/authn/routes.py similarity index 100% rename from src/fastapi_oidc_op/authn/routes.py rename to src/porchlight/authn/routes.py diff --git a/src/fastapi_oidc_op/authn/webauthn.py b/src/porchlight/authn/webauthn.py similarity index 100% rename from src/fastapi_oidc_op/authn/webauthn.py rename to src/porchlight/authn/webauthn.py diff --git a/src/fastapi_oidc_op/config.py b/src/porchlight/config.py similarity index 100% rename from src/fastapi_oidc_op/config.py rename to src/porchlight/config.py diff --git a/src/fastapi_oidc_op/dependencies.py b/src/porchlight/dependencies.py similarity index 100% rename from src/fastapi_oidc_op/dependencies.py rename to src/porchlight/dependencies.py diff --git a/src/fastapi_oidc_op/invite/__init__.py b/src/porchlight/invite/__init__.py similarity index 100% rename from src/fastapi_oidc_op/invite/__init__.py rename to src/porchlight/invite/__init__.py diff --git a/src/fastapi_oidc_op/invite/service.py b/src/porchlight/invite/service.py similarity index 100% rename from src/fastapi_oidc_op/invite/service.py rename to src/porchlight/invite/service.py diff --git a/src/fastapi_oidc_op/manage/__init__.py b/src/porchlight/manage/__init__.py similarity index 100% rename from src/fastapi_oidc_op/manage/__init__.py rename to src/porchlight/manage/__init__.py diff --git a/src/fastapi_oidc_op/manage/routes.py b/src/porchlight/manage/routes.py similarity index 100% rename from src/fastapi_oidc_op/manage/routes.py rename to src/porchlight/manage/routes.py diff --git a/src/fastapi_oidc_op/models.py b/src/porchlight/models.py similarity index 100% rename from src/fastapi_oidc_op/models.py rename to src/porchlight/models.py diff --git a/src/fastapi_oidc_op/oidc/__init__.py b/src/porchlight/oidc/__init__.py similarity index 100% rename from src/fastapi_oidc_op/oidc/__init__.py rename to src/porchlight/oidc/__init__.py diff --git a/src/fastapi_oidc_op/oidc/claims.py b/src/porchlight/oidc/claims.py similarity index 100% rename from src/fastapi_oidc_op/oidc/claims.py rename to src/porchlight/oidc/claims.py diff --git a/src/fastapi_oidc_op/oidc/endpoints.py b/src/porchlight/oidc/endpoints.py similarity index 100% rename from src/fastapi_oidc_op/oidc/endpoints.py rename to src/porchlight/oidc/endpoints.py diff --git a/src/fastapi_oidc_op/oidc/provider.py b/src/porchlight/oidc/provider.py similarity index 100% rename from src/fastapi_oidc_op/oidc/provider.py rename to src/porchlight/oidc/provider.py diff --git a/src/fastapi_oidc_op/static/favicon.png b/src/porchlight/static/favicon.png similarity index 100% rename from src/fastapi_oidc_op/static/favicon.png rename to src/porchlight/static/favicon.png diff --git a/src/fastapi_oidc_op/static/htmx.min.js b/src/porchlight/static/htmx.min.js similarity index 100% rename from src/fastapi_oidc_op/static/htmx.min.js rename to src/porchlight/static/htmx.min.js diff --git a/src/fastapi_oidc_op/static/logo.svg b/src/porchlight/static/logo.svg similarity index 100% rename from src/fastapi_oidc_op/static/logo.svg rename to src/porchlight/static/logo.svg diff --git a/src/fastapi_oidc_op/static/style.css b/src/porchlight/static/style.css similarity index 100% rename from src/fastapi_oidc_op/static/style.css rename to src/porchlight/static/style.css diff --git a/src/fastapi_oidc_op/static/webauthn.js b/src/porchlight/static/webauthn.js similarity index 100% rename from src/fastapi_oidc_op/static/webauthn.js rename to src/porchlight/static/webauthn.js diff --git a/src/fastapi_oidc_op/store/__init__.py b/src/porchlight/store/__init__.py similarity index 100% rename from src/fastapi_oidc_op/store/__init__.py rename to src/porchlight/store/__init__.py diff --git a/src/fastapi_oidc_op/store/exceptions.py b/src/porchlight/store/exceptions.py similarity index 100% rename from src/fastapi_oidc_op/store/exceptions.py rename to src/porchlight/store/exceptions.py diff --git a/src/fastapi_oidc_op/store/mongodb/__init__.py b/src/porchlight/store/mongodb/__init__.py similarity index 100% rename from src/fastapi_oidc_op/store/mongodb/__init__.py rename to src/porchlight/store/mongodb/__init__.py diff --git a/src/fastapi_oidc_op/store/protocols.py b/src/porchlight/store/protocols.py similarity index 100% rename from src/fastapi_oidc_op/store/protocols.py rename to src/porchlight/store/protocols.py diff --git a/src/fastapi_oidc_op/store/sqlite/__init__.py b/src/porchlight/store/sqlite/__init__.py similarity index 100% rename from src/fastapi_oidc_op/store/sqlite/__init__.py rename to src/porchlight/store/sqlite/__init__.py diff --git a/src/fastapi_oidc_op/store/sqlite/migrations.py b/src/porchlight/store/sqlite/migrations.py similarity index 100% rename from src/fastapi_oidc_op/store/sqlite/migrations.py rename to src/porchlight/store/sqlite/migrations.py diff --git a/src/fastapi_oidc_op/store/sqlite/migrations/001_initial.sql b/src/porchlight/store/sqlite/migrations/001_initial.sql similarity index 100% rename from src/fastapi_oidc_op/store/sqlite/migrations/001_initial.sql rename to src/porchlight/store/sqlite/migrations/001_initial.sql diff --git a/src/fastapi_oidc_op/store/sqlite/repositories.py b/src/porchlight/store/sqlite/repositories.py similarity index 100% rename from src/fastapi_oidc_op/store/sqlite/repositories.py rename to src/porchlight/store/sqlite/repositories.py diff --git a/src/fastapi_oidc_op/templates/base.html b/src/porchlight/templates/base.html similarity index 100% rename from src/fastapi_oidc_op/templates/base.html rename to src/porchlight/templates/base.html diff --git a/src/fastapi_oidc_op/templates/login.html b/src/porchlight/templates/login.html similarity index 100% rename from src/fastapi_oidc_op/templates/login.html rename to src/porchlight/templates/login.html diff --git a/src/fastapi_oidc_op/templates/manage/credentials.html b/src/porchlight/templates/manage/credentials.html similarity index 100% rename from src/fastapi_oidc_op/templates/manage/credentials.html rename to src/porchlight/templates/manage/credentials.html diff --git a/src/fastapi_oidc_op/userid.py b/src/porchlight/userid.py similarity index 100% rename from src/fastapi_oidc_op/userid.py rename to src/porchlight/userid.py diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..2b80a34 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,57 @@ +{ + "name": "porchlight-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "porchlight-e2e", + "dependencies": { + "playwright": "^1.52.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +}