diff --git a/.gitignore b/.gitignore index ece507b..aa6dd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,4 @@ wheels/ .idea/ # Runtime data -data/ +data 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/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 1cd4b6c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,99 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Commands - -```bash -# Format -make reformat # uv run ruff format src/ tests/ - -# Lint (with auto-fix) -make lint # uv run ruff check src/ tests/ --fix - -# Type checking -make typecheck # uv run ty check src/ - -# Tests -make test # uv run python -m pytest -v -uv run pytest tests/test_foo.py::test_specific # single test - -# All checks -make check - -# Dev server -OIDC_OP_ISSUER=http://localhost:8000 OIDC_OP_DEBUG=true \ - uv run uvicorn porchlight.app:create_app \ - --factory --host 127.0.0.1 --port 8000 --reload --reload-dir src -``` - -### End-to-end tests (Playwright/Node) - -```bash -# One-time setup -cd tests/e2e && npm install && npm run setup && cd ../.. - -# Run e2e tests (starts/stops the server automatically) -./tests/e2e/run.sh - -# Run a specific spec -./tests/e2e/run.sh tests/e2e/login.spec.js - -# Run with visible browser -E2E_HEADLESS=0 ./tests/e2e/run.sh -``` - -## Architecture - -Porchlight is an OpenID Connect (OIDC) Provider + user management app built on **FastAPI** with **idpyoidc** for the OIDC protocol layer. UI is server-side **Jinja2** templates with **HTMX** for interactivity. Storage defaults to **SQLite** via `aiosqlite`. - -### Request flow - -``` -Browser/RP → FastAPI routes → Service classes → Repository layer → SQLite - ↕ - idpyoidc (OIDC protocol) -``` - -### Module layout - -- `src/porchlight/app.py` — FastAPI app factory (`create_app()`), lifespan, middleware stack -- `src/porchlight/config.py` — Settings loaded from env vars (`OIDC_OP_*` prefix) and optional TOML file -- `src/porchlight/models.py` — Pydantic domain models (`User`, `WebAuthnCredential`, `MagicLink`, `Consent`) -- `src/porchlight/validation.py` — Form-validation models (`ProfileUpdate` with email/phone/URL validation) -- `src/porchlight/dependencies.py` — FastAPI dependency injection helpers -- `src/porchlight/csrf.py` — Synchronizer-token CSRF middleware (exempt: `/token`, `/userinfo`) -- `src/porchlight/authn/` — Password (`argon2`) and WebAuthn (`fido2`) authentication services + routes -- `src/porchlight/manage/` — Authenticated user credential & profile management routes -- `src/porchlight/admin/` — Admin user management routes (user list, invite creation, profile editing) -- `src/porchlight/invite/` — Magic-link invitation service (proquint tokens, time-limited) -- `src/porchlight/oidc/` — OIDC endpoints (discovery, JWKS, `/authorization`, `/token`, `/userinfo`) -- `src/porchlight/store/` — Repository pattern: `protocols.py` defines interfaces; `sqlite/` has the concrete implementation; `mongodb/` is a stub -- `src/porchlight/cli.py` — Typer CLI (`create-invite`, `initial-admin`) - -### Storage layer - -`store/protocols.py` defines `UserRepository`, `CredentialRepository`, `MagicLinkRepository`, and `ConsentRepository` as Protocol classes. The SQLite implementation lives in `store/sqlite/repositories.py`. Migrations run automatically on startup from `store/sqlite/migrations/*.sql`. - -### Configuration - -All settings use the `OIDC_OP_` env-var prefix (see `config.py`). Key vars: - -| Variable | Notes | -|---|---| -| `OIDC_OP_ISSUER` | **Required.** Must match the public-facing URL. | -| `OIDC_OP_SESSION_SECRET` | Session signing key | -| `OIDC_OP_DEBUG` | Enables Swagger UI | -| `OIDC_OP_SQLITE_PATH` | DB file path (default `data/oidc_op.db`) | -| `OIDC_OP_CONFIG_FILE` | Optional TOML file for clients and advanced config | - -OIDC relying parties (clients) are defined in the TOML config file under `[clients.]`. - -### Test fixtures - -`tests/conftest.py` provides: -- `settings` — in-memory SQLite, test issuer -- `client` — async `httpx.AsyncClient` with ASGI transport -- CSRF token extraction helper - -Unit and integration tests use in-memory SQLite; e2e tests use a seeded file-based DB (`tests/e2e/setup_db.py`). 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/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-16-discoverable-credentials-design.md b/docs/plans/2026-02-16-discoverable-credentials-design.md deleted file mode 100644 index 7dc4df9..0000000 --- a/docs/plans/2026-02-16-discoverable-credentials-design.md +++ /dev/null @@ -1,88 +0,0 @@ -# Discoverable Credentials (Usernameless WebAuthn) - -## Problem - -The current WebAuthn login flow is username-first: the user types their username, -the server looks up their credential IDs, and sends those as `allowCredentials` -to the browser. This requires the user to remember and type their username. - -Additionally, the current WebAuthn login flow has bugs that make it non-functional: -the login button isn't wired up in JS, the JS isn't loaded on the login page, and -the server returns HX-Redirect headers but the JS tries to parse JSON. - -## Decision - -Switch to discoverable credentials (passkeys). The browser/OS credential picker -handles identity selection — no username needed. - -Key choices: -- **Usernameless only** — remove the username field from WebAuthn login entirely -- **User verification: preferred** — ask for PIN/biometric but allow authenticators - that only support presence (touch) -- **Resident key: required** — credentials must be stored on the authenticator as - discoverable credentials -- **JSON response** — the WebAuthn complete endpoint returns - `{"redirect": "..."}` since the flow uses fetch(), not HTMX - -## Registration changes - -`WebAuthnService.begin_registration()` passes -`resident_key_requirement=ResidentKeyRequirement.REQUIRED` and -`user_verification=UserVerificationRequirement.PREFERRED` to -`Fido2Server.register_begin()`. This tells the authenticator to store a resident -credential with the user's ID embedded. - -No schema changes needed. The existing `webauthn_credentials` table and model -already store everything required. - -## Authentication changes - -### Begin (`POST /login/webauthn/begin`) - -- No username parameter (change from POST to GET since there's no form body) -- Call `begin_authentication(credentials=None, user_verification=PREFERRED)` — - empty `allowCredentials` triggers the browser's passkey picker -- Store only `webauthn_login_state` in session (no userid — we don't know it yet) - -### Complete (`POST /login/webauthn/complete`) - -- Extract `userHandle` from the assertion response — this contains the `user_id` - bytes set during registration (`userid.encode()`) -- Decode `userHandle` to get the userid string -- Look up the user's stored credentials by userid -- Verify the assertion against those credentials -- Update sign count -- Set session -- Return `JSONResponse({"redirect": "/manage/credentials"})` (or the OIDC - redirect target if a pending authorization exists) - -### Service layer - -Add `user_verification` parameter to `begin_registration()` and -`begin_authentication()` in `WebAuthnService`, passing them through to the -fido2 server methods. - -## Frontend changes - -### `webauthn.js` - -- `beginAuthentication()` drops the username parameter and FormData body -- Change fetch to GET for `/login/webauthn/begin` -- On success, read `data.redirect` from JSON response (server now returns JSON) -- Wire `#webauthn-login-btn` in the `DOMContentLoaded` handler - -### `login.html` - -- Remove the username field and label from the WebAuthn section -- Add `{% block scripts %}` to load `webauthn.js` - -## Files changed - -| File | Change | -|------|--------| -| `src/porchlight/authn/webauthn.py` | Add `resident_key_requirement` and `user_verification` params | -| `src/porchlight/authn/routes.py` | Rewrite begin (no username, GET), fix complete (userHandle lookup, JSON response) | -| `src/porchlight/static/webauthn.js` | Drop username, wire login button, fix response handling | -| `src/porchlight/templates/login.html` | Remove username field, add scripts block | - -No database migrations. No model changes. No new repository methods. diff --git a/docs/plans/2026-02-18-admin-pages-design.md b/docs/plans/2026-02-18-admin-pages-design.md deleted file mode 100644 index ce32464..0000000 --- a/docs/plans/2026-02-18-admin-pages-design.md +++ /dev/null @@ -1,95 +0,0 @@ -# Admin Pages Design - -## Overview - -Admin pages for user management in the porchlight OIDC provider. Authenticated users with the `"admin"` group can list, search, view, edit, activate/deactivate, and delete users, manage group memberships, view/delete credentials, create invite links, and re-invite existing users. - -## Routing & Auth - -New router at `src/porchlight/admin/routes.py`, mounted at `/admin/` in `app.py`. - -**Admin guard:** Every admin route fetches the full user via `user_repo.get_by_userid()` and checks `"admin" in user.groups`. Unauthenticated users redirect to `/login`. Non-admin authenticated users get 403. - -### Endpoints - -| Method | Path | Purpose | -|--------|------|---------| -| GET | `/admin/users` | User list (paginated, searchable) | -| GET | `/admin/users/{userid}` | User detail page | -| POST | `/admin/users/{userid}/profile` | Update user profile | -| POST | `/admin/users/{userid}/groups` | Update group memberships | -| POST | `/admin/users/{userid}/activate` | Activate user | -| POST | `/admin/users/{userid}/deactivate` | Deactivate user | -| DELETE | `/admin/users/{userid}/credentials/password` | Delete user's password | -| DELETE | `/admin/users/{userid}/credentials/webauthn/{cred_id}` | Delete a WebAuthn key | -| POST | `/admin/users/{userid}/invite` | Generate re-invite link | -| DELETE | `/admin/users/{userid}` | Delete user entirely | -| POST | `/admin/invite` | Create invite for new username | - -## Templates & UI - -### Template Structure - -``` -templates/admin/ - base.html -- extends base.html, adds admin nav + admin label - users.html -- user list table - user_detail.html -- single-user detail with sections -``` - -### User List Page (`/admin/users`) - -- Search input at top (HTMX GET to filter, targets table body) -- Table columns: Username, Name, Email, Groups, Status, Created -- Each row links to detail page -- Active/inactive toggle button per row (HTMX POST, swaps button) -- Pagination controls (prev/next, HTMX) -- "Create Invite" form -- enter username, generates magic link URL - -### User Detail Page (`/admin/users/{userid}`) - -Single page with sections, each with its own HTMX form/target: - -1. **Profile** -- Same editable fields as self-service (given_name, family_name, preferred_username, email, phone_number, picture, locale). Username displayed read-only. HTMX POST. - -2. **Groups** -- Current groups as removable tags/chips. Text input to add groups. HTMX POST replaces full group list. - -3. **Credentials** -- Read-only list: password (exists/doesn't), WebAuthn keys (device name, created). Delete buttons per credential (HTMX DELETE with confirmation). - -4. **Actions** -- Re-invite button (shows generated URL). Delete user button (with confirmation). Activate/deactivate toggle. - -### New CSS - -- `.admin-table` -- bordered table with hover rows -- `.group-tag` -- removable group chips -- `.status-badge` / `.status-active` / `.status-inactive` -- status indicators - -## Data Layer Changes - -### No Schema Changes - -Existing tables cover all needs: `users`, `user_groups`, `webauthn_credentials`, `password_credentials`, `magic_links`. - -### Repository Additions - -Add to `UserRepository` protocol and SQLite implementation: - -- `search_users(query: str, offset: int, limit: int) -> list[User]` -- SQL LIKE on username and email -- `count_users(query: str | None) -> int` -- total count for pagination - -Groups update: fetch user, modify `user.groups`, call `update(user)` (existing method handles group replacement). - -## Testing - -### Python Unit Tests - -- Admin guard (403 for non-admin, redirect for unauthenticated) -- `search_users()` and `count_users()` repository methods -- Route handlers: profile update, group update, activate/deactivate, delete, invite - -### E2E Playwright Tests - -- Auth guard (non-admin blocked from `/admin/`) -- User list: pagination, search, inline activate/deactivate -- User detail: edit profile, manage groups, view credentials, delete credential, re-invite, delete user -- Create invite from admin UI diff --git a/docs/plans/2026-02-18-admin-pages-plan.md b/docs/plans/2026-02-18-admin-pages-plan.md deleted file mode 100644 index c470633..0000000 --- a/docs/plans/2026-02-18-admin-pages-plan.md +++ /dev/null @@ -1,1365 +0,0 @@ -# Admin Pages Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Build admin pages at `/admin/` for user management -- list, search, view, edit, activate/deactivate, delete users, manage groups, view/delete credentials, and create/re-send invite links. - -**Architecture:** New `src/porchlight/admin/` package with its own router mounted at `/admin/`. Admin guard checks `"admin" in user.groups`. Templates under `templates/admin/`. HTMX for all interactive actions. Adds `search_users()` and `count_users()` to the repository layer. - -**Tech Stack:** FastAPI, Jinja2, htmx, aiosqlite, Playwright (E2E tests), pytest (unit tests) - ---- - -### Task 1: Repository — add `search_users()` and `count_users()` - -**Files:** -- Modify: `src/porchlight/store/protocols.py` -- Modify: `src/porchlight/store/sqlite/repositories.py` -- Test: `tests/test_store/test_sqlite_user_repo.py` - -**Step 1: Write failing tests** - -Add to `tests/test_store/test_sqlite_user_repo.py`: - -```python -async def test_search_users_by_username(user_repo, sample_user): - await user_repo.create(sample_user) - results = await user_repo.search_users("sample", offset=0, limit=100) - assert len(results) == 1 - assert results[0].userid == sample_user.userid - - -async def test_search_users_by_email(user_repo, sample_user): - user = sample_user.model_copy(update={"email": "alice@example.com"}) - await user_repo.create(user) - results = await user_repo.search_users("alice", offset=0, limit=100) - assert len(results) == 1 - - -async def test_search_users_no_match(user_repo, sample_user): - await user_repo.create(sample_user) - results = await user_repo.search_users("nonexistent", offset=0, limit=100) - assert len(results) == 0 - - -async def test_search_users_pagination(user_repo): - for i in range(5): - user = User(userid=f"id-{i}", username=f"user{i}", groups=["users"]) - await user_repo.create(user) - page1 = await user_repo.search_users("user", offset=0, limit=2) - page2 = await user_repo.search_users("user", offset=2, limit=2) - assert len(page1) == 2 - assert len(page2) == 2 - assert page1[0].username != page2[0].username - - -async def test_count_users_no_query(user_repo, sample_user): - await user_repo.create(sample_user) - count = await user_repo.count_users() - assert count == 1 - - -async def test_count_users_with_query(user_repo, sample_user): - await user_repo.create(sample_user) - count = await user_repo.count_users(query="sample") - assert count == 1 - count = await user_repo.count_users(query="nonexistent") - assert count == 0 -``` - -**Step 2: Run tests to verify they fail** - -Run: `uv run python -m pytest tests/test_store/test_sqlite_user_repo.py -v -k "search or count_users"` -Expected: FAIL — `search_users` and `count_users` not defined - -**Step 3: Add to protocol** - -In `src/porchlight/store/protocols.py`, add to `UserRepository`: - -```python -async def search_users(self, query: str, offset: int = 0, limit: int = 100) -> list[User]: ... - -async def count_users(self, query: str | None = None) -> int: ... -``` - -**Step 4: Implement in SQLite repository** - -In `src/porchlight/store/sqlite/repositories.py`, add to `SQLiteUserRepository`: - -```python -async def search_users(self, query: str, offset: int = 0, limit: int = 100) -> list[User]: - pattern = f"%{query}%" - async with self._db.execute( - "SELECT * FROM users WHERE username LIKE ? OR email LIKE ? ORDER BY username LIMIT ? OFFSET ?", - (pattern, pattern, 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 count_users(self, query: str | None = None) -> int: - if query: - pattern = f"%{query}%" - async with self._db.execute( - "SELECT COUNT(*) FROM users WHERE username LIKE ? OR email LIKE ?", - (pattern, pattern), - ) as cursor: - row = await cursor.fetchone() - else: - async with self._db.execute("SELECT COUNT(*) FROM users") as cursor: - row = await cursor.fetchone() - return row[0] if row else 0 -``` - -**Step 5: Run tests to verify they pass** - -Run: `uv run python -m pytest tests/test_store/test_sqlite_user_repo.py -v -k "search or count_users"` -Expected: PASS - -**Step 6: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All 172+ tests pass - -**Step 7: Commit** - -``` -git add src/porchlight/store/protocols.py src/porchlight/store/sqlite/repositories.py tests/test_store/test_sqlite_user_repo.py -git commit -m "feat: add search_users and count_users to user repository" -``` - ---- - -### Task 2: Admin guard helper + admin router skeleton - -**Files:** -- Create: `src/porchlight/admin/__init__.py` -- Create: `src/porchlight/admin/routes.py` -- Modify: `src/porchlight/app.py` -- Test: `tests/test_admin/__init__.py` -- Test: `tests/test_admin/test_admin_guard.py` - -**Step 1: Write failing tests** - -Create `tests/test_admin/__init__.py` (empty) and `tests/test_admin/test_admin_guard.py`: - -```python -import pytest -from httpx import ASGITransport, AsyncClient - -from porchlight.app import create_app -from porchlight.config import Settings - - -@pytest.fixture -def settings(tmp_path): - return Settings( - issuer="http://localhost:8000", - sqlite_path=str(tmp_path / "test.db"), - signing_key_path=str(tmp_path / "keys"), - ) - - -@pytest.fixture -def app(settings): - return create_app(settings) - - -@pytest.fixture -async def client(app): - async with AsyncClient(transport=ASGITransport(app=app), base_url="http://localhost:8000") as c: - yield c - - -@pytest.mark.asyncio -async def test_admin_users_redirects_unauthenticated(client): - response = await client.get("/admin/users", follow_redirects=False) - assert response.status_code == 303 - assert response.headers["location"] == "/login" - - -@pytest.mark.asyncio -async def test_admin_users_403_for_non_admin(client, app): - from porchlight.models import PasswordCredential, User - - async with app.router.lifespan_context(app): - # Create a non-admin user - user = User(userid="regular-01", username="regularuser", groups=["users"]) - await app.state.user_repo.create(user) - password_hash = app.state.password_service.hash("password123") - await app.state.credential_repo.create_password( - PasswordCredential(user_id=user.userid, password_hash=password_hash) - ) - - # Login - response = await client.post( - "/login", - data={"username": "regularuser", "password": "password123"}, - follow_redirects=False, - ) - - # Try to access admin - response = await client.get("/admin/users", follow_redirects=False) - assert response.status_code == 403 -``` - -**Step 2: Run tests to verify they fail** - -Run: `uv run python -m pytest tests/test_admin/ -v` -Expected: FAIL — no `/admin/users` route - -**Step 3: Create admin package and router** - -Create `src/porchlight/admin/__init__.py` (empty). - -Create `src/porchlight/admin/routes.py`: - -```python -from fastapi import APIRouter, Request, Response -from fastapi.responses import HTMLResponse, RedirectResponse - -from porchlight.dependencies import get_session_user -from porchlight.models import User - -router = APIRouter(prefix="/admin", tags=["admin"]) - - -async def _get_admin_user(request: Request) -> User | None: - """Return the current user if they are an admin, else None.""" - session_user = get_session_user(request) - if session_user is None: - return None - userid, _username = session_user - user_repo = request.app.state.user_repo - user = await user_repo.get_by_userid(userid) - if user is None or "admin" not in user.groups: - return None - return user - - -@router.get("/users", response_class=HTMLResponse) -async def users_list(request: Request) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - # Placeholder — will be implemented in Task 4 - return HTMLResponse("Admin users list") -``` - -**Step 4: Mount router in app.py** - -In `src/porchlight/app.py`, add import: - -```python -from porchlight.admin.routes import router as admin_router -``` - -And in `create_app()`, add after the other routers: - -```python -app.include_router(admin_router) -``` - -**Step 5: Run tests to verify they pass** - -Run: `uv run python -m pytest tests/test_admin/ -v` -Expected: PASS - -**Step 6: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All tests pass - -**Step 7: Commit** - -``` -git add src/porchlight/admin/ tests/test_admin/ src/porchlight/app.py -git commit -m "feat: add admin router with admin group guard" -``` - ---- - -### Task 3: Admin base template + CSS - -**Files:** -- Create: `src/porchlight/templates/admin/base.html` -- Modify: `src/porchlight/static/style.css` - -**Step 1: Create admin base template** - -Create `src/porchlight/templates/admin/base.html`: - -```html -{% extends "base.html" %} - -{% block content %} - -{% block admin_content %}{% endblock %} -{% endblock %} -``` - -**Step 2: Add admin CSS** - -In `src/porchlight/static/style.css`, add (after the `.manage-nav` section): - -```css -/* Admin navigation */ -.admin-nav { - display: flex; - align-items: center; - gap: var(--space-md); - border-bottom: 1px solid var(--border); - margin-bottom: var(--space-lg); - padding-bottom: var(--space-sm); -} - -.admin-nav a { - color: var(--text-muted); - text-decoration: none; - padding-bottom: var(--space-sm); - border-bottom: 2px solid transparent; - margin-bottom: -1px; -} - -.admin-nav a[aria-current="page"] { - color: var(--text); - border-bottom-color: var(--accent); -} - -.admin-badge { - font-size: var(--text-sm); - font-weight: 600; - color: var(--accent); - border: 1px solid var(--accent); - border-radius: 4px; - padding: 0.1em 0.5em; -} - -/* Admin table */ -.admin-table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-sm); -} - -.admin-table th, -.admin-table td { - text-align: left; - padding: var(--space-sm) var(--space-md); - border-bottom: 1px solid var(--border); -} - -.admin-table th { - font-weight: 600; - color: var(--text-muted); -} - -.admin-table tr:hover td { - background: var(--surface); -} - -.admin-table a { - color: var(--accent); - text-decoration: none; -} - -.admin-table a:hover { - text-decoration: underline; -} - -/* Status badges */ -.status-badge { - font-size: var(--text-sm); - font-weight: 500; - padding: 0.1em 0.5em; - border-radius: 4px; -} - -.status-active { - color: #15803d; - background: #f0fdf4; -} - -.status-inactive { - color: #b91c1c; - background: #fef2f2; -} - -/* Group tags */ -.group-tag { - display: inline-flex; - align-items: center; - gap: 0.25em; - font-size: var(--text-sm); - background: var(--surface); - border: 1px solid var(--border); - border-radius: 4px; - padding: 0.1em 0.5em; -} - -.group-tag button { - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 0; - font-size: 1em; - line-height: 1; -} - -.group-tag button:hover { - color: var(--danger, #dc2626); -} - -/* Search bar */ -.admin-search { - display: flex; - gap: var(--space-sm); - margin-bottom: var(--space-lg); -} - -.admin-search input { - flex: 1; -} - -/* Pagination */ -.pagination { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: var(--space-lg); - font-size: var(--text-sm); - color: var(--text-muted); -} - -/* Detail sections */ -.admin-detail section { - margin-bottom: var(--space-xl); -} - -/* Invite result */ -.invite-url { - font-family: monospace; - font-size: var(--text-sm); - word-break: break-all; - background: var(--surface); - border: 1px solid var(--border); - padding: var(--space-sm) var(--space-md); - border-radius: 4px; -} - -/* Confirm dialog */ -.confirm-danger { - background: #fef2f2; - border: 1px solid #fca5a5; - border-radius: 4px; - padding: var(--space-md); - margin-top: var(--space-sm); -} -``` - -Note: also add dark mode overrides for `.status-active` and `.status-inactive`: - -```css -/* Inside @media (prefers-color-scheme: dark) */ -.status-active { - color: #86efac; - background: #14532d; -} - -.status-inactive { - color: #fca5a5; - background: #7f1d1d; -} -``` - -**Step 3: Commit** - -``` -git add src/porchlight/templates/admin/base.html src/porchlight/static/style.css -git commit -m "feat: add admin base template and CSS styles" -``` - ---- - -### Task 4: User list page (GET /admin/users) - -**Files:** -- Create: `src/porchlight/templates/admin/users.html` -- Modify: `src/porchlight/admin/routes.py` - -**Step 1: Create user list template** - -Create `src/porchlight/templates/admin/users.html`: - -```html -{% extends "admin/base.html" %} - -{% block title %}Users — Admin — Porchlight{% endblock %} - -{% block admin_content %} -

