feat: handle WebAuthn step-up (SUF-02) on sensitive operations (#75) - #89
Merged
Conversation
Adds the client handling and E2E coverage for the framework's opt-in WebAuthn step-up primitive (library 5.3.3, #365). Off unless user.security.stepUp.enabled is true, so existing demo flows are unchanged. Sensitive operations on passkey-only accounts return 401 until a recent passkey assertion exists: POST /user/setPassword (JSONResponse code 6) and passkey delete/rename (GenericResponse error "step-up-required"). On either shape the new static/js/user/step-up.js prompts with a modal, re-runs the existing passkey login ceremony (authenticateWithPasskey), and retries once. Re-running /login/webauthn mid-session rotates the CSRF token (default session repository) and changes the session id; since step-up stays on the page instead of navigating, step-up.js refreshes the token from the new GET /csrf endpoint before retrying. Wired into update-password.js (setPassword) and webauthn-manage.js (delete, rename), reading the CSRF token live so the retry uses the fresh value. E2E: new application-step-up.yml profile and chromium-step-up Playwright project (@step-up-enabled, excluded from the default and mfa projects). step-up-flow.spec covers the enabled path (ceremony then retry succeeds, for setPassword and rename) and the negative path (absent WEBAUTHN factor returns 401 and runs no ceremony). application-playwright-test.yml now also pins user.webauthn.rpId to localhost and allowedOrigins to http://localhost:8080 so the virtual-authenticator ceremonies work even when a developer's application-local.yml points WebAuthn at an ngrok host; this also hardens the existing mfa E2E under local,playwright-test. Docs: AUTHENTICATION.md gains a step-up section; CONFIGURATION.md and TESTING.md document the profile and the new Playwright project. Verified: chromium-step-up 3/3 pass; change-password + auth-methods 16/16 pass on a non-step-up server (no regression); ./gradlew test green against 5.3.3. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN
Contributor
There was a problem hiding this comment.
Pull request overview
Adds demo-app client support and E2E coverage for the Spring User Framework’s opt-in WebAuthn step-up primitive (SUF-02), including a client-side retry wrapper that re-runs the existing passkey authentication ceremony and refreshes CSRF mid-flow.
Changes:
- Introduces a generic
withStepUp()JS wrapper + modal UX to handle step-up-required 401 responses, re-authenticate via passkey, refresh CSRF, and retry once. - Wires step-up handling into passkey rename/delete and initial password set flows.
- Adds a
GET /csrfendpoint plus a newstep-upprofile and Playwright project/specs to validate the end-to-end behavior.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/main/resources/static/js/user/webauthn-manage.js | Wraps passkey rename/delete with withStepUp() and switches those calls to read CSRF live. |
| src/main/resources/static/js/user/update-password.js | Wraps /user/setPassword with withStepUp() and uses CSRF helpers for the step-up retry path. |
| src/main/resources/static/js/user/step-up.js | New client module implementing step-up detection, modal prompt, passkey ceremony reuse, CSRF refresh, and one retry. |
| src/main/resources/application-step-up.yml | New Spring profile enabling step-up with a short TTL for demo/testing. |
| src/main/resources/application-playwright-test.yml | Pins WebAuthn rpId/allowedOrigins for reliable virtual-authenticator E2E runs on localhost. |
| src/main/java/com/digitalsanctuary/spring/demo/controller/CsrfController.java | New endpoint to retrieve the current session CSRF token for post-step-up retries. |
| playwright/tests/step-up/step-up-flow.spec.ts | New Chromium-only E2E spec covering step-up success paths and the negative (no ceremony) path. |
| playwright/playwright.config.ts | Adds a dedicated chromium-step-up project and excludes step-up tests from default projects. |
| docs/TESTING.md | Documents how to run MFA vs step-up Playwright projects and why rpId/origin are pinned. |
| docs/CONFIGURATION.md | Adds the step-up profile row and cross-links to step-up auth docs. |
| docs/AUTHENTICATION.md | Documents step-up behavior, response shapes, CSRF rotation handling, and the Playwright coverage. |
| build.gradle | Bumps ds-spring-user-framework dependency to 5.3.3 (step-up support). |
Suppressed comments (2)
src/main/resources/static/js/user/webauthn-manage.js:184
- After
withStepUp()succeeds, the CSRF token may have rotated.loadPasskeys()uses the module-scopedcsrfHeader/csrfToken, so refreshing them here avoids an immediate CSRF failure when reloading the list after a step-up flow.
invalidateAuthMethodsCache();
loadPasskeys();
updateAuthMethodsUI();
src/main/resources/static/js/user/webauthn-manage.js:251
- Same as rename: after
withStepUp()succeeds, refresh the module-scoped CSRF values before calling helpers that still use the cachedcsrfHeader/csrfToken, otherwise follow-up requests can fail with a stale token.
invalidateAuthMethodsCache();
loadPasskeys();
updateAuthMethodsUI();
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
8
to
11
| import { withStepUp, StepUpCancelledError } from '/js/user/step-up.js'; | ||
|
|
||
| const csrfHeader = getCsrfHeaderName(); | ||
| const csrfToken = getCsrfToken(); |
Comment on lines
+33
to
+37
| @GetMapping("/csrf") | ||
| public Map<String, String> csrf(CsrfToken token) { | ||
| return Map.of("token", token.getToken(), "headerName", token.getHeaderName(), "parameterName", token.getParameterName()); | ||
| } | ||
| } |
…ate, E2E (#75) Addresses valid findings from the ticket-grounded review of #75, and closes gaps found by actually exercising the expanded acceptance criteria in a browser. Client: - Enrollment step-up (SUF-02) now has its own handling. POST /webauthn/register is gated by an authorization rule that returns a bare 403, not a step-up-required 401, and no passkey ceremony can satisfy a first-passkey enrollment. registerPasskey throws PasskeyEnrollmentStepUpError on 403 and the profile page shows an actionable "sign in again" message instead of the generic failure. - webauthn-manage.js now reads the CSRF header/token live in the remove-password and credential-list requests too, not just delete/rename. An in-page step-up ceremony rotates the CSRF token, so the previously captured module-level values were stale and the next state-changing request would 403 until reload. Demo: - Add the mail/webauthn-credential-registered.html template. 5.3.3 (#367) notifies the owner on every passkey registration via this template; the demo shipped without it, so enrollment logged a TemplateInputException. E2E (chromium-step-up): - Serial execution: concurrent passwordless registrations deadlock on the user_account insert (a framework-side concurrency limit), which surfaced as intermittent 500s. - Delete now has a ceremony-then-success case (deleting the last passkey on a passwordless account is blocked for lockout safety, so it enrolls a second passkey on a roaming authenticator first). - The setPassword case now asserts the browser-only behavior the ticket exists to verify: the page did not navigate, the session id is preserved (factor merging reuses the session rather than running fixation; the principal is unchanged so no fixation vector), the CSRF token rotated (which is why the /csrf refresh is load-bearing), and authorities survive (a protected page stays reachable). - Enrollment shortly after login is asserted explicitly. Verified: chromium-step-up 4/4; chromium-mfa 1/1; change-password + auth-methods + passwordless-registration 24/24 on a non-step-up server. Still outstanding from the expanded AC, deferred deliberately (see PR notes): TTL-expiry and stale-session-enrollment E2E (need a short-TTL profile or an aged session), social-login (OIDC) setPassword fallback (needs the Keycloak stack), the notification email/audit assertions (need Mailpit querying), and first-passkey-from-verification-link. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN
…n-link enrollment (#75) Adds the deterministic E2E acceptance cases from the expanded #75 criteria, via a test-only override profile (application-step-up-e2e.yml) layered under the chromium-step-up run: - ttlSeconds shrunk to 2 so a WEBAUTHN factor can be aged past the window in a few seconds. New test: assert a passkey login, wait it out, and setPassword is then refused 401 code 6 before any mutation (E2E AC "factor aged past ttlSeconds"). - dev.auto-login-enabled turned on so /dev/login-as reaches a factorless session deterministically. New test: enrollment from that session returns 403 and the UI shows the actionable "sign in again" message, no passkey added (E2E AC "stale session -> 403, UI explains"; exercises the new PasskeyEnrollmentStepUpError path). - New test: register-then-confirm via the emailed link auto-logs-in with FACTOR_OTT, and a first passkey enrolls successfully from that session (E2E AC "first-passkey enrollment from a verification-link session"). The realistic demo profile (application-step-up.yml, ttlSeconds 120) is unchanged; the short window lives only in the E2E override. Run: APP_PROFILES=local,playwright-test,step-up,step-up-e2e npx playwright test --project=chromium-step-up Verified: chromium-step-up 7/7. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN
Adds the notification acceptance case (#367): registering a passkey emails the owner. Mail is redirected to a Mailpit catcher and the E2E asserts the message arrives. - compose.dev.yaml: add Mailpit (SMTP 1025, REST/web 8025). bootRun starts it automatically alongside MariaDB; nothing sends to it unless a profile points spring.mail there. - application-step-up-e2e.yml: point spring.mail at localhost:1025 so enrollment notifications (notifyOnRegistration on by default) land in Mailpit. - step-up-flow.spec.ts: new test polls Mailpit's REST API for the "New passkey added to your account" message to the enrolling user. - Docs: TESTING.md and CONFIGURATION.md document the step-up-e2e override and Mailpit. The notifyOnRegistration=false suppression and the PasskeyRegistration audit event are covered library-side (#367); the audit event is not observable from the browser harness. Social-login (OIDC) setPassword fallback is deferred to #90 (needs the Keycloak stack). Verified: chromium-step-up 8/8. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN
Adds a third Playwright E2E step covering the step-up suite, with a Mailpit service container for the notification assertion. Uses APP_PROFILES=local,playwright-test,step-up,step-up-e2e: local activates the dev-login controller (@Profile("local")) the factorless-session case needs, application-local.yml is absent in CI so it contributes no overrides, and playwright-test pins rpId=localhost for the virtual authenticator. Claude-Session: https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #75 (OIDC E2E case deferred to #90).
Client handling and E2E for the framework's opt-in WebAuthn step-up primitive (SUF-02), landed library-side in devondragon/SpringUserFramework#365 / #367 and released as 5.3.3. Off by default (
user.security.stepUp.enabled=false), so existing demo flows are unchanged until enabled.What it does
Sensitive operations on a passkey-only account return 401 until a recent passkey assertion exists (
setPassword→JSONResponsecode6; passkey delete/rename →error: "step-up-required"). On either shape,static/js/user/step-up.jsshows a modal, re-runs the existing passkey login ceremony, refreshes the rotated CSRF token from a newGET /csrf, and retries once. Passkey enrollment is gated differently — a bare403— and gets its own "sign in again" message (PasskeyEnrollmentStepUpError), since no ceremony can satisfy a first-passkey enrollment.CSRF rotation mid-flow (verified in-browser)
Re-running
/login/webauthnwhile logged in rotates the CSRF token but, because step-up uses factor merging, does not rotate the session id (the E2E asserts both; see devondragon/SpringUserFramework#369). The retry would 403 on the stale token, sostep-up.jsrefreshes it fromGET /csrfbefore retrying. The ceremony itself calls no new endpoints;/csrfis a support call.Acceptance criteria coverage (from the issue's expanded criteria)
Client handling — all covered: 401 ceremony+retry (setPassword/delete/rename), prompt-before-authenticator, distinct 403 enrollment message, no new ceremony endpoints.
E2E (
chromium-step-up, 8 tests):ttlSeconds→ 401 before mutationDeferred: social-login (OIDC) setPassword fallback → #90 (needs the Keycloak stack; its own PR/CI job).
notifyOnRegistration=falsesuppression and thePasskeyRegistrationaudit event are covered library-side (#367); the audit event isn't observable from the browser harness.Changes
static/js/user/step-up.js(new); wired intoupdate-password.jsandwebauthn-manage.js; live CSRF reads throughout.webauthn-register.js:PasskeyEnrollmentStepUpErrorfor the 403 enrollment gate; surfaced on the profile page.controller/CsrfController.java(new):GET /csrf.mail/webauthn-credential-registered.html(new): the 5.3.3 notification template the demo was missing.application-step-up.yml(demo) +application-step-up-e2e.yml(test-only: short TTL, dev login, Mailpit) +chromium-step-upproject +@step-up-enabledtag.application-playwright-test.yml: pinwebauthn.rpId=localhost(thelocalprofile's ngrok rpId broke the virtual authenticator; also hardens the mfa E2E).compose.dev.yaml: add Mailpit. Docs: AUTHENTICATION / CONFIGURATION / TESTING.Verification
chromium-step-up8/8;chromium-mfa1/1;change-password+auth-methods+passwordless-registration24/24 on a non-step-up server;./gradlew testgreen against 5.3.3.Filed follow-ups
https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN