Skip to content

Built-in WebAuthn step-up primitive (default StepUpService) for credential-altering operations #335

Description

@devondragon

Summary

Ship a built-in WebAuthn step-up primitive so consumers get step-up re-authentication out of the box, rather than having to implement the StepUpService SPI themselves.

Background

The security concern from SUF-02 (#328) is already closed: POST /user/setPassword now requires a StepUpService when one is provided and is disabled by default otherwise (shipped in #334). This ticket is the enhancement — a default, framework-provided step-up implementation — not a security gap.

Design

Revised 2026-08-18 after a spike (findings, assumptions pinned in #364). The original design proposed custom challenge/verify endpoints with a server-side challenge store, written before this project moved to Spring Boot 4.1 / Spring Security 7.1. SS 7.1 supplies the mechanism directly, so that surface is dropped. The superseded design is preserved in the local design doc's appendix.

Step-up is expressed as a freshness requirement on the WebAuthn factor:

  • WebAuthnAuthenticationProvider stamps FactorGrantedAuthority.fromAuthority("FACTOR_WEBAUTHN") on every assertion, with issuedAt defaulting to now.
  • RequiredFactor.validDuration(ttl) plus AllRequiredFactorsAuthorizationManager deny a factor older than the TTL.
  • AbstractAuthenticationProcessingFilter with mfaEnabled=true merges a re-assertion into the existing session, deduping by authority string in favor of the new authorities. Re-running /login/webauthn while logged in therefore refreshes the clock and keeps the user's roles.

So the ceremony is the ordinary passkey login, re-run. No new endpoints, no challenge storage, no session marker, no library-owned WebAuthnRelyingPartyOperations bean, and no second client ceremony.

The StepUpService SPI shipped in #334 is unchanged: a default implementation satisfies boolean isStepUpSatisfied(User, String, HttpServletRequest) by inspecting the current Authentication, so 5.3.x consumer implementations keep working and still take precedence.

Work

  • user.security.stepUp.* config: enabled (default false), ttlSeconds (default 300), factor (default WEBAUTHN).
  • Default StepUpService bean under @ConditionalOnMissingBean(StepUpService.class), applying the validDuration factor check.
  • Activate filter factor-merging when step-up is enabled, not only when user.mfa.enabled=true. The MfaFilterMergingConfiguration warning about flipping mfaEnabled on consumer-defined filters carries over and must be repeated in the step-up docs.
  • Extend the gate to WebAuthnManagementAPI delete/rename, which today are no-ops for passwordless accounts because requireCurrentPasswordIfSet has no password to check.
  • Sensitive operations rejected for staleness return 401 with code step-up-required; the client re-runs the passkey ceremony and retries.
  • Reject FACTOR_-prefixed authority names in user.roles-and-privileges at startup. AllRequiredFactors resolves a required factor by taking the first authority matching by string and type-checking only that one, and the provider appends its stamp after the UserDetailsService's authorities, so a consumer privilege named FACTOR_WEBAUTHN sorts first and shadows the genuine factor. The gate then denies immediately after a real assertion: fail-closed, but step-up becomes permanently unsatisfiable with an opaque symptom. Pinned in test: pin Spring Security factor-freshness behavior for WebAuthn step-up #364.
  • Docs (CONFIG.md, dsspringuserconfig.properties) and tests.

Open decisions

  • Binding model. Factor freshness is a per-session time window: one ceremony authorizes any sensitive op inside the TTL. The superseded design was single-use and action-bound. Weaker binding, standard mechanism, mitigated by a short TTL.
  • Accounts with no passkey. OAuth-only accounts have no WEBAUTHN factor to refresh. Either leave them on the existing allowInitialPasswordSetWithoutStepUp behavior, or make the required factor configurable, since PASSWORD and the other factors carry the same issuedAt.

Not yet verified

Step-up is a full re-login, so the session-fixation strategy runs, the documented duplicate InteractiveAuthenticationSuccessEvent fires, and the login JSON response lands mid-flow. Nothing in the code looks problematic, but only a browser can confirm it. That is the demo-app work below.

Scope / effort

Roughly a day for the library side, down from the original feature-scale estimate. Not fully validated end to end until the demo-app ceremony lands.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions