add uncommitted plans and CLAUDE.md

This commit is contained in:
Johan Lundberg 2026-04-10 11:28:51 +02:00
parent 6b4cbdc152
commit fb133f9cba
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
10 changed files with 5241 additions and 0 deletions

View file

@ -0,0 +1,88 @@
# 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.

View file

@ -0,0 +1,95 @@
# 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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,851 @@
# 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("<h1>Error</h1><p>No pending consent request</p>", 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("<h1>Error</h1><p>No pending consent request</p>", 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"<h1>Error</h1><p>{exc}</p>", 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 %}
<div class="card consent-card">
<h1>Authorize {{ client_id }}</h1>
<p>This application is requesting access to your account.</p>
<form method="post" action="/consent">
<fieldset>
<legend>Permissions requested</legend>
<ul class="scope-list" role="list">
{% for scope in scopes %}
<li>
<label>
<input type="checkbox" name="scope" value="{{ scope.name }}"
{% if scope.required %}checked disabled{% else %}checked{% endif %}>
{{ scope.description }}
</label>
{% if scope.required %}
<input type="hidden" name="scope" value="{{ scope.name }}">
{% endif %}
</li>
{% endfor %}
</ul>
</fieldset>
<div class="consent-actions">
<button type="submit" name="action" value="allow" class="btn btn-primary">Allow</button>
<button type="submit" name="action" value="deny" class="btn btn-secondary">Deny</button>
</div>
</form>
</div>
{% 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"
```

View file

@ -0,0 +1,117 @@
# 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 |

View file

@ -0,0 +1,913 @@
# 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 = '<div role="alert">' + (data.error || 'Failed') + '</div>';
});
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 <li> 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
```

View file

@ -0,0 +1,82 @@
# 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 `<nav>` element with links:
- Profile (`/manage/profile`)
- Credentials (`/manage/credentials`)
This keeps the nav scoped to authenticated manage pages without modifying the public `base.html`.
### Form Behavior
- HTMX: `hx-post="/manage/profile"` with `hx-target="#profile-status"` and `hx-swap="innerHTML"`
- Success: `<div role="status">Profile updated</div>`
- Validation error: `<div role="alert">specific error message</div>`
- Pre-filled with current values on GET
- Empty optional fields show empty inputs (not "None")
### Validation
Server-side validation in the POST handler:
- `email`: if provided, basic format check (contains `@`)
- `picture`: if provided, basic URL format check (starts with `http://` or `https://`)
- String length limits enforced server-side
- HTML5 input attributes for client-side hints (`type="email"`, `type="url"`, `maxlength`)
### Data Flow
1. GET: `get_session_user()``user_repo.get_by_id(userid)` → render template with user fields
2. POST: `get_session_user()``user_repo.get_by_id(userid)` → validate form data → `user.model_copy(update={...})``user_repo.update(user)` → return status HTML
## E2E Tests
`tests/e2e/profile.spec.js`:
- Page renders with correct form fields
- Auth guard redirects unauthenticated users
- Successful profile update
- Validation errors (invalid email, etc.)
- Navigation between profile and credentials
- Fields pre-filled after update (persistence)

View file

@ -0,0 +1,50 @@
# Self-Service Profile Page — Implementation Plan
## Tasks
### Task 1: Create `manage/base.html` template with navigation
- Create `src/porchlight/templates/manage/base.html`
- Extends `base.html`
- Adds `<nav>` block with Profile and Credentials links
- Style nav links in `style.css` (minimal, consistent with existing design)
### Task 2: Update `credentials.html` to extend `manage/base.html`
- Change `{% extends "base.html" %}` to `{% extends "manage/base.html" %}`
- Verify credentials page still renders correctly
### Task 3: Create `profile.html` template
- Extends `manage/base.html`
- Form with fields: given_name, family_name, preferred_username, email, phone_number, picture, locale
- Read-only username display
- HTMX form submission with status target
- HTML5 input types and validation attributes
### Task 4: Add GET and POST routes for `/manage/profile`
- `GET /manage/profile` — fetch user, render profile.html with pre-filled fields
- `POST /manage/profile` — validate form data, update user via repo, return HTMX partial
- Auth guard via `get_session_user()` on both routes
- Server-side validation for email format, URL format, string lengths
### Task 5: Add nav CSS styles
- Style the manage nav bar (links, active state, spacing)
- Keep consistent with existing design system
### Task 6: Add E2E tests (`profile.spec.js`)
- Page structure and form fields
- Auth guard redirect
- Successful profile update and persistence
- Validation error handling
- Navigation between profile and credentials
### Task 7: Seed test user profile data in `setup_db.py`
- Add profile fields to an existing test user so tests can verify pre-filled data
### Task 8: Run full test suite and verify
- `uv run pytest` (Python tests)
- `npx playwright test` (E2E tests)
- Fix any failures
## Verification
- All existing tests continue to pass
- New E2E tests pass
- Profile page renders, submits, and updates correctly

File diff suppressed because it is too large Load diff