Skip to content

feat: handle WebAuthn step-up (SUF-02) on sensitive operations (#75) - #89

Merged
devondragon merged 5 commits into
mainfrom
feature/issue-75-webauthn-step-up
Aug 21, 2026
Merged

feat: handle WebAuthn step-up (SUF-02) on sensitive operations (#75)#89
devondragon merged 5 commits into
mainfrom
feature/issue-75-webauthn-step-up

Conversation

@devondragon

@devondragon devondragon commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Closes #75 (OIDC E2E case deferred to #90).

ds-spring-user-framework:5.3.3 is published to Maven Central, so build.gradle's pinned 5.3.3 resolves and CI can build. Verified locally with a clean resolve from Central (./gradlew test --refresh-dependencies).

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 (setPasswordJSONResponse code 6; passkey delete/rename → error: "step-up-required"). On either shape, static/js/user/step-up.js shows a modal, re-runs the existing passkey login ceremony, refreshes the rotated CSRF token from a new GET /csrf, and retries once. Passkey enrollment is gated differently — a bare 403 — 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/webauthn while 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, so step-up.js refreshes it from GET /csrf before retrying. The ceremony itself calls no new endpoints; /csrf is 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):

  • setPassword / delete / rename → ceremony → success
  • factor aged past ttlSeconds → 401 before mutation
  • absent factor → 401, no ceremony
  • stale (factorless) session enrollment → 403 + actionable message
  • enrollment shortly after login → succeeds
  • first-passkey enrollment from a verification-link session → succeeds
  • passkey-registration notification email asserted via Mailpit
  • browser-verification on setPassword: page not navigated, session id preserved, CSRF rotated, authorities survive

Deferred: social-login (OIDC) setPassword fallback#90 (needs the Keycloak stack; its own PR/CI job). notifyOnRegistration=false suppression and the PasskeyRegistration audit 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 into update-password.js and webauthn-manage.js; live CSRF reads throughout.
  • webauthn-register.js: PasskeyEnrollmentStepUpError for 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-up project + @step-up-enabled tag.
  • application-playwright-test.yml: pin webauthn.rpId=localhost (the local profile's ngrok rpId broke the virtual authenticator; also hardens the mfa E2E).
  • compose.dev.yaml: add Mailpit. Docs: AUTHENTICATION / CONFIGURATION / TESTING.

Verification

  • chromium-step-up 8/8; chromium-mfa 1/1; change-password + auth-methods + passwordless-registration 24/24 on a non-step-up server; ./gradlew test green against 5.3.3.

Filed follow-ups

https://claude.ai/code/session_016iMnES4LsPse9LvJvyRpdN

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
Copilot AI lite review requested due to automatic review settings August 21, 2026 02:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 /csrf endpoint plus a new step-up profile 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-scoped csrfHeader/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 cached csrfHeader/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
@devondragon
devondragon merged commit d7f83cc into main Aug 21, 2026
8 checks passed
@devondragon
devondragon deleted the feature/issue-75-webauthn-step-up branch August 21, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Handle step-up-required (401) by re-running the passkey ceremony on sensitive operations

2 participants