diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java
index eda22c8..c2f436b 100644
--- a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java
+++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnAuthenticationSuccessHandlerTest.java
@@ -173,7 +173,10 @@ void shouldPreserveAuthorities() throws Exception {
WebAuthnAuthentication webAuthnAuth = new WebAuthnAuthentication(userEntity, authorities);
- DSUserDetails dsUserDetails = new DSUserDetails(testUser, authorities);
+ // The handler must read authorities off the incoming authentication, not off the UserDetails it loads.
+ // Giving the loaded principal a narrower set is what makes the assertion below discriminating: ROLE_ADMIN
+ // can only have come from the WebAuthnAuthentication.
+ DSUserDetails dsUserDetails = new DSUserDetails(testUser, Set.of(new SimpleGrantedAuthority("ROLE_USER")));
when(userDetailsService.loadUserByUsername(testUser.getEmail())).thenReturn(dsUserDetails);
// When
diff --git a/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnStepUpFactorAssumptionsTest.java b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnStepUpFactorAssumptionsTest.java
new file mode 100644
index 0000000..69ab2c9
--- /dev/null
+++ b/src/test/java/com/digitalsanctuary/spring/user/security/WebAuthnStepUpFactorAssumptionsTest.java
@@ -0,0 +1,330 @@
+package com.digitalsanctuary.spring.user.security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.mock.web.MockFilterChain;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.authorization.AllRequiredFactorsAuthorizationManager;
+import org.springframework.security.authorization.AuthorizationManager;
+import org.springframework.security.authorization.AuthorizationResult;
+import org.springframework.security.authorization.RequiredFactor;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.FactorGrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.userdetails.UserDetailsService;
+import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.ImmutablePublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationProvider;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationRequestToken;
+import org.springframework.security.web.webauthn.management.RelyingPartyAuthenticationRequest;
+import org.springframework.security.web.webauthn.management.WebAuthnRelyingPartyOperations;
+import com.digitalsanctuary.spring.user.persistence.model.User;
+import com.digitalsanctuary.spring.user.service.DSUserDetails;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+/**
+ * Characterization tests for the behaviour a built-in WebAuthn step-up primitive would depend on (issue #335).
+ *
+ *
+ * This framework ships no step-up primitive today: {@link StepUpService} is an SPI a consuming application implements,
+ * and nothing in production code configures {@code RequiredFactor.validDuration}. The design proposed in #335 would
+ * build step-up on Spring Security's own factor machinery rather than on a bespoke challenge/verify flow: a sensitive
+ * operation would require a {@code FACTOR_WEBAUTHN} {@link FactorGrantedAuthority} issued within a short TTL, and the
+ * user would refresh it by re-running the ordinary passkey assertion at {@code /login/webauthn}. These tests pin the
+ * behaviour that design rests on, ahead of building it.
+ *
+ *
+ *
Freshness enforcement — {@code RequiredFactor.validDuration} denies a stale WEBAUTHN factor, grants
+ * a fresh one, and refuses a look-alike authority that is not a {@link FactorGrantedAuthority} — including when
+ * that look-alike sorts ahead of a genuine one and shadows it.
+ *
Stamping — {@code WebAuthnAuthenticationProvider} adds a {@code FACTOR_WEBAUTHN} authority whose
+ * {@code issuedAt} defaults to now, on top of whatever authorities the {@code UserDetailsService} supplies. That
+ * default is the freshness clock.
+ *
Refresh — re-asserting while already authenticated merges the new factor into the existing session
+ * rather than replacing it, so the fresh {@code issuedAt} wins while the session's other authorities survive. This is
+ * the merging half of {@link AbstractAuthenticationProcessingFilter} that {@code setMfaEnabled(true)} activates; with
+ * it off, the second authentication replaces the first and any authority the {@code UserDetailsService} does not
+ * re-supply (the {@code FACTOR_PASSWORD} from the original login, say) is lost.
+ *
+ *
+ *
+ * Those three are Spring Security's behaviour, not this framework's, so they will fail loudly on an upgrade that
+ * changes them. The last test is different in kind: it pins this framework's own
+ * {@link WebAuthnAuthenticationSuccessHandler} against a regression that would silently drop the refreshed factor
+ * while swapping {@link DSUserDetails} in as the principal.
+ *
+ *
+ *
+ * The only behaviour stubbed is {@link WebAuthnRelyingPartyOperations#authenticate}, which needs a real
+ * authenticator (its request argument is a stand-in for the same reason, and is never read).
+ * Everything downstream is the genuine path: the real {@code WebAuthnAuthenticationProvider} assembles the
+ * authentication and stamps the factor, and the real {@code AbstractAuthenticationProcessingFilter#doFilter} performs
+ * the merge. Only the credential-JSON converter is skipped, by overriding {@code attemptAuthentication} to hand the
+ * authentication manager a request token directly.
+ *
+ *
+ *
+ * That merge lives in {@code AbstractAuthenticationProcessingFilter}, so it is inherited by the filters that extend it
+ * (form login, one-time token, WebAuthn) and not by the {@code OncePerRequestFilter}-based ones. It fires only when
+ * all four of {@code shouldPerformMfa}'s gates pass: {@code mfaEnabled} is set, an authenticated authentication is
+ * already in the context, the result's concrete class declares {@code toBuilder()} (the check reflects over
+ * {@code getDeclaredMethods()}, so an inherited one would not count), and {@code current.getName()} equals the new
+ * result's name. The last gate is why the pre-step-up session below is a {@link WebAuthnAuthenticationToken} over
+ * {@link DSUserDetails}, matching what a completed passkey login actually leaves in the context: its {@code getName()}
+ * resolves to the user's email, the same value {@code WebAuthnAuthentication} takes from its
+ * {@link PublicKeyCredentialUserEntity}.
+ *
+ */
+@DisplayName("WebAuthn Step-Up Factor Assumptions Tests")
+class WebAuthnStepUpFactorAssumptionsTest {
+
+ private static final String EMAIL = "passkey-user@test.com";
+ private static final String ROLE_USER = "ROLE_USER";
+ private static final Duration STEP_UP_TTL = Duration.ofMinutes(5);
+ private static final Duration LONG_AGO = Duration.ofMinutes(30);
+
+ private final AuthorizationManager