Users

- -
-

Create invite

-
- -
-
-
- -
- - -
- - - - - - - - - - - - - {% include "admin/_user_rows.html" %} - -
UsernameNameEmailGroupsStatusCreated
- -
-
-{% endblock %} -``` - -**Step 2: Create partial templates for HTMX** - -Create `src/porchlight/templates/admin/_user_rows.html`: - -```html -{% for user in users %} - - {{ user.username }} - {{ [user.given_name, user.family_name]|select|join(' ') }} - {{ user.email or '' }} - {% for g in user.groups %}{{ g }} {% endfor %} - - - {% if user.active %} - Active - {% else %} - Inactive - {% endif %} - - - {{ user.created_at.strftime('%Y-%m-%d') }} - -{% endfor %} -{% if not users %} -No users found. -{% endif %} -``` - -Create `src/porchlight/templates/admin/_pagination.html`: - -```html - - Showing {{ offset + 1 }}–{{ offset + users|length }} of {{ total }} - - - {% if offset > 0 %} - Previous - {% endif %} - {% if offset + per_page < total %} - Next - {% endif %} - -``` - -**Step 3: Implement route handler** - -Replace the placeholder `users_list` in `src/porchlight/admin/routes.py`: - -```python -@router.get("/users", response_class=HTMLResponse) -async def users_list(request: Request) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - user_repo = request.app.state.user_repo - query = request.query_params.get("q", "").strip() - offset = int(request.query_params.get("offset", "0")) - per_page = 20 - - if query: - users = await user_repo.search_users(query, offset=offset, limit=per_page) - total = await user_repo.count_users(query=query) - else: - users = await user_repo.list_users(offset=offset, limit=per_page) - total = await user_repo.count_users() - - templates = request.app.state.templates - context = { - "users": users, - "query": query, - "offset": offset, - "per_page": per_page, - "total": total, - "active_page": "users", - } - - # If HTMX search request, return just the rows - if request.headers.get("HX-Request") and request.headers.get("HX-Trigger-Name") == "q": - return templates.TemplateResponse(request, "admin/_user_rows.html", context) - - return templates.TemplateResponse(request, "admin/users.html", context) -``` - -**Step 4: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All tests pass - -**Step 5: Commit** - -``` -git add src/porchlight/admin/routes.py src/porchlight/templates/admin/ -git commit -m "feat: add admin user list page with search and pagination" -``` - ---- - -### Task 5: Create invite from admin (POST /admin/invite) - -**Files:** -- Modify: `src/porchlight/admin/routes.py` - -**Step 1: Add route handler** - -```python -@router.post("/invite", response_class=HTMLResponse) -async def create_invite( - request: Request, - username: str = Form(), -) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - username = username.strip() - if not username: - return HTMLResponse('
Username is required
') - - magic_link_service = request.app.state.magic_link_service - settings = request.app.state.settings - link = await magic_link_service.create(username=username, created_by=admin.username, note="admin invite") - url = f"{settings.issuer}/register/{link.token}" - - return HTMLResponse( - f'
Invite created for {username}:
' - f'
{url}
' - ) -``` - -Add `from fastapi import Form` to imports. - -**Step 2: Commit** - -``` -git add src/porchlight/admin/routes.py -git commit -m "feat: add admin invite creation endpoint" -``` - ---- - -### Task 6: User detail page (GET /admin/users/{userid}) - -**Files:** -- Create: `src/porchlight/templates/admin/user_detail.html` -- Modify: `src/porchlight/admin/routes.py` - -**Step 1: Create user detail template** - -Create `src/porchlight/templates/admin/user_detail.html`: - -```html -{% extends "admin/base.html" %} - -{% block title %}{{ target_user.username }} — Admin — Porchlight{% endblock %} - -{% block admin_content %} -

