Skip to content

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

Closed

Description

@devondragon

Context

SpringUserFramework is adding an opt-in WebAuthn step-up primitive (library issue: devondragon/SpringUserFramework#335, finding SUF-02). When user.security.stepUp.enabled=true, sensitive operations on passwordless (passkey-only) accounts require a WebAuthn assertion performed within a short TTL (default 300s):

  • POST /user/setPassword
  • passkey delete / rename (WebAuthnManagementAPI)

The library ships the server side. This app needs the client handling and the E2E coverage, and serves as the reference implementation.

Scope changed 2026-08-18. This issue originally specified a bespoke ceremony against POST /user/stepup/options and POST /user/stepup/verify. Those endpoints are not being built. The library now expresses step-up as a freshness requirement on the WebAuthn factor that Spring Security already issues, so the step-up ceremony is the ordinary passkey login ceremony, re-run while the user is still logged in. Re-running it refreshes the factor's issuedAt and preserves the session's other authorities.

What the demo app needs

  1. Handle the step-up-required response. Sensitive endpoints return 401 with code step-up-required when no sufficiently recent assertion exists. On that response, run the existing passkey ceremony and retry the original call.
  2. Reuse the existing ceremony. src/main/resources/static/js/user/webauthn-authenticate.js already exports authenticateWithPasskey(), which does /webauthn/authenticate/options -> navigator.credentials.get() -> /login/webauthn. No new ceremony code, and no new endpoints to call.
  3. UI wiring on the affected flows: set-password on a passwordless account, and passkey delete/rename in webauthn-manage.js. Prompt the user before triggering the authenticator so the browser dialog is not a surprise.
  4. Playwright E2E for the enabled path (user.security.stepUp.enabled=true), following the virtual-authenticator setup already used in playwright/tests/mfa/mfa-flow.spec.ts: passwordless account, attempt a sensitive op, ceremony, success; plus the negative path (stale or absent factor, no ceremony, 401).

Why the E2E matters more than usual

Step-up is a full re-login, so the session-fixation strategy runs, the framework's documented duplicate InteractiveAuthenticationSuccessEvent fires, and the /login/webauthn JSON response lands in the middle of another flow. That was checked by reading the code but not in a browser; this issue is where it actually gets verified.

Notes

Activity

  1. changed the title [-]Add WebAuthn step-up ceremony (JS + UI + E2E) for passwordless sensitive operations[/-] [+]Handle step-up-required (401) by re-running the passkey ceremony on sensitive operations[/+] on Aug 19, 2026
  2. devondragon commented on Aug 21, 2026

    @devondragon
    OwnerAuthor

    Library side has landed — updated scope, corrections, and acceptance criteria

    devondragon/SpringUserFramework#365 and #367 are merged to main (unreleased, 5.3.3-SNAPSHOT). This issue is no longer blocked. Several details above are now stale, and the scope grew.

    Corrections to the description above

    • TTL default is 120, not 300. Key is user.security.stepUp.ttlSeconds.
    • factors is a list, not a single factor: user.security.stepUp.factors, default [WEBAUTHN], evaluated any-of.
    • Passkey enrollment is now gated too, which the description does not mention at all. See below.

    New: enrollment is gated, and it fails differently

    POST /webauthn/register now requires an authentication factor issued within user.security.stepUp.enrollmentTtlSeconds (default 600), whenever step-up is enabled. Without this the whole feature protected nothing: an attacker with a stolen session cookie could enroll their own passkey, assert with it to mint a fresh FACTOR_WEBAUTHN, and satisfy every other gate.

    This fails as a plain 403 from the filter chain, not a 401 with step-up-required. It is an authorizeHttpRequests rule, so it never reaches the controller advice that produces the JSON error body. Client handling is therefore different from the other three operations, and the UI needs a message that makes sense for it ("log in again before adding a passkey"), not the generic step-up retry.

    Any factor satisfies enrollment, deliberately not the configured factors list, because with the default [WEBAUTHN] requiring a configured factor would demand a passkey in order to register a first passkey.

    Two interactions that will bite the E2E setup

    1. The mfa profile unprotects /webauthn/register, but the gate still applies. application-mfa.yml:25 lists /webauthn/register in unprotectedURIs, and mfa-flow.spec.ts relies on that to enroll a first passkey while only partially authenticated. The library registers the enrollment gate before the unprotectedURIs permitAll() rule (WebSecurityConfig.java:197 vs :206), so with step-up enabled the gate wins regardless. In practice that flow still passes, because a partially-authenticated MFA session carries a fresh FACTOR_PASSWORD from the password step. But if enrollmentTtlSeconds is ever set shorter than the time the test spends between login and enrollment, it will start failing in a way that looks unrelated.

    2. Dev login stamps no authentication factor, by design. user.dev.auto-login-enabled impersonation proves nothing about presence, so authWithoutPassword(user, DEV_LOGIN) deliberately adds no factor. Any test that authenticates via /dev/login-as/{email} and then tries to enroll a passkey will get a 403 while step-up is on. remember-me.spec.ts is the current dev-login user; anything new should use a real login for passkey work. Remember-me sessions carry no factor either, for the same reason.

    Login flows now stamp factors that previously did not

    To make the enrollment gate satisfiable, the library now stamps a factor on three paths that produced none. Relevant here because it changes what a session carries after these flows:

    • OIDC login (Keycloak, or Google configured with the openid scope) — OidcAuthorizationCodeAuthenticationProvider stamps nothing upstream, unlike the password and plain-OAuth2 providers. The library adds FACTOR_AUTHORIZATION_CODE when a login produced no factor at all. Worth exercising under docker-keycloak.
    • Email-verification link — FACTOR_OTT.
    • Post-registration auto-login (verification disabled) — FACTOR_PASSWORD.

    Without these, a newly registered user's first session carried no factor and could never enroll a first passkey. The passwordless-registration flow in this app depends on that working.

    Social-login accounts no longer get a dead end

    hasPassword() is a null check on the password column, and OAuth/OIDC users are created without one, so every social-login account is "passwordless" for this feature. Previously, enabling step-up gave them a permanent 401 on POST /user/setPassword with no configuration that recovered it. Now StepUpService.canSatisfyStepUp distinguishes "has not authenticated recently" from "could never satisfy this", and such accounts fall back to user.security.allowInitialPasswordSetWithoutStepUp as before. Worth an E2E case, since "sign in with Google, then add a password" is an ordinary flow that was broken.

    Enrollment notification (#367)

    Independent of step-up and on by default: every passkey registration now publishes a PasskeyRegistration audit event and emails the account owner (user.webauthn.notifyOnRegistration, default true). The demo app captures mail already, so this is cheap to assert and it is the detective half of the same threat.


    Acceptance criteria

    Client handling

    • A 401 with error code step-up-required on setPassword, passkey delete, or passkey rename triggers the existing authenticateWithPasskey() ceremony and retries the original call.
    • The user is prompted before the authenticator dialog is triggered, so the browser prompt is not a surprise.
    • A 403 on POST /webauthn/register surfaces a distinct, accurate message. It is not a step-up-required retry and re-running the passkey ceremony will not fix it if the user has no passkey yet.
    • No new endpoints are called. The ceremony is /webauthn/authenticate/options → navigator.credentials.get() → /login/webauthn, unchanged.

    E2E, step-up enabled

    • Passwordless account, sensitive operation with no recent assertion → 401, ceremony runs, retry succeeds.
    • Passwordless account, factor deliberately aged past ttlSeconds → 401 before any mutation occurs.
    • Passkey delete and rename each covered, not just setPassword.
    • Enrollment with a stale session → 403, and the UI explains it.
    • Enrollment shortly after a real login → succeeds.
    • Social-login (OIDC) account with no passkey → setPassword is governed by allowInitialPasswordSetWithoutStepUp, not a permanent 401.
    • First-passkey enrollment works for a freshly registered account whose only session came from the verification link or post-registration auto-login.

    E2E, step-up disabled (the default)

    • Every existing passkey, MFA, and registration flow behaves exactly as it does today. This is the regression gate on the whole feature being opt-in.

    The reason this issue exists — browser-only verification

    These were reasoned about in code review but never observed in a browser. Step-up is a full re-login, so:

    • Session fixation. The session id changes on re-assertion and the user is not logged out or left with a dead session.
    • Duplicate InteractiveAuthenticationSuccessEvent. The framework documents that a second event fires; confirm the demo app's listeners (session profile, audit) tolerate it and do not double-count or corrupt state.
    • Login JSON mid-flow. /login/webauthn returns a JSON body in the middle of another operation; confirm it does not navigate away, clobber the page, or leave the retry unfired.
    • Authorities survive. After step-up, the user retains roles and any session-only authorities. Factor merging is what protects this, and it is enabled by step-up as well as by user.mfa.enabled.

    Notification (#367)

    • Registering a passkey sends the owner an email and records a PasskeyRegistration audit event.
    • user.webauthn.notifyOnRegistration=false suppresses the email and still records the audit event.

    Config for the E2E profile

    user:
      security:
        stepUp:
          enabled: true
          ttlSeconds: 120
          factors: [WEBAUTHN]
          enrollmentTtlSeconds: 600
      webauthn:
        enabled: true
        notifyOnRegistration: true

    Library reference: acceptance criteria and rationale on devondragon/SpringUserFramework#335; CONFIG.md and MIGRATION.md on main carry the full property documentation and the documented residual risks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions