Admin credential deletion removed password/WebAuthn credentials with no
last-credential check, so an admin could delete a user's only credential and
lock them out. Use the atomic delete_*_if_not_last repo methods; on refusal
re-render the credentials section unchanged with an explanatory alert.
Refs: porchlight-lg7
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The self-service credential delete handlers counted credentials and then
deleted in separate steps, so concurrent deletes could each see >1 and both
proceed, removing the user's last credential and locking them out.
Add atomic delete_password_if_not_last / delete_webauthn_if_not_last repo
methods (count + delete in one conditional statement) and use them in the
manage delete handlers. Removes the now-unused _count_credentials helper.
Refs: porchlight-2nv
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both password and WebAuthn login wrote the authenticated identity onto the
existing pre-auth session, so a fixed/planted session could be elevated to an
authenticated one. Add _establish_authenticated_session() which clears the
session (preserving only a pending OIDC authorization request) before setting
the identity, used by both login paths.
Tests that reused a pre-login CSRF token now re-fetch it after login, matching
real client behavior.
Refs: porchlight-vxr
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OIDC error responses interpolated parse-error/exception and error_description
text straight into HTML. idpyoidc currently emits canned messages, but this is
the same reflected-XSS class as the validation-error fix; relying on upstream
not to echo input is fragile.
Add a shared _error_page() helper that HTML-escapes the message and route all
six dynamic error responses through it.
Refs: porchlight-8iw
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
idpyoidc advertised PKCE support but did not actually verify the code
challenge at the token endpoint, so a sent code_challenge provided no
protection. Enable the PKCE add-on restricted to S256.
Configured as non-essential: relying parties that do not send a
code_challenge continue to work (no breaking change), but any RP that uses
PKCE must use S256, and the code_verifier is verified at token exchange.
Flip essential=True (or per-client pkce_essential) to require PKCE once all
clients have migrated.
Refs: porchlight-s48
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A registration/re-invite link auto-established a session for any existing
active user, so re-inviting a fully set-up user acted as a passwordless
login. Invite links are for account setup only.
After consuming the token, refuse to establish a session when the target
account already has a password or WebAuthn credential. Credential-less
accounts (e.g. freshly created by initial-admin) can still complete setup.
Account recovery for set-up accounts must use a separate, authenticated flow.
Refs: porchlight-a3a
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Validation and marking-used were two separate steps, so two concurrent
requests for the same registration token could both pass validation before
either marked it used — a replay window.
Add an atomic consume() at the repository (conditional UPDATE ... WHERE
used = 0 AND not expired, gated on rowcount) and service layers, and switch
the /register handler to consume() instead of validate()+mark_used().
Refs: porchlight-ur7
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Magic-link tokens were persisted in plaintext, so a database read disclosed
usable login/invite tokens. The service now hashes tokens (HMAC-SHA256 when a
pepper is configured, else SHA-256 of the high-entropy token) and persists
only the hash; the raw token is exposed solely in the registration URL and is
re-attached to objects returned to callers.
Refs: porchlight-42h
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The /consent POST handler trusted the scope values submitted in the form,
so a forged consent submission could approve (and persist consent for)
scopes that were never part of the originating authorization request —
a scope-escalation vector.
Intersect the submitted scopes with the originally requested set stored in
the session before saving consent and completing the flow.
Refs: porchlight-a03
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
format_validation_errors interpolated Pydantic error messages directly into
HTML. Some messages echo user input (e.g. "Invalid group name '<name>'"), so
a crafted group name was reflected as raw HTML — a stored/reflected XSS.
HTML-escape each formatted message before interpolation.
Refs: porchlight-due
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full Playwright suite authenticates ~100 times in a few minutes, far
over the login endpoint's 5/minute limit, so most specs failed at the
beforeEach login with 429s.
Add an OIDC_OP_RATE_LIMIT_ENABLED setting (default True) wired to the
slowapi limiter's enabled flag, and set it to false in tests/e2e/run.sh.
Production behavior is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The password setup/change form used hx-target="#password-section" with
hx-swap="innerHTML", but that div wraps the form itself. On a validation
error the route returns only an alert div, so the swap replaced the entire
form — the password inputs disappeared. Most visible during registration's
"set password" step.
Retarget the form to a dedicated #password-error div outside the form
(mirrors the working login form's #login-error pattern), so the form and
its inputs survive errors while messages still render inside #password-section.
Also fix pre-existing broken e2e tests: they omitted the required
current_password fill and used passwords below the zxcvbn strength
threshold (score 1 < MIN_PASSWORD_STRENGTH=2).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Use Annotated[str, Form()] for FastAPI dependencies (FAST002)
- Add missing type annotations across src/ and tests/ (ANN001/003/201/202)
- Reduce function arguments via request.form() reads (PLR0913)
- Combine return paths to reduce return statements (PLR0911)
- Use anyio.Path for async-safe filesystem operations (ASYNC240)
- Extract constants, helpers, and dict comprehensions for clarity
- Move inline imports to top-level (PLC0415)
- Use raw strings for regex match patterns (RUF043)
- Fix redundant get_session_user call in delete_user (not-iterable)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use PasswordChange model (requires current password) for users with
existing passwords and PasswordSet for first-time setup. Add zxcvbn
strength validation and current password field to credentials template.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace manual validation error formatting with shared helper in both
admin and manage profile routes. Add UsernameInput validation to invite
route and GroupListInput validation to groups route.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add slowapi-based rate limiting: 5/min on password login, 10/min on
WebAuthn login. Includes shared rate limiter reset fixture for tests.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add active-user checks to password login, WebAuthn login, and magic
link registration to prevent deactivated accounts from authenticating.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add BCP 47 locale validator to ProfileUpdate, UsernameInput model,
GroupListInput model, PasswordSet/PasswordChange with zxcvbn strength
checking, and shared format_validation_errors HTML helper.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add 7 new e2e tests verifying profile form validation in both manage
and admin UIs: invalid phone number, phone normalization, E.164 hint
attributes, and admin-side email/phone/picture URL validation errors.
Fix 3 pre-existing test failures:
- Replace invalid seeded phone number (+1234567890) with valid E.164
(+12025551234) that was causing profile update tests to fail
- Update email validation error assertion to match actual pydantic
message (value_error type uses raw message, not label-prefixed)
The CSRF middleware added to main after the admin-pages branch was
created caused all admin test POSTs/DELETEs to be rejected. Add
get_csrf_token() calls and X-CSRF-Token headers to login helpers and
all mutation requests, matching the pattern used by other tests.
Add /manage/profile page where authenticated users can view and edit
their OIDC profile fields (given_name, family_name, preferred_username,
email, phone_number, picture, locale).
- Create manage/base.html with tab-style nav for Profile/Credentials
- Update credentials.html to extend manage/base.html
- Add GET/POST routes with server-side validation
- Add input styling for tel and url input types
- Add profile test user with pre-filled data in setup_db.py
- Add 19 E2E tests covering structure, navigation, updates, validation
- All 76 E2E tests and 172 Python tests pass
- Use localhost instead of 127.0.0.1 as TARGET_URL so the WebAuthn RP ID
is a valid domain (the spec forbids IP addresses)
- Replace request.post('/logout') with page.context().clearCookies() since
Playwright's request fixture has a separate cookie jar from the page
- Add registerPasskey() helper that waits for 'load' event to reliably
detect the page reload after successful registration
- Track credential count with getCredentialCount() since credentials
accumulate across serial tests sharing the same database
- Fix login.spec.js selector from #webauthn-login-form to #webauthn-login-btn
to match the actual template
All 57 E2E tests now pass (50 migrated + 7 WebAuthn).