{{ target_user.username }}

-

ID: {{ target_user.userid }} · Created {{ target_user.created_at.strftime('%Y-%m-%d %H:%M') }}

- -
- -
-

Profile

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- -
-

Groups

-
-
-
- {% for group in target_user.groups %} - {{ group }} - {% endfor %} -
-
- - -
-
- -
-
-
- -
-

Credentials

-
-

Password

- {% if has_password %} -

Password is set. - -

- {% else %} -

No password set.

- {% endif %} - -

Security keys

- {% if webauthn_credentials %} -
    - {% for cred in webauthn_credentials %} -
  • - {{ cred.device_name or "Security key" }} - (added {{ cred.created_at.strftime('%Y-%m-%d') }}) - -
  • - {% endfor %} -
- {% else %} -

No security keys registered.

- {% endif %} -
-
- -
-

Actions

-
-
- {% if target_user.active %} - - {% else %} - - {% endif %} -
-
- -
-
-
- -
-
-
- -
-{% endblock %} -``` - -**Step 2: Add Jinja2 filter for base64 encoding credential IDs** - -In `src/porchlight/app.py`, after creating the templates instance, add a custom filter: - -```python -from base64 import urlsafe_b64encode - -# After: app.state.templates = Jinja2Templates(...) -app.state.templates.env.filters["b64encode"] = lambda v: urlsafe_b64encode(v).decode().rstrip("=") -``` - -**Step 3: Add route handler** - -In `src/porchlight/admin/routes.py`: - -```python -@router.get("/users/{userid}", response_class=HTMLResponse) -async def user_detail(request: Request, userid: str) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - user_repo = request.app.state.user_repo - cred_repo = request.app.state.credential_repo - target_user = await user_repo.get_by_userid(userid) - if target_user is None: - return HTMLResponse("User not found", status_code=404) - - webauthn_credentials = await cred_repo.get_webauthn_by_user(userid) - password_credential = await cred_repo.get_password_by_user(userid) - - templates = request.app.state.templates - return templates.TemplateResponse( - request, - "admin/user_detail.html", - { - "target_user": target_user, - "webauthn_credentials": webauthn_credentials, - "has_password": password_credential is not None, - "active_page": "users", - }, - ) -``` - -**Step 4: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All tests pass - -**Step 5: Commit** - -``` -git add src/porchlight/admin/routes.py src/porchlight/templates/admin/user_detail.html src/porchlight/app.py -git commit -m "feat: add admin user detail page with profile, groups, credentials, and actions" -``` - ---- - -### Task 7: Admin action routes (profile, groups, activate, deactivate, credentials, invite, delete) - -**Files:** -- Modify: `src/porchlight/admin/routes.py` - -**Step 1: Add all action route handlers** - -```python -from base64 import urlsafe_b64decode -from urllib.parse import urlparse - -from fastapi import APIRouter, Form, Request, Response -from fastapi.responses import HTMLResponse, RedirectResponse - -from porchlight.dependencies import get_session_user -from porchlight.models import User - - -# --- Profile update --- -@router.post("/users/{userid}/profile", response_class=HTMLResponse) -async def update_user_profile( - request: Request, - userid: str, - given_name: str = Form(""), - family_name: str = Form(""), - preferred_username: str = Form(""), - email: str = Form(""), - phone_number: str = Form(""), - picture: str = Form(""), - locale: str = Form(""), -) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - # Validate - if email and "@" not in email: - return HTMLResponse('
Invalid email address
') - if picture: - parsed = urlparse(picture) - if parsed.scheme not in ("http", "https") or not parsed.netloc: - return HTMLResponse('
Picture URL must be a valid HTTP or HTTPS URL
') - - user_repo = request.app.state.user_repo - user = await user_repo.get_by_userid(userid) - if user is None: - return HTMLResponse("User not found", status_code=404) - - updated = user.model_copy( - update={ - "given_name": given_name or None, - "family_name": family_name or None, - "preferred_username": preferred_username or None, - "email": email or None, - "phone_number": phone_number or None, - "picture": picture or None, - "locale": locale or None, - } - ) - await user_repo.update(updated) - return HTMLResponse('
Profile updated
') - - -# --- Groups update --- -@router.post("/users/{userid}/groups", response_class=HTMLResponse) -async def update_user_groups( - request: Request, - userid: str, - groups: str = Form(""), -) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - user_repo = request.app.state.user_repo - user = await user_repo.get_by_userid(userid) - if user is None: - return HTMLResponse("User not found", status_code=404) - - group_list = [g.strip() for g in groups.split(",") if g.strip()] - updated = user.model_copy(update={"groups": group_list}) - await user_repo.update(updated) - return HTMLResponse('
Groups updated
') - - -# --- Activate / deactivate --- -@router.post("/users/{userid}/activate", response_class=HTMLResponse) -async def activate_user(request: Request, userid: str) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - user_repo = request.app.state.user_repo - user = await user_repo.get_by_userid(userid) - if user is None: - return HTMLResponse("User not found", status_code=404) - - updated = user.model_copy(update={"active": True}) - await user_repo.update(updated) - return HTMLResponse( - '
User activated
' - f'' - ) - - -@router.post("/users/{userid}/deactivate", response_class=HTMLResponse) -async def deactivate_user(request: Request, userid: str) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - user_repo = request.app.state.user_repo - user = await user_repo.get_by_userid(userid) - if user is None: - return HTMLResponse("User not found", status_code=404) - - updated = user.model_copy(update={"active": False}) - await user_repo.update(updated) - return HTMLResponse( - '
User deactivated
' - f'' - ) - - -# --- Delete credentials --- -@router.delete("/users/{userid}/credentials/password", response_class=HTMLResponse) -async def delete_user_password(request: Request, userid: str) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - cred_repo = request.app.state.credential_repo - await cred_repo.delete_password(userid) - - # Re-render credentials section - webauthn_credentials = await cred_repo.get_webauthn_by_user(userid) - templates = request.app.state.templates - return templates.TemplateResponse( - request, - "admin/_credentials_section.html", - {"target_user": await request.app.state.user_repo.get_by_userid(userid), - "webauthn_credentials": webauthn_credentials, - "has_password": False}, - ) - - -@router.delete("/users/{userid}/credentials/webauthn/{credential_id_b64}", response_class=HTMLResponse) -async def delete_user_webauthn(request: Request, userid: str, credential_id_b64: str) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - padded = credential_id_b64 + "=" * (-len(credential_id_b64) % 4) - credential_id = urlsafe_b64decode(padded) - cred_repo = request.app.state.credential_repo - await cred_repo.delete_webauthn(userid, credential_id) - - # Re-render credentials section - webauthn_credentials = await cred_repo.get_webauthn_by_user(userid) - password = await cred_repo.get_password_by_user(userid) - templates = request.app.state.templates - return templates.TemplateResponse( - request, - "admin/_credentials_section.html", - {"target_user": await request.app.state.user_repo.get_by_userid(userid), - "webauthn_credentials": webauthn_credentials, - "has_password": password is not None}, - ) - - -# --- Re-invite --- -@router.post("/users/{userid}/invite", response_class=HTMLResponse) -async def reinvite_user(request: Request, userid: str) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - user_repo = request.app.state.user_repo - user = await user_repo.get_by_userid(userid) - if user is None: - return HTMLResponse("User not found", status_code=404) - - magic_link_service = request.app.state.magic_link_service - settings = request.app.state.settings - link = await magic_link_service.create(username=user.username, created_by=admin.username, note="admin re-invite") - url = f"{settings.issuer}/register/{link.token}" - - return HTMLResponse( - f'
Invite link generated:
' - f'
{url}
' - ) - - -# --- Delete user --- -@router.delete("/users/{userid}", response_class=HTMLResponse) -async def delete_user(request: Request, userid: str) -> Response: - session_user = get_session_user(request) - if session_user is None: - return RedirectResponse("/login", status_code=303) - admin = await _get_admin_user(request) - if admin is None: - return HTMLResponse("Forbidden", status_code=403) - - # Prevent self-deletion - admin_userid, _ = get_session_user(request) - if userid == admin_userid: - return HTMLResponse('
Cannot delete your own account
') - - user_repo = request.app.state.user_repo - deleted = await user_repo.delete(userid) - if not deleted: - return HTMLResponse("User not found", status_code=404) - - return HTMLResponse( - status_code=200, - content='
User deleted
', - headers={"HX-Redirect": "/admin/users"}, - ) -``` - -**Step 2: Create credentials section partial** - -Create `src/porchlight/templates/admin/_credentials_section.html`: - -```html -

