test: pin Spring Security factor-freshness behavior for WebAuthn step-up - #364
Conversation
Adds WebAuthnStepUpFactorAssumptionsTest, which characterizes the four Spring Security 7.1 behaviors the planned built-in step-up primitive (#335) is built on, none of which are this framework's code: - RequiredFactor.validDuration denies a stale WEBAUTHN factor and grants a fresh one. - AbstractAuthenticationProcessingFilter with mfaEnabled=true merges a re-assertion into the existing session, refreshing the WEBAUTHN factor's issuedAt while retaining the user's other authorities, which flips a freshness gate from denied to granted. - With merging disabled the session's authorities are replaced instead, so merging has to be switched on deliberately. - The refreshed factor survives WebAuthnAuthenticationSuccessHandler's DSUserDetails principal conversion. These pin third-party behavior, so a Spring Security upgrade that changes it fails here rather than silently weakening step-up. Refs #335
There was a problem hiding this comment.
Pull request overview
Adds a new characterization/assumptions test that “pins” specific Spring Security 7.1 factor freshness + MFA authority-merging behaviors that the planned built-in WebAuthn step-up design (issue #335) intends to rely on, so upgrades fail loudly if these semantics change.
Changes:
- Introduces
WebAuthnStepUpFactorAssumptionsTestto assertRequiredFactor.validDuration(...)freshness enforcement forFACTOR_WEBAUTHN. - Verifies
AbstractAuthenticationProcessingFilter#setMfaEnabled(true)-driven authority merging refreshes the WEBAUTHN factor while retaining non-factor authorities (and demonstrates the control case when disabled). - Verifies the project’s
WebAuthnAuthenticationSuccessHandlerprincipal conversion preserves the refreshed factor’sissuedAt.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| * filter, and its only type-sensitive step is a reflective {@code declaresToBuilder(authenticationResult)} check. The | ||
| * filter here therefore returns a real {@link WebAuthnAuthentication} (which does declare {@code toBuilder()}) and | ||
| * stubs only the assertion verification, which needs an authenticator. The real | ||
| * {@code WebAuthnAuthenticationProvider} stamps {@code FactorGrantedAuthority.fromAuthority("FACTOR_WEBAUTHN")}, whose | ||
| * {@code issuedAt} defaults to now, so the freshness clock is driven by the genuine login path. |
There was a problem hiding this comment.
Confirmed, and fixed in 060e7c0 — though by changing the test rather than the comment.
You were right that the test bypassed the provider entirely: attemptAuthentication was overridden to return a prebuilt WebAuthnAuthentication, with an identity AuthenticationManager that was never invoked. So the claim that the freshness clock is "driven by the genuine login path" was false of the test — every issuedAt was hand-supplied. A Spring Security release that stopped stamping FACTOR_WEBAUTHN, or stamped it with a non-now instant, would have left all four tests green while step-up silently stopped working.
Rather than weaken the comment, runAssertion now drives the real WebAuthnAuthenticationProvider through the filter's AuthenticationManager, so the stamping and its issuedAt default are genuinely exercised. The only stub left is WebAuthnRelyingPartyOperations.authenticate, which needs a real authenticator. That also retires the dead setAuthenticationManager line, since the manager is now on the path. A new test pins the stamping directly.
The surrounding JavaDoc was corrected on three further points while I was in there: step-up is proposed in #335 rather than shipped (StepUpService is a consumer SPI, and nothing in src/main sets validDuration); the merge is inherited only by AbstractAuthenticationProcessingFilter subclasses, not every authentication filter; and all four shouldPerformMfa gates are named, since the name-equality one was load-bearing and undocumented.
| Authentication merged = runAssertion(new WebAuthnAuthentication(userEntity(), Set.of(webAuthnFactor(freshIssuedAt))), true); | ||
|
|
||
| assertThat(webAuthnFactorsOf(merged)).as("exactly one WEBAUTHN factor survives the merge (deduped by authority string)").hasSize(1); | ||
| assertThat(webAuthnFactorsOf(merged).get(0).getIssuedAt()).as("the surviving WEBAUTHN factor is the newly issued one") | ||
| .isEqualTo(freshIssuedAt); | ||
| assertThat(authorityStrings(merged)).as("non-factor authorities from the existing session are carried over") | ||
| .contains("ROLE_USER"); | ||
|
|
||
| // The step-up assertion itself: the same gate that denied before the ceremony now grants. | ||
| assertThat(granted(freshWebAuthnRequired, context.getAuthentication())).as("gate before step-up").isFalse(); | ||
| assertThat(granted(freshWebAuthnRequired, merged)).as("gate after step-up").isTrue(); |
There was a problem hiding this comment.
Checked this against the Spring Security 7.1.0 source, and the assertion was safe as written — AbstractAuthenticationProcessingFilter.successfulAuthentication does:
SecurityContext context = this.securityContextHolderStrategy.createEmptyContext();
context.setAuthentication(authResult);
this.securityContextHolderStrategy.setContext(context);It allocates a fresh SecurityContext and swaps it into the holder rather than mutating the one the test installed, and the MFA merge itself only reads getContext().getAuthentication(). So the local variable still held the pre-step-up token and the isFalse() was a real expired-factor denial.
Worth noting the failure mode would have been loud rather than silent: had the framework switched to mutating the existing context, the assertion would fail, which is the intended behavior for a characterization test.
That said, the readability point stands — "gate before step-up" reads as a precondition but executed after the ceremony. In 060e7c0 it is captured into a grantedBeforeStepUp local before the assertion runs, so execution order now matches how it reads.
ReviewThis PR adds a single test-only file ( Strengths
Minor / non-blocking suggestions
Bugs / security / performanceNone found. No production code is touched, no new endpoints or data handling, and the test has no external dependencies (no DB, no network, no Spring context), so it's cheap to run and shouldn't affect suite performance meaningfully. Nice groundwork — the four pinned assumptions map cleanly to the four points in the PR description, and this should make the actual #335 implementation much lower-risk. |
Review of #364 found two tests that could not fail and a class JavaDoc that overstated what was exercised. Make the tests discriminating: - shouldPreserveRefreshedFactorWhenPrincipalIsConverted passed the same authority set to both the stubbed UserDetailsService and the incoming authentication, so it could not tell which one the handler reads. The stub now returns roles only, with the factor solely on the incoming authentication. Same flaw and same fix in the pre-existing WebAuthnAuthenticationSuccessHandlerTest.shouldPreserveAuthorities. Verified by mutation: pointing the handler at userDetails.getAuthorities() leaves both original tests green and fails both revised ones. - runAssertion now drives the real WebAuthnAuthenticationProvider through the filter's AuthenticationManager instead of returning a prebuilt WebAuthnAuthentication, so the FACTOR_WEBAUTHN stamping and its issuedAt default are genuinely exercised rather than assumed. Only WebAuthnRelyingPartyOperations.authenticate is stubbed; it needs an authenticator. - The pre-step-up session is now a WebAuthnAuthenticationToken over DSUserDetails, matching what a completed passkey login leaves in the context, so the name-equality gate in shouldPerformMfa is satisfied the way production satisfies it rather than by construction. - The merging-disabled control asserts the exact resulting authority set and gives the session a FACTOR_PASSWORD the UserDetailsService does not re-supply, so it shows a real loss rather than only an absence. Add two cases: the provider stamps a fresh factor onto the user's authorities, and a plain SimpleGrantedAuthority named FACTOR_WEBAUTHN does not satisfy a validDuration requirement. The latter matters because consumers can name any authority in user.roles-and-privileges. Correct the class JavaDoc: the step-up primitive is proposed in #335, not shipped (StepUpService is a consumer SPI and no production code sets validDuration); the merge is inherited only by AbstractAuthenticationProcessingFilter subclasses, not every authentication filter; all four shouldPerformMfa gates are named; and the success-handler test is a local regression guard, not a Spring Security upgrade tripwire. No production code changes. ./gradlew test: 275 suites, 1171 tests, 0 failures.
ReviewThis is a test-only PR (no production code changes), and it's unusually well self-audited already — the second commit explicitly fixes two non-discriminating assertions and a JavaDoc that overstated coverage, found in the author's own review pass. I read the new Verified as correct:
Minor observations, nothing blocking:
No bugs, security concerns, or missing coverage found. This is solid groundwork for #335 — approving from a code-review standpoint. |
The existing look-alike case shows that a plain SimpleGrantedAuthority named FACTOR_WEBAUTHN cannot satisfy a validDuration requirement on its own. It also shadows a real one, which is the case that actually bites. AllRequiredFactorsAuthorizationManager resolves a RequiredFactor by taking the first authority whose string matches and type-checking only that one, and its getFactorGrantedAuthorities does not filter by type despite the name. WebAuthnAuthenticationProvider appends its stamp after the UserDetailsService's authorities, so a consumer privilege named FACTOR_WEBAUTHN always sorts first: the gate denies immediately after a genuine assertion and the fresh factor behind it is never considered. Fails closed, so not a hole, but step-up would be permanently unsatisfiable on such a deployment with an opaque symptom. Pinned here so the built-in primitive proposed in #335 rejects FACTOR_-prefixed names in user.roles-and-privileges at startup rather than discovering this in the field. No production code changes. ./gradlew test: 1172 tests, 0 failures. Refs #335
|
Review This is a test-only PR (confirmed: no src/main changes), so the usual production-code concerns (transactions, security config, DI) don't apply. Reviewed the diff against WebAuthnAuthenticationSuccessHandler/WebAuthnAuthenticationToken in src/main to sanity-check the assumptions the new tests encode. Strengths
Concerns worth discussing
Correctness spot-check Traced through the authority ordering/dedup logic by hand for shouldRefreshWebAuthnFactorWhenReAssertingWhileAuthenticated and shouldDenyWhenALookAlikeAuthorityPrecedesTheStampedFactor -- both are internally consistent with the stated Spring Security behavior and with DSUserDetails's/WebAuthnAuthenticationToken's actual constructors in src/main. I wasn't able to execute ./gradlew test in this environment to independently confirm the suite is green, so I'd lean on the PR's stated '1172 tests, 0 failures' / mutation-testing verification rather than re-deriving it. No security, performance, or test-coverage concerns beyond the maintenance-cost note above -- this is solid, well-documented groundwork for #335. |
Groundwork for #335 (built-in WebAuthn step-up primitive). No production code changes.
Why
A spike asked whether Spring Security 7.1 can serve as the step-up mechanism, instead of the custom challenge/verify endpoints and session marker the original design specified. It can, and the answer rests entirely on framework behavior this project does not own:
FactorGrantedAuthoritycarriesissuedAt, defaulting to now, andWebAuthnAuthenticationProviderstampsFACTOR_WEBAUTHNon every assertion.RequiredFactor.validDuration+AllRequiredFactorsAuthorizationManagerdeny an expired factor.AbstractAuthenticationProcessingFilterwithmfaEnabled=truemerges a re-assertion into the existing session, deduping by authority string in favor of the new authorities, so re-running/login/webauthnwhile logged in refreshes the factor clock and keeps the user's roles.That makes step-up "re-run the ordinary passkey ceremony", with no new endpoints, no challenge store, and no change to the
StepUpServiceSPI shipped in #334.What this adds
WebAuthnStepUpFactorAssumptionsTestpins that behavior so a Spring Security upgrade that changes it fails here rather than quietly weakening step-up:validDuration(5m)denies a 30-minute-old WEBAUTHN factor and grants a just-issued one.SimpleGrantedAuthoritynamedFACTOR_WEBAUTHNdoes not satisfy avalidDurationrequirement: it has noissuedAt.AllRequiredFactorstakes the first authority matching by string and type-checks only that one, and the provider appends its stamp after theUserDetailsService's authorities, so a consumer privilege namedFACTOR_WEBAUTHNsorts first and the gate denies right after a real assertion. Fails closed, but step-up would be permanently unsatisfiable on that deployment, so the primitive in Built-in WebAuthn step-up primitive (default StepUpService) for credential-altering operations #335 should rejectFACTOR_-prefixed names inuser.roles-and-privilegesat startup.WebAuthnAuthenticationProviderstamps exactly oneFACTOR_WEBAUTHNwhoseissuedAtdefaults to the moment of assertion, on top of theUserDetailsService's authorities.issuedAtwins, session-only authorities such as the original login'sFACTOR_PASSWORDsurvive, and the gate flips from denied to granted.FACTOR_PASSWORDis lost.WebAuthnAuthenticationSuccessHandlermust copy authorities off the incoming authentication rather than theUserDetailsit loads, or the refreshed factor disappears during theDSUserDetailsprincipal swap.The only stub is
WebAuthnRelyingPartyOperations.authenticate, which needs a real authenticator; the provider, the filter, and the merge are the genuine path.MfaLoginIntegrationTestalready covers that the merging post-processor reaches the DSL-built filters.WebAuthnAuthenticationSuccessHandlerTest.shouldPreserveAuthoritiesis tightened for the same reason as item 7: it previously handed the same authority set to both the stub and the incoming authentication, so it could not tell which one the handler read. Both revised tests were mutation-checked, and pointing the handler atuserDetails.getAuthorities()fails exactly those two.Verification
./gradlew test: 1172 tests, 0 failures.