Password

-{% if has_password %} -

Password is set. - -

-{% else %} -

No password set.

-{% endif %} - -

Security keys

-{% if webauthn_credentials %} - -{% else %} -

No security keys registered.

-{% endif %} -``` - -**Step 3: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All tests pass - -**Step 4: Commit** - -``` -git add src/porchlight/admin/routes.py src/porchlight/templates/admin/ -git commit -m "feat: add admin action routes (profile, groups, activate, credentials, invite, delete)" -``` - ---- - -### Task 8: Python unit tests for admin routes - -**Files:** -- Create: `tests/test_admin/test_admin_routes.py` - -**Step 1: Write comprehensive tests** - -Test all admin routes using the same `httpx.AsyncClient` + `ASGITransport` pattern used elsewhere in the test suite. Cover: - -- Admin guard: redirect for unauthenticated, 403 for non-admin -- User list: returns HTML with user table -- User detail: returns HTML with user data -- Profile update: modifies user profile -- Groups update: modifies user groups -- Activate/deactivate: toggles user active status -- Delete user: removes user -- Delete credentials: removes password/webauthn -- Create invite: generates magic link -- Re-invite: generates magic link for existing user -- Self-deletion prevention - -Look at the existing test patterns in `tests/test_auth_routes/` for fixture and session patterns. - -**Step 2: Run tests** - -Run: `uv run python -m pytest tests/test_admin/ -v` -Expected: All pass - -**Step 3: Commit** - -``` -git add tests/test_admin/ -git commit -m "test: add unit tests for admin routes" -``` - ---- - -### Task 9: Add admin link to manage nav for admin users - -**Files:** -- Modify: `src/porchlight/templates/manage/base.html` -- Modify: `src/porchlight/manage/routes.py` - -**Step 1: Pass `is_admin` to manage templates** - -In `src/porchlight/manage/routes.py`, update `credentials_page` and `profile_page` to check if user is admin and pass `is_admin` to context: - -```python -# In credentials_page and profile_page, after fetching the user: -user = await user_repo.get_by_userid(userid) -is_admin = user is not None and "admin" in user.groups -# Add is_admin=is_admin to template context -``` - -**Step 2: Update manage/base.html** - -```html -{% extends "base.html" %} - -{% block content %} - -{% block manage_content %}{% endblock %} -{% endblock %} -``` - -**Step 3: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All tests pass - -**Step 4: Commit** - -``` -git add src/porchlight/manage/routes.py src/porchlight/templates/manage/base.html -git commit -m "feat: show admin link in manage nav for admin users" -``` - ---- - -### Task 10: Seed admin test user + E2E tests - -**Files:** -- Modify: `tests/e2e/setup_db.py` -- Create: `tests/e2e/admin.spec.js` - -**Step 1: Seed test data** - -In `tests/e2e/setup_db.py`, add an admin user and additional regular users: - -```python -# Admin user for admin page tests -admin_user = User( - userid="test-user-05", - username="adminuser", - given_name="Admin", - family_name="User", - email="admin@example.com", - groups=["admin", "users"], -) -await user_repo.create(admin_user) -admin_password_hash = password_service.hash("adminpass123") -await cred_repo.create_password( - PasswordCredential(user_id=admin_user.userid, password_hash=admin_password_hash) -) -result["admin_username"] = "adminuser" -result["admin_password"] = "adminpass123" -result["admin_userid"] = "test-user-05" -``` - -**Step 2: Write E2E tests** - -Create `tests/e2e/admin.spec.js` covering: - -- Auth guard: unauthenticated redirect, non-admin 403 -- User list: page structure, search, pagination -- User detail: page structure, all sections visible -- Profile update: modify and verify -- Groups update: modify and verify -- Activate/deactivate: toggle and verify -- Create invite: generate link and verify URL -- Re-invite: generate link for existing user -- Delete credential: remove password -- Delete user: remove user and verify redirect - -Use `helpers.login(page, username, password)` pattern from existing tests. - -**Step 3: Run E2E tests** - -Run from `tests/e2e/`: `bash run.sh admin.spec.js` -Expected: All pass - -**Step 4: Run full E2E suite** - -Run from `tests/e2e/`: `bash run.sh` -Expected: All 76+ existing tests pass + new admin tests - -**Step 5: Commit** - -``` -git add tests/e2e/setup_db.py tests/e2e/admin.spec.js -git commit -m "test: add E2E tests for admin pages" -``` - ---- - -### Task 11: Final verification - -**Step 1: Run full Python test suite** - -Run: `uv run python -m pytest -v` -Expected: All tests pass - -**Step 2: Run full E2E test suite** - -Run from `tests/e2e/`: `bash run.sh` -Expected: All tests pass - -**Step 3: Run linter** - -Run: `uv run ruff check src/ tests/ --fix` -Expected: No errors - -**Step 4: Final commit if needed** - -Fix any lint issues and commit. diff --git a/docs/plans/2026-02-18-consent-screen-plan.md b/docs/plans/2026-02-18-consent-screen-plan.md deleted file mode 100644 index 95e7e49..0000000 --- a/docs/plans/2026-02-18-consent-screen-plan.md +++ /dev/null @@ -1,851 +0,0 @@ -# Consent Screen Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a consent screen to the OIDC authorization flow so users -approve scopes before tokens are issued. Consent is saved per user+client -and only re-shown when requested scopes change. - -**Architecture:** New `user_consents` SQLite table stores approved scopes -per user+client. A consent check runs after authentication in the -authorization flow. If consent is needed, the user sees a scope approval -page. The manage-app client bypasses consent. Partial consent (unchecking -optional scopes) is supported. - -**Tech Stack:** SQLite migration, aiosqlite repository, Pydantic model, -Jinja2 template, existing FastAPI routing patterns. - ---- - -### Task 1: Add Consent model and SQLite migration - -**Files:** -- Modify: `src/porchlight/models.py` -- Create: `src/porchlight/store/sqlite/migrations/002_user_consents.sql` -- Modify: `src/porchlight/store/protocols.py` -- Modify: `src/porchlight/store/sqlite/repositories.py` -- Test: `tests/test_store/test_sqlite_consent_repo.py` - -**Step 1: Write the failing tests** - -Add `tests/test_store/test_sqlite_consent_repo.py`: - -```python -from datetime import UTC, datetime - -from porchlight.models import Consent -from porchlight.store.protocols import ConsentRepository -from porchlight.store.sqlite.repositories import SQLiteConsentRepository - - -async def test_implements_protocol(db_connection) -> None: - repo = SQLiteConsentRepository(db_connection) - assert isinstance(repo, ConsentRepository) - - -async def test_set_and_get_consent(db_connection, sample_user) -> None: - repo = SQLiteConsentRepository(db_connection) - await repo.set_consent(sample_user.userid, "test-rp", ["openid", "profile"]) - - consent = await repo.get_consent(sample_user.userid, "test-rp") - assert consent is not None - assert consent.userid == sample_user.userid - assert consent.client_id == "test-rp" - assert consent.scopes == ["openid", "profile"] - assert isinstance(consent.created_at, datetime) - assert isinstance(consent.updated_at, datetime) - - -async def test_get_consent_not_found(db_connection, sample_user) -> None: - repo = SQLiteConsentRepository(db_connection) - consent = await repo.get_consent(sample_user.userid, "nonexistent") - assert consent is None - - -async def test_set_consent_upserts(db_connection, sample_user) -> None: - repo = SQLiteConsentRepository(db_connection) - await repo.set_consent(sample_user.userid, "test-rp", ["openid"]) - await repo.set_consent(sample_user.userid, "test-rp", ["openid", "profile", "email"]) - - consent = await repo.get_consent(sample_user.userid, "test-rp") - assert consent is not None - assert consent.scopes == ["openid", "profile", "email"] - - -async def test_delete_consent(db_connection, sample_user) -> None: - repo = SQLiteConsentRepository(db_connection) - await repo.set_consent(sample_user.userid, "test-rp", ["openid"]) - - result = await repo.delete_consent(sample_user.userid, "test-rp") - assert result is True - - consent = await repo.get_consent(sample_user.userid, "test-rp") - assert consent is None - - -async def test_delete_consent_not_found(db_connection, sample_user) -> None: - repo = SQLiteConsentRepository(db_connection) - result = await repo.delete_consent(sample_user.userid, "nonexistent") - assert result is False - - -async def test_list_consents(db_connection, sample_user) -> None: - repo = SQLiteConsentRepository(db_connection) - await repo.set_consent(sample_user.userid, "rp-a", ["openid"]) - await repo.set_consent(sample_user.userid, "rp-b", ["openid", "profile"]) - - consents = await repo.list_consents(sample_user.userid) - assert len(consents) == 2 - client_ids = {c.client_id for c in consents} - assert client_ids == {"rp-a", "rp-b"} - - -async def test_list_consents_empty(db_connection, sample_user) -> None: - repo = SQLiteConsentRepository(db_connection) - consents = await repo.list_consents(sample_user.userid) - assert consents == [] - - -async def test_consent_deleted_on_user_cascade(db_connection, sample_user) -> None: - """Consent records are deleted when the user is deleted (CASCADE).""" - from porchlight.store.sqlite.repositories import SQLiteUserRepository - - repo = SQLiteConsentRepository(db_connection) - user_repo = SQLiteUserRepository(db_connection) - - await repo.set_consent(sample_user.userid, "test-rp", ["openid"]) - await user_repo.delete(sample_user.userid) - - consent = await repo.get_consent(sample_user.userid, "test-rp") - assert consent is None -``` - -This test file uses fixtures `db_connection` and `sample_user` that should -already exist in `tests/conftest.py`. Check `tests/conftest.py` for the -exact fixture names — they may be named differently (e.g. `db` instead of -`db_connection`). Adapt the test to match. - -**Step 2: Run tests to verify they fail** - -Run: `uv run python -m pytest tests/test_store/test_sqlite_consent_repo.py -v` -Expected: FAIL — `Consent` model not defined, `ConsentRepository` not -defined, `SQLiteConsentRepository` not defined. - -**Step 3: Write the implementation** - -Add to `src/porchlight/models.py`: - -```python -class Consent(BaseModel): - userid: str - client_id: str - scopes: list[str] - created_at: datetime = Field(default_factory=_utcnow) - updated_at: datetime = Field(default_factory=_utcnow) -``` - -Create `src/porchlight/store/sqlite/migrations/002_user_consents.sql`: - -```sql -CREATE TABLE user_consents ( - userid TEXT NOT NULL REFERENCES users(userid) ON DELETE CASCADE, - client_id TEXT NOT NULL, - scopes TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - PRIMARY KEY (userid, client_id) -); -``` - -Add to `src/porchlight/store/protocols.py`: - -```python -from porchlight.models import Consent # add to existing imports - -@runtime_checkable -class ConsentRepository(Protocol): - async def get_consent(self, userid: str, client_id: str) -> Consent | None: ... - - async def set_consent(self, userid: str, client_id: str, scopes: list[str]) -> None: ... - - async def delete_consent(self, userid: str, client_id: str) -> bool: ... - - async def list_consents(self, userid: str) -> list[Consent]: ... -``` - -Add to `src/porchlight/store/sqlite/repositories.py`: - -```python -import json # add to existing imports -from porchlight.models import Consent # add to existing imports - - -class SQLiteConsentRepository: - def __init__(self, db: aiosqlite.Connection) -> None: - self._db = db - - async def get_consent(self, userid: str, client_id: str) -> Consent | None: - async with self._db.execute( - "SELECT * FROM user_consents WHERE userid = ? AND client_id = ?", - (userid, client_id), - ) as cursor: - row = await cursor.fetchone() - if row is None: - return None - return Consent( - userid=row["userid"], - client_id=row["client_id"], - scopes=json.loads(row["scopes"]), - created_at=datetime.fromisoformat(row["created_at"]), - updated_at=datetime.fromisoformat(row["updated_at"]), - ) - - async def set_consent(self, userid: str, client_id: str, scopes: list[str]) -> None: - now = datetime.now(UTC).isoformat() - await self._db.execute( - """ - INSERT INTO user_consents (userid, client_id, scopes, created_at, updated_at) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (userid, client_id) - DO UPDATE SET scopes = excluded.scopes, updated_at = excluded.updated_at - """, - (userid, client_id, json.dumps(scopes), now, now), - ) - await self._db.commit() - - async def delete_consent(self, userid: str, client_id: str) -> bool: - cursor = await self._db.execute( - "DELETE FROM user_consents WHERE userid = ? AND client_id = ?", - (userid, client_id), - ) - await self._db.commit() - return cursor.rowcount > 0 - - async def list_consents(self, userid: str) -> list[Consent]: - async with self._db.execute( - "SELECT * FROM user_consents WHERE userid = ? ORDER BY client_id", - (userid,), - ) as cursor: - rows = await cursor.fetchall() - return [ - Consent( - userid=row["userid"], - client_id=row["client_id"], - scopes=json.loads(row["scopes"]), - created_at=datetime.fromisoformat(row["created_at"]), - updated_at=datetime.fromisoformat(row["updated_at"]), - ) - for row in rows - ] -``` - -**Step 4: Run tests to verify they pass** - -Run: `uv run python -m pytest tests/test_store/test_sqlite_consent_repo.py -v` -Expected: All PASS. - -**Step 5: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All tests PASS (existing + new). - -**Step 6: Commit** - -```bash -git add src/porchlight/models.py src/porchlight/store/protocols.py \ - src/porchlight/store/sqlite/migrations/002_user_consents.sql \ - src/porchlight/store/sqlite/repositories.py \ - tests/test_store/test_sqlite_consent_repo.py -git commit -m "feat: add Consent model, migration, and repository" -``` - ---- - -### Task 2: Add consent check to authorization flow - -**Files:** -- Modify: `src/porchlight/app.py:36-38` (add consent_repo to app.state) -- Modify: `src/porchlight/oidc/endpoints.py:45-97,100-149` -- Test: `tests/test_oidc/test_consent_flow.py` - -**Step 1: Write the failing tests** - -Add `tests/test_oidc/test_consent_flow.py`: - -```python -import secrets -from datetime import UTC, datetime -from urllib.parse import parse_qs, urlparse - -from argon2 import PasswordHasher -from httpx import AsyncClient - -from porchlight.authn.password import PasswordService -from porchlight.models import PasswordCredential, User - - -async def test_authorization_shows_consent_for_new_client(client: AsyncClient) -> None: - """First-time authorization for an RP should redirect to /consent.""" - app = client._transport.app # type: ignore[union-attr] - _register_test_rp(app) - await _create_test_user(app) - - # Login - await client.post( - "/login/password", - data={"username": "consentuser", "password": "testpass"}, - headers={"HX-Request": "true"}, - ) - - # Authorization request - res = await client.get( - "/authorization", - params={ - "response_type": "code", - "client_id": "consent-rp", - "redirect_uri": "http://localhost:9000/callback", - "scope": "openid profile", - "state": "teststate", - }, - follow_redirects=False, - ) - assert res.status_code == 303 - assert "/consent" in res.headers["location"] - - -async def test_consent_page_renders(client: AsyncClient) -> None: - """GET /consent should render the consent form.""" - app = client._transport.app # type: ignore[union-attr] - _register_test_rp(app) - await _create_test_user(app) - await _login_and_start_auth(client) - - res = await client.get("/consent") - assert res.status_code == 200 - assert "consent-rp" in res.text - assert "profile" in res.text.lower() - - -async def test_consent_allow_redirects_with_code(client: AsyncClient) -> None: - """Approving consent should complete the authorization flow.""" - app = client._transport.app # type: ignore[union-attr] - _register_test_rp(app) - await _create_test_user(app) - await _login_and_start_auth(client) - - res = await client.post( - "/consent", - data={"action": "allow", "scope": ["openid", "profile"]}, - follow_redirects=False, - ) - assert res.status_code == 303 - location = res.headers["location"] - parsed = urlparse(location) - params = parse_qs(parsed.query) - assert "code" in params - - -async def test_consent_deny_redirects_with_error(client: AsyncClient) -> None: - """Denying consent should redirect with access_denied error.""" - app = client._transport.app # type: ignore[union-attr] - _register_test_rp(app) - await _create_test_user(app) - await _login_and_start_auth(client) - - res = await client.post( - "/consent", - data={"action": "deny"}, - follow_redirects=False, - ) - assert res.status_code == 303 - location = res.headers["location"] - parsed = urlparse(location) - params = parse_qs(parsed.query) - assert params["error"] == ["access_denied"] - - -async def test_saved_consent_skips_consent_screen(client: AsyncClient) -> None: - """Second authorization with same scopes should skip consent.""" - app = client._transport.app # type: ignore[union-attr] - _register_test_rp(app) - await _create_test_user(app) - - # First flow: login, authorize, consent - await _login_and_start_auth(client) - await client.post( - "/consent", - data={"action": "allow", "scope": ["openid", "profile"]}, - follow_redirects=False, - ) - - # Second flow: same scopes, should skip consent - res = await client.get( - "/authorization", - params={ - "response_type": "code", - "client_id": "consent-rp", - "redirect_uri": "http://localhost:9000/callback", - "scope": "openid profile", - "state": "teststate2", - }, - follow_redirects=False, - ) - assert res.status_code == 303 - location = res.headers["location"] - # Should redirect directly to callback, not /consent - assert "callback" in location - assert "code" in location - - -async def test_new_scopes_reshows_consent(client: AsyncClient) -> None: - """If RP requests new scopes, consent screen should reappear.""" - app = client._transport.app # type: ignore[union-attr] - _register_test_rp(app) - await _create_test_user(app) - - # First flow: consent to openid only - await _login_and_start_auth(client, scope="openid") - await client.post( - "/consent", - data={"action": "allow", "scope": ["openid"]}, - follow_redirects=False, - ) - - # Second flow: request openid + profile (new scope) - res = await client.get( - "/authorization", - params={ - "response_type": "code", - "client_id": "consent-rp", - "redirect_uri": "http://localhost:9000/callback", - "scope": "openid profile", - "state": "teststate2", - }, - follow_redirects=False, - ) - assert res.status_code == 303 - assert "/consent" in res.headers["location"] - - -async def test_manage_app_skips_consent(client: AsyncClient) -> None: - """The manage-app client should bypass consent entirely.""" - app = client._transport.app # type: ignore[union-attr] - settings = app.state.settings - await _create_test_user(app) - - await client.post( - "/login/password", - data={"username": "consentuser", "password": "testpass"}, - headers={"HX-Request": "true"}, - ) - - manage_cdb = app.state.oidc_server.context.cdb[settings.manage_client_id] - redirect_uri = manage_cdb["redirect_uris"][0][0] - - res = await client.get( - "/authorization", - params={ - "response_type": "code", - "client_id": settings.manage_client_id, - "redirect_uri": redirect_uri, - "scope": "openid profile email", - "state": "teststate", - }, - follow_redirects=False, - ) - assert res.status_code == 303 - location = res.headers["location"] - # Should redirect directly to callback, not /consent - assert "code" in location - assert "/consent" not in location - - -async def test_partial_consent_filters_scopes(client: AsyncClient) -> None: - """User can approve only some scopes (partial consent).""" - app = client._transport.app # type: ignore[union-attr] - _register_test_rp(app) - await _create_test_user(app) - - # Request openid + profile + email, approve only openid + profile - await _login_and_start_auth(client, scope="openid profile email") - res = await client.post( - "/consent", - data={"action": "allow", "scope": ["openid", "profile"]}, - follow_redirects=False, - ) - assert res.status_code == 303 - location = res.headers["location"] - assert "code" in location - - # Verify consent was saved with only the approved scopes - consent_repo = app.state.consent_repo - consent = await consent_repo.get_consent("lusab-consent", "consent-rp") - assert consent is not None - assert set(consent.scopes) == {"openid", "profile"} - - -# -- Test helpers -- - -def _register_test_rp(app) -> None: - oidc_server = app.state.oidc_server - if "consent-rp" in oidc_server.context.cdb: - return - client_id = "consent-rp" - client_secret = "consent-secret-0123456789abcdef" - oidc_server.context.cdb[client_id] = { - "client_id": client_id, - "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(client_id, client_secret) - - -async def _create_test_user(app) -> None: - user_repo = app.state.user_repo - existing = await user_repo.get_by_username("consentuser") - if existing: - return - user = User( - userid="lusab-consent", - username="consentuser", - email="consent@example.com", - 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)) - cred_repo = app.state.credential_repo - await cred_repo.create_password( - PasswordCredential(user_id=user.userid, password_hash=svc.hash("testpass")) - ) - - -async def _login_and_start_auth(client: AsyncClient, scope: str = "openid profile") -> None: - await client.post( - "/login/password", - data={"username": "consentuser", "password": "testpass"}, - headers={"HX-Request": "true"}, - ) - await client.get( - "/authorization", - params={ - "response_type": "code", - "client_id": "consent-rp", - "redirect_uri": "http://localhost:9000/callback", - "scope": scope, - "state": "teststate", - }, - follow_redirects=False, - ) -``` - -**Step 2: Run tests to verify they fail** - -Run: `uv run python -m pytest tests/test_oidc/test_consent_flow.py -v` -Expected: FAIL — consent_repo not on app.state, no `/consent` route. - -**Step 3: Write the implementation** - -**3a. Wire up consent_repo in `src/porchlight/app.py`** - -Add after `app.state.magic_link_repo` (around line 38): - -```python -from porchlight.store.sqlite.repositories import SQLiteConsentRepository # add to imports - -app.state.consent_repo = SQLiteConsentRepository(db) -``` - -**3b. Add consent routes and modify authorization flow in `src/porchlight/oidc/endpoints.py`** - -Add scope descriptions constant: - -```python -SCOPE_DESCRIPTIONS: dict[str, str] = { - "openid": "Sign you in (required)", - "profile": "Your name and profile information", - "email": "Your email address", - "phone": "Your phone number", -} -``` - -Modify `authorization()` (line 65-66) — when user is authenticated, instead -of calling `_complete_authorization()` directly, call a new -`_check_consent_or_complete()` helper: - -```python -if userid and username: - return await _check_consent_or_complete( - request, oidc_server, endpoint, parsed, userid, username, query_params - ) -``` - -Similarly modify `authorization_complete()` (line 97) to call -`_check_consent_or_complete()` instead of `_complete_authorization()`. - -Add the consent check helper: - -```python -async def _check_consent_or_complete( - request: Request, - oidc_server: object, - endpoint: object, - parsed: object, - userid: str, - username: str, - auth_params: dict, -) -> Response: - """Check if consent is needed; if so redirect to /consent, otherwise complete.""" - settings = request.app.state.settings - client_id = auth_params.get("client_id", "") - - # Manage-app bypasses consent - if client_id == settings.manage_client_id: - return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username) - - # Check stored consent - consent_repo = request.app.state.consent_repo - requested_scopes = auth_params.get("scope", "openid").split() - stored_consent = await consent_repo.get_consent(userid, client_id) - - if stored_consent and set(requested_scopes) <= set(stored_consent.scopes): - # All requested scopes already approved - return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username) - - # Consent needed — store auth state and redirect - request.session["consent_auth_request"] = auth_params - return RedirectResponse("/consent", status_code=303) -``` - -Add the consent page routes: - -```python -@router.get("/consent") -async def consent_page(request: Request) -> Response: - """Show the consent form.""" - auth_params = request.session.get("consent_auth_request") - if auth_params is None: - return HTMLResponse("

Error

No pending consent request

", status_code=400) - - userid = request.session.get("userid") - if not userid: - return RedirectResponse("/login", status_code=303) - - client_id = auth_params.get("client_id", "") - requested_scopes = auth_params.get("scope", "openid").split() - - scope_info = [ - {"name": s, "description": SCOPE_DESCRIPTIONS.get(s, s), "required": s == "openid"} - for s in requested_scopes - ] - - templates = request.app.state.templates - return templates.TemplateResponse( - request, - "consent.html", - {"client_id": client_id, "scopes": scope_info}, - ) - - -@router.post("/consent") -async def consent_submit(request: Request) -> Response: - """Handle consent form submission.""" - auth_params = request.session.pop("consent_auth_request", None) - if auth_params is None: - return HTMLResponse("

Error

No pending consent 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) - - form = await request.form() - action = form.get("action") - client_id = auth_params.get("client_id", "") - redirect_uri = auth_params.get("redirect_uri", "") - state = auth_params.get("state", "") - - if action == "deny": - params = urlencode({"error": "access_denied", "state": state}) - return RedirectResponse(f"{redirect_uri}?{params}", status_code=303) - - # Allow — collect approved scopes - approved_scopes = form.getlist("scope") - if "openid" not in approved_scopes: - approved_scopes = ["openid"] + list(approved_scopes) - - # Save consent - consent_repo = request.app.state.consent_repo - await consent_repo.set_consent(userid, client_id, list(approved_scopes)) - - # Filter auth request scopes to only approved - auth_params["scope"] = " ".join(approved_scopes) - - # Re-parse and complete - oidc_server = request.app.state.oidc_server - endpoint = oidc_server.get_endpoint("authorization") - - try: - parsed = endpoint.parse_request(auth_params) - except Exception as exc: - return HTMLResponse(f"

Error

{exc}

", status_code=400) - - return await _complete_authorization(request, oidc_server, endpoint, parsed, userid, username) -``` - -**Step 4: Run tests to verify they pass** - -Run: `uv run python -m pytest tests/test_oidc/test_consent_flow.py -v` -Expected: All PASS. - -**Step 5: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All tests PASS. Note: `test_full_authorization_code_flow` in -`tests/test_oidc/test_e2e_flow.py` will need updating since it now hits the -consent screen. Either pre-seed consent in the test setup, or add the consent -step to the test flow. - -**Step 6: Commit** - -```bash -git add src/porchlight/app.py src/porchlight/oidc/endpoints.py \ - tests/test_oidc/test_consent_flow.py -git commit -m "feat: add consent check to authorization flow" -``` - ---- - -### Task 3: Add consent page template - -**Files:** -- Create: `src/porchlight/templates/consent.html` -- Modify: `src/porchlight/static/style.css` (if needed) - -**Step 1: Create the consent template** - -Create `src/porchlight/templates/consent.html`: - -```html -{% extends "base.html" %} - -{% block title %}Authorize — Porchlight{% endblock %} - -{% block content %} - -{% endblock %} -``` - -Note: The `openid` checkbox is `checked disabled` so the user sees it but -can't uncheck it. A hidden input ensures the value is still submitted. - -**Step 2: Verify consent page renders** - -Run: `uv run python -m pytest tests/test_oidc/test_consent_flow.py::test_consent_page_renders -v` -Expected: PASS. - -**Step 3: Commit** - -```bash -git add src/porchlight/templates/consent.html -git commit -m "feat: add consent page template" -``` - ---- - -### Task 4: Update existing E2E test and add consent E2E test - -**Files:** -- Modify: `tests/test_oidc/test_e2e_flow.py` - -The existing `test_full_authorization_code_flow` test directly calls -`/authorization/complete` which now redirects to `/consent` instead of the RP -callback. This test needs to handle the consent step. - -**Step 1: Update the existing E2E flow test** - -In `tests/test_oidc/test_e2e_flow.py`, after step 2 (login) and step 3 -(complete authorization), add the consent step. The test should: - -1. Follow the redirect to `/consent` when `/authorization/complete` - returns 303 to `/consent` -2. POST to `/consent` with `action=allow` and the requested scopes -3. Follow the redirect to the RP callback - -Alternatively, pre-seed consent in the test setup using the consent repo: - -```python -consent_repo = app.state.consent_repo -await consent_repo.set_consent(user.userid, client_id, ["openid", "profile", "email"]) -``` - -This approach is simpler and keeps the existing test focused on the OIDC -token flow rather than the consent UI. - -**Step 2: Run full test suite** - -Run: `uv run python -m pytest -v` -Expected: All PASS. - -**Step 3: Commit** - -```bash -git add tests/test_oidc/test_e2e_flow.py -git commit -m "test: update E2E flow test to handle consent" -``` - ---- - -### Task 5: Quality check - -**Step 1: Run formatter and linter** - -Run: `uv run ruff format src/ tests/ && uv run ruff check src/ tests/ --fix` - -**Step 2: Run type checker** - -Run: `uv run ty check src/` - -**Step 3: Run full test suite** - -Run: `uv run python -m pytest -v` - -**Step 4: Fix any issues and commit** - -```bash -git add -A -git commit -m "refactor: fix lint and type check issues" -``` diff --git a/docs/plans/2026-02-18-playwright-migration-webauthn-e2e-design.md b/docs/plans/2026-02-18-playwright-migration-webauthn-e2e-design.md deleted file mode 100644 index ef8aaa4..0000000 --- a/docs/plans/2026-02-18-playwright-migration-webauthn-e2e-design.md +++ /dev/null @@ -1,117 +0,0 @@ -# Playwright Test Migration + WebAuthn E2E Tests - -## Problem - -The existing 7 E2E tests use a hand-rolled test runner (`helpers.js` with -`run()`/`assert()`). This works but lacks structured reporting, retry logic, -parallel execution, and proper lifecycle hooks. - -Additionally, the WebAuthn (passkey) authentication flow has no E2E coverage. -The existing tests acknowledge this gap -- `test_login.js` checks that the -WebAuthn button exists but doesn't exercise the actual flow. - -## Decision - -Two-phase approach: - -1. Migrate all existing E2E tests from the custom runner to `@playwright/test` -2. Add comprehensive WebAuthn E2E tests using CDP virtual authenticators - -## Phase 1: Migrate to @playwright/test - -### Infrastructure - -- Add `@playwright/test` to `package.json` -- Create `playwright.config.js` with: - - `baseURL` from `TARGET_URL` env var (default `http://localhost:8099`) - - Chromium-only project (WebAuthn CDP requires Chromium) - - `testDir` pointing to the e2e directory - - `testMatch` for `*.spec.js` files -- Update `run.sh` to call `npx playwright test` instead of looping over `test_*.js` - -### Test conversion - -Each `test_*.js` becomes `*.spec.js`: -- `run(async (page, assert) => { ... })` becomes `test('...', async ({ page }) => { ... })` -- `assert(condition, msg)` becomes `expect(condition).toBeTruthy()` or specific matchers -- Shared setup moves to `test.beforeAll()` / `test.beforeEach()` -- `TARGET_URL` usage replaced by Playwright's `baseURL` (use relative paths) - -### What stays the same - -- `run.sh` still starts the app, seeds data, runs tests, tears down -- `setup_db.py` unchanged -- Test logic/assertions are equivalent - -### Files removed - -- `helpers.js` -- replaced by Playwright Test's built-in fixtures and `expect` - -## Phase 2: WebAuthn E2E tests - -### Approach: CDP Virtual Authenticator + Route Interception - -Chromium DevTools Protocol exposes `WebAuthn.addVirtualAuthenticator` which -creates a software authenticator that the browser's WebAuthn API treats as real. -This lets us test the full stack: button click -> `navigator.credentials` -> -server round-trip -> redirect. - -For error scenarios, we use Playwright's `page.route()` to intercept network -requests and return error responses. - -### Virtual authenticator configuration - -```js -const cdpSession = await page.context().newCDPSession(page); -await cdpSession.send('WebAuthn.enable'); -const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', { - options: { - protocol: 'ctap2', - transport: 'internal', - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: true, - } -}); -``` - -### Test scenarios - -**Registration (from credentials page, requires active session):** -- Register a passkey via the "Add security key" button -- Verify the new passkey appears in the credential list -- Verify registration uses resident key (discoverable credential) - -**Authentication (usernameless login):** -- Full round-trip: register passkey -> logout -> login via passkey button -- No username needed -- browser's passkey picker selects the credential -- Verify redirect to `/manage/credentials` after successful login -- Verify session is established (can access protected pages) - -**Deletion:** -- Register passkey + have password, delete the passkey -- Cannot delete last credential (only has passkey, no password) - -**Error handling (route interception):** -- Server error on authentication begin -- Server error on authentication complete -- Expired session (complete without prior begin) - -### Test data - -Extend `setup_db.py` to create a user for WebAuthn tests: -- User with password credential (for logging in to register a passkey) -- The test flow: login with password -> register passkey -> logout -> login with passkey - -### Files changed/created - -| File | Change | -|------|--------| -| `tests/e2e/package.json` | Add `@playwright/test` dependency | -| `tests/e2e/playwright.config.js` | New: Playwright Test configuration | -| `tests/e2e/run.sh` | Update to use `npx playwright test` | -| `tests/e2e/helpers.js` | Remove (replaced by Playwright Test) | -| `tests/e2e/test_*.js` -> `*.spec.js` | Migrate all 7 tests to Playwright Test syntax | -| `tests/e2e/test_webauthn.spec.js` | New: WebAuthn E2E test suite | -| `tests/e2e/setup_db.py` | Add WebAuthn test user fixture | diff --git a/docs/plans/2026-02-18-playwright-migration-webauthn-e2e-plan.md b/docs/plans/2026-02-18-playwright-migration-webauthn-e2e-plan.md deleted file mode 100644 index c5f81b3..0000000 --- a/docs/plans/2026-02-18-playwright-migration-webauthn-e2e-plan.md +++ /dev/null @@ -1,913 +0,0 @@ -# Playwright Test Migration + WebAuthn E2E Tests — Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Migrate existing E2E tests from custom runner to @playwright/test, then add comprehensive WebAuthn E2E tests using CDP virtual authenticators. - -**Architecture:** Two-phase migration. Phase 1 replaces the hand-rolled `helpers.js` runner with Playwright Test's built-in `test()`/`expect()` framework. Phase 2 adds a new `test_webauthn.spec.js` using CDP `WebAuthn.addVirtualAuthenticator` for real passkey simulation, plus `page.route()` for error scenarios. - -**Tech Stack:** Playwright Test (`@playwright/test`), Chrome DevTools Protocol (CDP) WebAuthn API, Node.js - ---- - -## Task 1: Update package.json and add Playwright config - -**Files:** -- Modify: `tests/e2e/package.json` -- Create: `tests/e2e/playwright.config.js` - -**Step 1: Update package.json** - -Replace `tests/e2e/package.json` with: - -```json -{ - "private": true, - "name": "porchlight-e2e", - "description": "End-to-end browser tests for Porchlight", - "scripts": { - "test": "npx playwright test", - "setup": "npx playwright install chromium" - }, - "dependencies": { - "@playwright/test": "^1.52.0" - } -} -``` - -Note: `@playwright/test` includes `playwright` as a dependency, so we replace the direct `playwright` dep. - -**Step 2: Create playwright.config.js** - -Create `tests/e2e/playwright.config.js`: - -```js -// @ts-check -const { defineConfig } = require('@playwright/test'); - -module.exports = defineConfig({ - testDir: '.', - testMatch: '*.spec.js', - timeout: 30_000, - retries: 0, - workers: 1, // Serial execution — tests share one seeded database - reporter: [['list']], - use: { - baseURL: process.env.TARGET_URL || 'http://localhost:8099', - browserName: 'chromium', - headless: process.env.E2E_HEADLESS !== '0', - }, -}); -``` - -**Step 3: Install updated dependencies** - -Run: `cd tests/e2e && npm install` - -**Step 4: Verify config loads** - -Run: `cd tests/e2e && npx playwright test --list` -Expected: No errors (will say "no tests found" since *.spec.js files don't exist yet) - -**Step 5: Commit** - -``` -feat(e2e): add @playwright/test and config -``` - ---- - -## Task 2: Update run.sh for Playwright Test - -**Files:** -- Modify: `tests/e2e/run.sh` - -**Step 1: Update run.sh** - -The script still starts the app, seeds data, and runs tests — but now calls `npx playwright test` instead of looping over individual files. - -Replace the "Run tests" section (lines 63-81) and the final summary (lines 83-90) in `run.sh`: - -```bash -# --- Run tests --- -echo "" -echo "=== Running Playwright tests ===" -cd "$SCRIPT_DIR" -if [ $# -gt 0 ]; then - npx playwright test "$@" -else - npx playwright test -fi -EXIT_CODE=$? - -exit "$EXIT_CODE" -``` - -The rest of the script (server start, seed, cleanup) stays the same. - -**Step 2: Verify run.sh still starts/stops correctly** - -Don't run the full suite yet (no spec files), just verify the script structure is correct by reading it. - -**Step 3: Commit** - -``` -feat(e2e): update run.sh for Playwright Test runner -``` - ---- - -## Task 3: Migrate test_health.js -> health.spec.js - -**Files:** -- Create: `tests/e2e/health.spec.js` -- Remove: `tests/e2e/test_health.js` - -**Step 1: Create health.spec.js** - -```js -// @ts-check -const { test, expect } = require('@playwright/test'); - -test.describe('Health endpoint', () => { - test('returns OK status', async ({ request }) => { - const resp = await request.get('/health'); - expect(resp.ok()).toBe(true); - - const body = await resp.json(); - expect(body.status).toBe('ok'); - }); -}); -``` - -Note: `request` fixture uses `baseURL` from config automatically. - -**Step 2: Delete test_health.js** - -**Step 3: Run the test** - -Run: `cd tests/e2e && TARGET_URL=http://localhost:8099 npx playwright test health.spec.js` -(Server must be running separately for this step) - -**Step 4: Commit** - -``` -refactor(e2e): migrate health test to Playwright Test -``` - ---- - -## Task 4: Migrate test_login.js -> login.spec.js - -**Files:** -- Create: `tests/e2e/login.spec.js` -- Remove: `tests/e2e/test_login.js` - -**Step 1: Create login.spec.js** - -```js -// @ts-check -const { test, expect } = require('@playwright/test'); - -test.describe('Login page', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/login'); - }); - - test.describe('Branding', () => { - test('page title contains Porchlight', async ({ page }) => { - await expect(page).toHaveTitle(/Porchlight/); - }); - - test('page title does not contain FastAPI', async ({ page }) => { - const title = await page.title(); - expect(title).not.toContain('FastAPI'); - }); - - test('favicon is served', async ({ page, request }) => { - const favicon = await page.locator('link[rel="icon"]').getAttribute('href'); - expect(favicon).toBe('/static/favicon.png'); - - const resp = await request.get('/static/favicon.png'); - expect(resp.ok()).toBe(true); - }); - - test('site header and logo are visible', async ({ page, request }) => { - await expect(page.locator('.site-header')).toBeVisible(); - await expect(page.locator('.site-logo')).toBeVisible(); - - const logoSrc = await page.locator('.site-logo').getAttribute('src'); - expect(logoSrc).toBe('/static/logo.svg'); - - const resp = await request.get('/static/logo.svg'); - expect(resp.ok()).toBe(true); - - await expect(page.locator('.site-title')).toHaveText('Porchlight'); - }); - }); - - test.describe('Accessibility', () => { - test('skip link is present', async ({ page }) => { - await expect(page.locator('.skip-link')).toHaveCount(1); - }); - - test('main landmark exists', async ({ page }) => { - await expect(page.locator('main#main')).toHaveCount(1); - }); - - test('polite live region exists', async ({ page }) => { - await expect(page.locator('#live[aria-live="polite"]')).toHaveCount(1); - }); - }); - - test.describe('Login form structure', () => { - test('heading says Sign in', async ({ page }) => { - await expect(page.locator('h1')).toHaveText('Sign in'); - }); - - test('password login form exists with inputs', async ({ page }) => { - await expect(page.locator('form[hx-post="/login/password"]')).toHaveCount(1); - await expect(page.locator('#username')).toBeVisible(); - await expect(page.locator('#password')).toBeVisible(); - await expect(page.locator('form[hx-post="/login/password"] button[type="submit"]')).toBeVisible(); - }); - - test('WebAuthn login form exists', async ({ page }) => { - await expect(page.locator('#webauthn-login-btn')).toBeVisible(); - }); - }); - - test.describe('Theme / styling', () => { - test('body has themed background', async ({ page }) => { - const bgColor = await page.evaluate(() => getComputedStyle(document.body).backgroundColor); - expect(['rgb(250, 250, 249)', 'rgb(28, 25, 23)']).toContain(bgColor); - }); - - test('button uses amber accent', async ({ page }) => { - const btnBg = await page.evaluate(() => { - const btn = document.querySelector('button[type="submit"]'); - return btn ? getComputedStyle(btn).backgroundColor : ''; - }); - expect(['rgb(217, 119, 6)', 'rgb(245, 158, 11)']).toContain(btnBg); - }); - - test('sections have surface background and border', async ({ page }) => { - const sectionBg = await page.evaluate(() => { - const section = document.querySelector('section'); - return section ? getComputedStyle(section).backgroundColor : ''; - }); - expect(['rgb(245, 245, 244)', 'rgb(41, 37, 36)']).toContain(sectionBg); - - const sectionBorder = await page.evaluate(() => { - const section = document.querySelector('section'); - return section ? getComputedStyle(section).borderStyle : ''; - }); - expect(sectionBorder).toBe('solid'); - }); - }); - - test.describe('Static assets', () => { - test('CSS is served with expected content', async ({ request }) => { - const resp = await request.get('/static/style.css'); - expect(resp.ok()).toBe(true); - const css = await resp.text(); - expect(css).toContain('--accent'); - expect(css).toContain('#d97706'); - expect(css).toContain('prefers-color-scheme: dark'); - expect(css).toContain('prefers-reduced-motion'); - }); - }); -}); -``` - -**Step 2: Delete test_login.js** - -**Step 3: Commit** - -``` -refactor(e2e): migrate login page test to Playwright Test -``` - ---- - -## Task 5: Migrate test_password_auth.js -> password-auth.spec.js - -**Files:** -- Create: `tests/e2e/password-auth.spec.js` -- Remove: `tests/e2e/test_password_auth.js` - -**Step 1: Create password-auth.spec.js** - -```js -// @ts-check -const { test, expect } = require('@playwright/test'); - -const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); - -test.describe('Password authentication', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/login'); - }); - - test('shows error for nonexistent user', async ({ page }) => { - await page.fill('#username', 'nobody'); - await page.fill('#password', 'whatever'); - await page.click('form[hx-post="/login/password"] button[type="submit"]'); - - const alert = page.locator('[role="alert"]'); - await expect(alert).toBeVisible({ timeout: 5000 }); - await expect(alert).toContainText('Invalid username or password'); - }); - - test('shows error for wrong password', async ({ page }) => { - await page.fill('#username', fixtures.login_username); - await page.fill('#password', 'wrongpassword'); - await page.click('form[hx-post="/login/password"] button[type="submit"]'); - - const alert = page.locator('[role="alert"]'); - await expect(alert).toBeVisible({ timeout: 5000 }); - await expect(alert).toContainText('Invalid username or password'); - }); - - test('successful login redirects to credentials', async ({ page }) => { - 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 }); - expect(page.url()).toContain('/manage/credentials'); - }); - - test('form has required and autocomplete attributes', async ({ page }) => { - await expect(page.locator('#username')).toHaveAttribute('required', ''); - await expect(page.locator('#password')).toHaveAttribute('required', ''); - await expect(page.locator('#username')).toHaveAttribute('autocomplete', 'username'); - await expect(page.locator('#password')).toHaveAttribute('autocomplete', 'current-password'); - }); -}); -``` - -**Step 2: Delete test_password_auth.js** - -**Step 3: Commit** - -``` -refactor(e2e): migrate password auth test to Playwright Test -``` - ---- - -## Task 6: Migrate test_registration.js -> registration.spec.js - -**Files:** -- Create: `tests/e2e/registration.spec.js` -- Remove: `tests/e2e/test_registration.js` - -**Step 1: Create registration.spec.js** - -```js -// @ts-check -const { test, expect } = require('@playwright/test'); - -const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); - -test.describe('Magic link registration', () => { - test('invalid token returns 400', async ({ page }) => { - const resp = await page.goto('/register/invalid-token-12345'); - expect(resp.status()).toBe(400); - await expect(page.locator('body')).toContainText('Invalid or expired'); - }); - - test('used token returns 400', async ({ page }) => { - expect(fixtures.used_token).toBeTruthy(); - const resp = await page.goto(`/register/${fixtures.used_token}`); - expect(resp.status()).toBe(400); - await expect(page.locator('body')).toContainText('Invalid or expired'); - }); -}); -``` - -**Step 2: Delete test_registration.js** - -**Step 3: Commit** - -``` -refactor(e2e): migrate registration test to Playwright Test -``` - ---- - -## Task 7: Migrate test_credentials.js -> credentials.spec.js - -**Files:** -- Create: `tests/e2e/credentials.spec.js` -- Remove: `tests/e2e/test_credentials.js` - -**Step 1: Create credentials.spec.js** - -```js -// @ts-check -const { test, expect } = require('@playwright/test'); - -const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); - -test.describe('Credential management', () => { - test.beforeEach(async ({ page }) => { - // Login with dedicated credentials user - await page.goto('/login'); - await page.fill('#username', fixtures.cred_username); - await page.fill('#password', fixtures.cred_password); - await page.click('form[hx-post="/login/password"] button[type="submit"]'); - await page.waitForURL('**/manage/credentials', { timeout: 5000 }); - }); - - test.describe('Page structure', () => { - test('page title contains Credentials and Porchlight', async ({ page }) => { - await expect(page).toHaveTitle(/Credentials/); - await expect(page).toHaveTitle(/Porchlight/); - }); - - test('heading says Credentials', async ({ page }) => { - await expect(page.locator('h1')).toHaveText('Credentials'); - }); - - test('security keys section is visible', async ({ page }) => { - await expect(page.locator('h2:has-text("Security keys")')).toBeVisible(); - await expect(page.locator('#webauthn-register-btn')).toBeVisible(); - }); - - test('password section is visible', async ({ page }) => { - await expect(page.locator('h2:has-text("Password")')).toBeVisible(); - await expect(page.locator('#password-section')).toBeVisible(); - }); - }); - - test.describe('Password validation', () => { - test('shows mismatch error', async ({ page }) => { - await page.fill('#password', 'newpassword1'); - await page.fill('#confirm', 'newpassword2'); - await page.click('#password-section button[type="submit"]'); - - const alert = page.locator('#password-section [role="alert"]'); - await expect(alert).toBeVisible({ timeout: 5000 }); - await expect(alert).toContainText('do not match'); - }); - - test('password inputs have minlength attribute', async ({ page }) => { - await expect(page.locator('#password')).toHaveAttribute('minlength', '8'); - await expect(page.locator('#confirm')).toHaveAttribute('minlength', '8'); - }); - }); - - test.describe('Password change', () => { - test('successful password change shows confirmation', async ({ page }) => { - await page.fill('#password', 'newpassword123'); - await page.fill('#confirm', 'newpassword123'); - await page.click('#password-section button[type="submit"]'); - - const status = page.locator('#password-section [role="status"]'); - await expect(status).toBeVisible({ timeout: 5000 }); - await expect(status).toContainText('Password updated'); - }); - }); -}); -``` - -**Step 2: Delete test_credentials.js** - -**Step 3: Commit** - -``` -refactor(e2e): migrate credentials test to Playwright Test -``` - ---- - -## Task 8: Migrate test_auth_guard.js -> auth-guard.spec.js - -**Files:** -- Create: `tests/e2e/auth-guard.spec.js` -- Remove: `tests/e2e/test_auth_guard.js` - -**Step 1: Create auth-guard.spec.js** - -```js -// @ts-check -const { test, expect } = require('@playwright/test'); - -test.describe('Auth guard', () => { - test('unauthenticated /manage/credentials redirects to login', async ({ page }) => { - await page.goto('/manage/credentials'); - await page.waitForURL('**/login', { timeout: 5000 }); - expect(page.url()).toContain('/login'); - }); - - test('unauthenticated /manage/credentials?setup=1 redirects to login', async ({ page }) => { - await page.goto('/manage/credentials?setup=1'); - await page.waitForURL('**/login', { timeout: 5000 }); - expect(page.url()).toContain('/login'); - }); -}); -``` - -**Step 2: Delete test_auth_guard.js** - -**Step 3: Commit** - -``` -refactor(e2e): migrate auth guard test to Playwright Test -``` - ---- - -## Task 9: Migrate test_full_flow.js -> full-flow.spec.js - -**Files:** -- Create: `tests/e2e/full-flow.spec.js` -- Remove: `tests/e2e/test_full_flow.js` - -**Step 1: Create full-flow.spec.js** - -```js -// @ts-check -const { test, expect } = require('@playwright/test'); - -const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); - -test.describe('Full user journey', () => { - test('magic link register -> set password -> logout -> login', async ({ page, request }) => { - // Step 1: Register via magic link - expect(fixtures.register_token).toBeTruthy(); - await page.goto(`/register/${fixtures.register_token}`); - await page.waitForURL('**/manage/credentials?setup=1', { timeout: 5000 }); - expect(page.url()).toContain('/manage/credentials'); - - // Welcome message visible - const welcome = page.locator('[role="status"]'); - await expect(welcome).toBeVisible(); - await expect(welcome).toContainText('Welcome'); - await expect(page).toHaveTitle(/Porchlight/); - - // Step 2: Set password - await expect(page.locator('#password')).toBeVisible(); - await expect(page.locator('#confirm')).toBeVisible(); - - await page.fill('#password', 'mypassword123'); - await page.fill('#confirm', 'mypassword123'); - await page.click('#password-section button[type="submit"]'); - - const successMsg = page.locator('#password-section [role="status"]'); - await expect(successMsg).toBeVisible({ timeout: 5000 }); - await expect(successMsg).toContainText('Password updated'); - - // Step 3: Logout - await request.post('/logout'); - await page.goto('/manage/credentials'); - await page.waitForURL('**/login', { timeout: 5000 }); - expect(page.url()).toContain('/login'); - - // Step 4: Login with new password - await page.fill('#username', fixtures.register_username); - await page.fill('#password', 'mypassword123'); - await page.click('form[hx-post="/login/password"] button[type="submit"]'); - - await page.waitForURL('**/manage/credentials', { timeout: 5000 }); - expect(page.url()).toContain('/manage/credentials'); - - // No setup message on normal login - await expect(page.locator('[role="status"]:has-text("Welcome")')).toHaveCount(0); - }); -}); -``` - -**Step 2: Delete test_full_flow.js** - -**Step 3: Commit** - -``` -refactor(e2e): migrate full flow test to Playwright Test -``` - ---- - -## Task 10: Remove old helpers.js and run the full suite - -**Files:** -- Remove: `tests/e2e/helpers.js` - -**Step 1: Delete helpers.js** - -The old custom runner is no longer used by any test. - -**Step 2: Run the full e2e suite** - -Run: `./tests/e2e/run.sh` -Expected: All tests pass with Playwright Test's list reporter output. - -**Step 3: Commit** - -``` -refactor(e2e): remove old custom test runner -``` - ---- - -## Task 11: Extend setup_db.py with WebAuthn test user - -**Files:** -- Modify: `tests/e2e/setup_db.py` - -**Step 1: Add WebAuthn test user** - -Add after the "Create a separate user for credentials management test" block (after line 63): - -```python - # 5. Create a user with password for WebAuthn registration tests - # (login with password first, then register a passkey) - webauthn_user = User(userid="test-user-03", username="webauthnuser", groups=["users"]) - await user_repo.create(webauthn_user) - webauthn_password_hash = password_service.hash("webauthnpass123") - await cred_repo.create_password( - PasswordCredential(user_id=webauthn_user.userid, password_hash=webauthn_password_hash) - ) - result["webauthn_username"] = "webauthnuser" - result["webauthn_password"] = "webauthnpass123" - result["webauthn_userid"] = "test-user-03" -``` - -**Step 2: Verify seeding still works** - -The server startup in run.sh will exercise this. - -**Step 3: Commit** - -``` -feat(e2e): add WebAuthn test user to fixture seeding -``` - ---- - -## Task 12: Create WebAuthn E2E tests - -**Files:** -- Create: `tests/e2e/webauthn.spec.js` - -**Step 1: Create webauthn.spec.js** - -```js -// @ts-check -const { test, expect } = require('@playwright/test'); - -const fixtures = JSON.parse(process.env.E2E_FIXTURES || '{}'); - -/** - * Set up a CDP virtual authenticator for WebAuthn testing. - * Returns { cdpSession, authenticatorId } for cleanup. - */ -async function addVirtualAuthenticator(page) { - const cdpSession = await page.context().newCDPSession(page); - await cdpSession.send('WebAuthn.enable'); - const { authenticatorId } = await cdpSession.send('WebAuthn.addVirtualAuthenticator', { - options: { - protocol: 'ctap2', - transport: 'internal', - hasResidentKey: true, - hasUserVerification: true, - isUserVerified: true, - automaticPresenceSimulation: true, - }, - }); - return { cdpSession, authenticatorId }; -} - -/** - * Log in with password and navigate to credentials page. - */ -async function loginWithPassword(page, username, password) { - await page.goto('/login'); - await page.fill('#username', username); - await page.fill('#password', password); - await page.click('form[hx-post="/login/password"] button[type="submit"]'); - await page.waitForURL('**/manage/credentials', { timeout: 5000 }); -} - -test.describe('WebAuthn', () => { - test.describe('Registration', () => { - test('register a passkey from credentials page', async ({ page }) => { - const { cdpSession, authenticatorId } = await addVirtualAuthenticator(page); - - try { - await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); - - // Verify no security keys initially - await expect(page.locator('#webauthn-list')).toContainText('No security keys registered'); - - // Click register button - await page.click('#webauthn-register-btn'); - - // Wait for page reload (registration success triggers reload) - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - - // Verify the passkey now appears in the list - await expect(page.locator('#webauthn-list')).not.toContainText('No security keys registered'); - await expect(page.locator('#webauthn-list li')).toHaveCount(1); - } finally { - await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); - } - }); - }); - - test.describe('Authentication (usernameless)', () => { - test('full round-trip: register passkey, logout, login with passkey', async ({ page, request }) => { - const { cdpSession, authenticatorId } = await addVirtualAuthenticator(page); - - try { - // Step 1: Login with password and register a passkey - await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); - await page.click('#webauthn-register-btn'); - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - - // Verify passkey registered - await expect(page.locator('#webauthn-list li')).toHaveCount(1); - - // Step 2: Logout - await request.post('/logout'); - - // Step 3: Login with passkey (no username) - await page.goto('/login'); - await page.click('#webauthn-login-btn'); - - // Wait for redirect to credentials page - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - expect(page.url()).toContain('/manage/credentials'); - - // Verify we're authenticated — page shows credential management - await expect(page.locator('h1')).toHaveText('Credentials'); - } finally { - await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); - } - }); - - test('shows error when session expired (complete without begin)', async ({ page }) => { - const { cdpSession, authenticatorId } = await addVirtualAuthenticator(page); - - try { - await page.goto('/login'); - - // Intercept the begin request to skip it but still try complete - await page.route('/login/webauthn/complete', async (route) => { - // Simulate server response for missing state - await route.fulfill({ - status: 400, - contentType: 'application/json', - body: JSON.stringify({ error: 'Authentication session expired' }), - }); - }); - - // Directly POST to complete endpoint (skipping begin) - await page.evaluate(async () => { - const res = await fetch('/login/webauthn/complete', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - id: 'fake', - rawId: 'fake', - type: 'public-key', - response: {}, - }), - }); - const data = await res.json(); - const el = document.getElementById('webauthn-login-status'); - if (el) el.innerHTML = '
' + (data.error || 'Failed') + '
'; - }); - - const alert = page.locator('#webauthn-login-status [role="alert"]'); - await expect(alert).toBeVisible({ timeout: 5000 }); - await expect(alert).toContainText('Authentication session expired'); - } finally { - await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); - } - }); - }); - - test.describe('Deletion', () => { - test('delete a passkey when password also exists', async ({ page, request }) => { - const { cdpSession, authenticatorId } = await addVirtualAuthenticator(page); - - try { - // Login and register a passkey (user already has password) - await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); - await page.click('#webauthn-register-btn'); - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - - // Verify passkey is in the list - await expect(page.locator('#webauthn-list li')).toHaveCount(1); - - // The credential list item should have a delete mechanism - // Look for an hx-delete button/link within the credential list - // Since the template shows cred items as
  • with name and date, - // we need to check what delete UI exists - // For now, verify the passkey was registered successfully - // The delete functionality depends on the template having delete buttons - } finally { - await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); - } - }); - }); - - test.describe('Error handling', () => { - test('server error on authentication begin shows error', async ({ page }) => { - await page.goto('/login'); - - // Intercept begin endpoint to return error - await page.route('/login/webauthn/begin', async (route) => { - await route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ error: 'Internal server error' }), - }); - }); - - await page.click('#webauthn-login-btn'); - - const alert = page.locator('#webauthn-login-status [role="alert"]'); - await expect(alert).toBeVisible({ timeout: 5000 }); - }); - - test('server error on authentication complete shows error', async ({ page }) => { - const { cdpSession, authenticatorId } = await addVirtualAuthenticator(page); - - try { - // Need to register a credential first so the authenticator has something - await loginWithPassword(page, fixtures.webauthn_username, fixtures.webauthn_password); - await page.click('#webauthn-register-btn'); - await page.waitForURL('**/manage/credentials', { timeout: 10000 }); - - // Logout - await page.request.post('/logout'); - await page.goto('/login'); - - // Intercept the complete endpoint to return error - await page.route('/login/webauthn/complete', async (route) => { - await route.fulfill({ - status: 400, - contentType: 'application/json', - body: JSON.stringify({ error: 'Authentication failed' }), - }); - }); - - await page.click('#webauthn-login-btn'); - - const alert = page.locator('#webauthn-login-status [role="alert"]'); - await expect(alert).toBeVisible({ timeout: 10000 }); - await expect(alert).toContainText('Authentication failed'); - } finally { - await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); - } - }); - }); -}); -``` - -**Step 2: Run the WebAuthn tests** - -Run: `./tests/e2e/run.sh webauthn.spec.js` -Expected: All WebAuthn tests pass. - -**Step 3: Commit** - -``` -feat(e2e): add WebAuthn E2E tests with CDP virtual authenticator -``` - ---- - -## Task 13: Run full suite and verify - -**Step 1: Run all E2E tests** - -Run: `./tests/e2e/run.sh` -Expected: All tests pass (health, login, password-auth, registration, credentials, auth-guard, full-flow, webauthn). - -**Step 2: Run with visible browser to manually verify** - -Run: `E2E_HEADLESS=0 ./tests/e2e/run.sh` -Expected: Browser opens, tests run visibly, all pass. - -**Step 3: Commit if any fixes were needed** - ---- - -## Task 14: Update beads and sync - -**Step 1: Close the bead** - -```bash -bd close fastapi-oidc-op-3yj --reason "Migrated 7 E2E tests to @playwright/test and added WebAuthn E2E tests with CDP virtual authenticators" -``` - -**Step 2: Sync beads** - -```bash -bd sync -``` diff --git a/docs/plans/2026-02-18-profile-page-design.md b/docs/plans/2026-02-18-profile-page-design.md deleted file mode 100644 index 978c101..0000000 --- a/docs/plans/2026-02-18-profile-page-design.md +++ /dev/null @@ -1,82 +0,0 @@ -# Self-Service Profile Page Design - -## Overview - -Add a `/manage/profile` page where authenticated users can view and edit their OIDC profile fields. This completes the self-service user management story alongside the existing `/manage/credentials` page. - -## Editable Fields - -| Field | Input type | Validation | -|-------|-----------|------------| -| `given_name` | text | max 255 chars | -| `family_name` | text | max 255 chars | -| `preferred_username` | text | max 255 chars | -| `email` | email | HTML5 + server-side format check | -| `phone_number` | tel | optional, no strict format | -| `picture` | url | optional, valid URL check | -| `locale` | text | optional, max 20 chars | - -Read-only: `username` (login identity, displayed but not editable). - -## Architecture - -### Routes - -Add to `manage/routes.py`: - -- `GET /manage/profile` — render profile form pre-filled with current user data -- `POST /manage/profile` — validate and update user, return HTMX partial response - -Auth guard follows existing pattern: `get_session_user(request)` with redirect to `/login`. - -### Templates - -Create `manage/base.html` extending `base.html` with a nav bar containing links to Profile and Credentials. Both manage pages extend this instead of `base.html` directly. - -``` -templates/ - base.html (unchanged) - manage/ - base.html (NEW - adds nav) - credentials.html (CHANGED - extends manage/base.html) - profile.html (NEW - profile form) -``` - -### Navigation - -The `manage/base.html` template adds a `