Skip to content

feat: built-in WebAuthn step-up for credential-altering operations - #365

Merged
devondragon merged 6 commits into
mainfrom
feat/webauthn-step-up
Aug 21, 2026
Merged

feat: built-in WebAuthn step-up for credential-altering operations#365
devondragon merged 6 commits into
mainfrom
feat/webauthn-step-up

Conversation

@devondragon

Copy link
Copy Markdown
Owner

Implements #335. Off by default; no behavior change until user.security.stepUp.enabled=true.

Approach

Step-up is a freshness requirement on a Spring Security factor, not a bespoke ceremony. WebAuthnAuthenticationProvider stamps FACTOR_WEBAUTHN with the moment of assertion, and re-running the ordinary passkey login while already authenticated merges a newly stamped factor onto the session. The ceremony is therefore the login flow the application already has, re-run: no challenge endpoint, no server-side challenge state, no step-up token, and nothing new for clients to implement beyond handling a 401.

The framework behavior this rests on is pinned by WebAuthnStepUpFactorAssumptionsTest (#364).

What changed

  • StepUpConfigProperties: user.security.stepUp.{enabled,ttlSeconds,factors}, defaulting to false / 120 / [WEBAUTHN]. Unknown or empty factor names fail startup rather than producing an operation nobody can perform.
  • DSFactorFreshnessStepUpService: registered under @ConditionalOnMissingBean(StepUpService.class) when enabled. It satisfies the SPI shipped in security: remaining SUF fixes (SUF-02 step-up, SUF-05 reset token, SUF-06 hardening) #334 unchanged, so consumer implementations keep working and still take precedence.
  • Passkey delete/rename now go through the same gate. These previously required nothing at all on a passwordless account, since the current-password check returns immediately when there is no password. Enforcement applies only when a StepUpService is configured, so existing deployments see no change. Rejections are 401 with error code step-up-required.
  • Factor merging is now enabled by step-up as well as by user.mfa.enabled. Without it, a re-assertion replaces the session's authorities instead of refreshing the factor on them.

Security fix that fell out of this

FactorAuthorityNameValidator rejects FACTOR_-prefixed role and privilege names. A plain granted authority with such a name is indistinguishable from a real factor by name, and database-loaded authorities sort ahead of the stamped one, so:

  • MFA enforcement checks only presence, so the counterfeit satisfies a required factor the user never completed. That is an authentication bypass for any deployment that happens to use such a name, independent of step-up.
  • Step-up additionally checks age, finds no issue time on the counterfeit, and denies even a genuine just-completed ceremony.

Startup fails when such a name is configured while MFA or step-up is enabled, and logs an error otherwise.

Decisions

Recorded on #335: the proof is bound to user, session, and time, but is not single-use and not per-action. That closes the case the feature exists for, an attacker holding a session cookie and no authenticator. It leaves a narrower one open, an attacker sharing the session piggybacking inside the window, hence the short default TTL. Accounts with no passkey (OAuth-only) stay on allowInitialPasswordSetWithoutStepUp; AUTHORIZATION_CODE is configurable but not recommended, since refreshing it usually round-trips silently through the identity provider.

Verification

./gradlew check: 1204 tests, 0 failures (30 new). ./gradlew javadoc clean.

Not verified in a browser: step-up is a full re-login, so session fixation, the duplicate InteractiveAuthenticationSuccessEvent, and the login JSON response landing mid-flow all need the demo app. That is devondragon/SpringUserFrameworkDemoApp#75, which also carries the Playwright E2E.

Copilot AI lite review requested due to automatic review settings August 19, 2026 19:10

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 an opt-in, built-in StepUpService implementation based on Spring Security factor freshness (defaulting to WebAuthn) to protect credential-altering operations in this library, while preserving the existing “off by default” contract unless user.security.stepUp.enabled=true.

Changes:

  • Introduces StepUpConfigProperties, StepUpAutoConfiguration, and DSFactorFreshnessStepUpService to provide a default step-up mechanism when enabled.
  • Extends step-up gating to WebAuthn credential rename/delete for passwordless accounts (401 + step-up-required), and enables factor-merging when step-up is enabled (even if MFA is off).
  • Adds a startup validator to reject FACTOR_* role/privilege names that would collide with Spring Security factor authorities; updates docs and adds tests.

Reviewed changes

Copilot reviewed 20 out of 20 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/test/java/com/digitalsanctuary/spring/user/security/StepUpIntegrationTest.java Full-context test that step-up registers the service and enables filter merging with MFA off
src/test/java/com/digitalsanctuary/spring/user/security/StepUpAutoConfigurationTest.java Auto-config contract tests: default-off, enablement, backoff, factor validation, validator presence
src/test/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfigurationTest.java Ensures merging post-processor is registered when step-up alone is enabled
src/test/java/com/digitalsanctuary/spring/user/security/FactorAuthorityNameValidatorTest.java Tests for reserved FACTOR_* authority-name detection and fail/log behavior
src/test/java/com/digitalsanctuary/spring/user/security/DSFactorFreshnessStepUpServiceTest.java Unit tests for factor freshness and caller identity checks
src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPITest.java Updates existing API tests for new method signatures and request parameter
src/test/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPIStepUpTest.java New tests for step-up enforcement on passkey rename/delete
src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports Registers StepUpAutoConfiguration in auto-config imports
src/main/resources/config/dsspringuserconfig.properties Documents new user.security.stepUp.* properties and defaults
src/main/java/com/digitalsanctuary/spring/user/security/StepUpConfigProperties.java New configuration properties for step-up enablement/TTL/factors
src/main/java/com/digitalsanctuary/spring/user/security/StepUpAutoConfiguration.java Auto-config that validates factors, wires default StepUpService, and registers authority-name validator
src/main/java/com/digitalsanctuary/spring/user/security/MfaFilterMergingConfiguration.java Enables factor merging when MFA or step-up is enabled
src/main/java/com/digitalsanctuary/spring/user/security/FactorAuthorityNameValidator.java New startup validator preventing FACTOR_* authority collisions
src/main/java/com/digitalsanctuary/spring/user/security/DSFactorFreshnessStepUpService.java Default step-up implementation using factor issued-at freshness
src/main/java/com/digitalsanctuary/spring/user/exceptions/WebAuthnStepUpRequiredException.java New exception for “step-up required” signaling (401 + distinct error code)
src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPIAdvice.java Maps step-up-required exception to 401 with step-up-required code
src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java Adds step-up gating for passkey rename/delete on passwordless accounts and threads HttpServletRequest through
src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java Updates references in comments to renamed gating method
MIGRATION.md Documents built-in step-up behavior, reserved name validation, and factor-merging implications
CONFIG.md Adds full step-up configuration + client contract documentation

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +186 to 188
* {@link #requireCredentialProof} and result in HTTP 400 with the message
* {@code "Current password is required to change authentication methods."}. A blank or incorrect
* {@code currentPassword} is likewise rejected with a 400 before any mutation occurs.
Set<String> offenders = new TreeSet<>();
rolesAndPrivilegesConfig.getRolesAndPrivileges().forEach((role, privileges) -> Stream
.concat(Stream.of(role), privileges == null ? Stream.<String>empty() : privileges.stream())
.filter(name -> name != null && name.toUpperCase().startsWith(FACTOR_PREFIX)).forEach(offenders::add));
Comment on lines +26 to +28
@Data
@ConfigurationProperties(prefix = "user.security.step-up")
public class StepUpConfigProperties {
Comment on lines +86 to +88
public void setSecurityContextHolderStrategy(SecurityContextHolderStrategy securityContextHolderStrategy) {
this.securityContextHolderStrategy = securityContextHolderStrategy;
}
Comment on lines +79 to +81
Set<String> unknown = factors.stream().filter(factor -> factor == null || factor.isBlank()
|| !StepUpConfigProperties.FACTOR_AUTHORITIES.containsKey(factor.toUpperCase()))
.map(factor -> factor == null ? "null" : factor).collect(Collectors.toCollection(TreeSet::new));
@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Review: feat: built-in WebAuthn step-up for credential-altering operations

Nicely scoped feature: off by default, reuses the existing login ceremony instead of inventing a new challenge protocol, and the FactorAuthorityNameValidator fix is a genuine, well-reasoned bypass closure that is worth its own praise (fail-fast when MFA/step-up are enabled, fail-loud otherwise). The test suite is thorough (freshness, TTL boundaries, caller-identity checks, factor selection, AnyNestedCondition wiring, full-context integration).

Two things worth a look before merge:

1. Stale Javadoc in WebAuthnManagementAPI (correctness of docs)

src/main/java/com/digitalsanctuary/spring/user/api/WebAuthnManagementAPI.java (class-level Javadoc, "Residual risk" paragraph, around L65-68) still reads:

For passwordless (passkey-only) accounts there is no current password to verify, and this library does not yet implement a WebAuthn step-up assertion. Passkey delete/rename on such accounts therefore remain session-only operations.

That is exactly what this PR fixes: requireCredentialProof now gates passwordless delete/rename through StepUpService when one is configured. The method-level Javadoc on requireCredentialProof was correctly updated, but this class-level paragraph was missed and now contradicts the actual behavior (and the CONFIG.md/MIGRATION.md updates in the same PR). Worth a follow-up edit so generated docs do not mislead consumers about the residual risk.

2. StepUpConfigProperties.ttlSeconds @min(1) is inert without @validated

src/main/java/com/digitalsanctuary/spring/user/security/StepUpConfigProperties.java adds @min(1) on ttlSeconds, but the class itself is not annotated @validated. Per the Spring Boot @ConfigurationProperties validation model, JSR-303 constraints are only enforced when the properties class carries @validated, and that is exactly the pattern this repo already follows elsewhere (UserSecurityConfigProperties, PasswordPolicyConfigProperties both pair their @Min/etc annotations with @validated). As written, a misconfigured user.security.stepUp.ttlSeconds=0 (or negative) will not fail fast at startup the way StepUpAutoConfiguration.validateFactors deliberately does for factor names; it will silently bind and only surface as strange runtime behavior in Duration.ofSeconds(...)/RequiredFactor.validDuration(...). Given how much of this PR is about failing startup rather than producing a confusing runtime symptom, this one slipped through. Suggest adding @validated to the class.

Other notes (non-blocking)

  • DSFactorFreshnessStepUpService public constructor documents that "factor names must already have been validated" but does not enforce it itself; a consumer who constructs it directly (bypassing StepUpAutoConfiguration) with a bad factor name gets an NPE inside RequiredFactor.withAuthority(null)... rather than a clear message. Minor, since the normal path (auto-configuration) always validates first.
  • The security model (freshness bound to user+session+time, not single-use/per-action) is clearly documented and the tradeoffs are well justified given the short default TTL (120s) and the threat model called out in the PR description.
  • Good catch tying MfaFilterMergingConfiguration factor-merging post-processor to step-up as well as MFA: without it, a step-up re-assertion would silently drop the session existing authorities. The AnyNestedCondition wiring and accompanying integration test (StepUpIntegrationTest) verifying mfaEnabled=true on all AbstractAuthenticationProcessingFilter beans in a live context is a good defense against this regressing silently.

Nothing here blocks merge on its own; both flagged items are documentation/validation-robustness gaps rather than functional bugs, but worth fixing given this PR own stated bar of failing startup rather than producing an operation nobody can perform.

Ships a default StepUpService so consumers get step-up out of the box
instead of implementing the SPI themselves (#335). Off by default.

Step-up is expressed as a freshness requirement on a Spring Security
factor: WebAuthnAuthenticationProvider stamps FACTOR_WEBAUTHN with the
moment of assertion, and re-running the ordinary passkey ceremony while
already logged in merges a newly stamped factor onto the session. So the
ceremony is the existing login flow, re-run. No new endpoints, no
server-side challenge state, no step-up token, and no second client
ceremony to write.

- user.security.stepUp.{enabled,ttlSeconds,factors}, defaults false/120/
  [WEBAUTHN]. Unknown or empty factor names fail startup.
- DSFactorFreshnessStepUpService satisfies the SPI shipped in #334
  unchanged, so consumer implementations keep working and still win via
  @ConditionalOnMissingBean.
- Passkey delete/rename now go through the same gate. They previously
  required nothing at all on a passwordless account: the current-password
  check returns immediately when there is no password. Enforcement only
  applies when a StepUpService is configured, so existing deployments are
  unaffected. Rejections are 401 with error code step-up-required.
- Enabling step-up now also enables factor merging, which was tied to
  user.mfa.enabled. Without it a re-assertion replaces the session's
  authorities instead of refreshing the factor on them.

Also adds FactorAuthorityNameValidator, which rejects FACTOR_-prefixed
role and privilege names. Such a name is indistinguishable from a real
factor by name, and the authorities loaded from the database sort ahead
of the stamped one: it satisfies MFA enforcement without the factor ever
being completed (an authentication bypass for that deployment), and it
shadows the genuine factor in a step-up freshness check. Startup fails
when MFA or step-up is enabled, and logs an error otherwise.

Binding model, per the decisions on #335: the proof is bound to user,
session, and time, but is not single-use and not per-action. That closes
the case the feature exists for, an attacker with a session cookie and no
authenticator. It leaves an attacker sharing the session able to
piggyback inside the window, hence the short default TTL.

Docs: CONFIG.md and MIGRATION.md.

./gradlew check: 1204 tests, 0 failures.

Refs #335
@devondragon
devondragon force-pushed the feat/webauthn-step-up branch from 9660d18 to 103e8fe Compare August 20, 2026 18:04
@devondragon

Copy link
Copy Markdown
Owner Author

Status: parked pending a design decision

Rebased onto 5.3.2. Recording the review findings and why this isn't merging yet, so the reasoning lives with the branch.

Prerequisite, now resolved

The step-up gate on passkey deletion was circumventable at the endpoint level: Spring Security's WebAuthnRegistrationFilter also serves DELETE /webauthn/register/{id}, guarded only by an ownership check, bypassing last-credential lockout protection, current-password re-auth, and audit logging. Fixed in #366 and released in 5.3.2 (GHSA-3cv9-vgqh-jwpm). That was independent of this feature but a precondition for it doing anything.

The blocker

UserService.hasPassword() is password != null && !isEmpty(). OAuth2/OIDC users are created by DSOAuth2UserService/DSOidcUserService with no password ever set, so every social-login user is "passwordless" for this feature's purposes. This is not a niche passkey-only population; it's anyone using social login.

Consequence: with user.security.stepUp.enabled=true, every social-login user gets a permanent HTTP 401 on POST /user/setPassword. They have no passkey, so they can never produce a fresh FACTOR_WEBAUTHN, and the else if branch holding allowInitialPasswordSetWithoutStepUp is unreachable whenever a StepUpService bean exists (UserAPI.java:572-585). No configuration recovers it. "Sign in with Google, then add a password" is an ordinary flow and this breaks it silently.

CONFIG.md currently promises the opposite: that accounts with no passkey stay governed by allowInitialPasswordSetWithoutStepUp "exactly as before".

The fix needs step-up to distinguish "this user could satisfy a configured factor and didn't" from "this user has no way to satisfy any configured factor", and treat the second as a fallback rather than a denial. The framework has no per-factor notion of achievability today, so this is a design addition, not a patch.

Also outstanding

  • Enrollment is not gated. POST /webauthn/register needs only a session, so an attacker with a stolen cookie on a passwordless account can enroll their own passkey, assert with it to earn a fresh factor, and then satisfy every gate here. The claim at DSFactorFreshnessStepUpService.java:40-42 that such an attacker "can never produce a recent factor" is false as written. Options: gate enrollment when the user already has a credential (preserves bootstrap), or narrow the claim.
  • SPI contract widened. StepUpService is unchanged in signature, but consumers who adopted it in 5.3.1 for set-password now receive rename-passkey and delete-passkey, because WebAuthnManagementAPI keys off bean presence rather than stepUp.enabled. An implementation returning true unconditionally silently authorizes passkey deletion.
  • @Min(1) on ttlSeconds is inertStepUpConfigProperties lacks @Validated, unlike its siblings. ttlSeconds=0 binds and denies everything forever.
  • Startup checks skipped under lazy init. FactorAuthorityNameValidator's @PostConstruct never runs when nothing injects it; ContextRefreshedEvent (as RolePrivilegeSetupService uses) is the pattern here.
  • FactorAuthorityNameValidator reads config, not the database. Authorities come from the role/privilege tables and RolePrivilegeSetupService never deletes, so a FACTOR_* row surviving its removal from config still grants the counterfeit authority while the check passes clean.
  • Stale docs. WebAuthnManagementAPI.java:66-70 and MIGRATION.md:215 both still state the library "does not yet implement a WebAuthn step-up assertion".
  • Test gaps. No end-to-end test joining the real service to a real request; WebAuthnManagementAPIAdviceTest has no case for the new 401 / step-up-required code.

Assessment

The architecture is sound. Freshness is delegated to Spring Security's RequiredFactor.validDuration rather than hand-rolled, and the session-bound/time-bound/not-single-use model matches what Spring Security ships and what GitHub's sudo mode does. Both security gaps are coverage problems, not design problems.

What's missing is a decision on how step-up behaves for users who cannot satisfy any configured factor. That gates the rest.

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Review: Step-Up Re-Authentication (#335)

Solid design — expressing step-up as a freshness check on Spring Security's own FactorGrantedAuthority rather than building a bespoke challenge/token mechanism is elegant, and it's a genuinely nice trick to reuse the existing login ceremony for the "assert again" step. Default-off, @ConditionalOnMissingBean backoff for consumer SPIs, and the fail-fast validation of factor names are all good calls. Test coverage is thorough (service freshness/identity/TTL edge cases, validator collision cases, autoconfig wiring, and a full-context integration test for the filter-merging interaction).

Two issues worth fixing before merge:

1. StepUpConfigProperties.ttlSeconds @Min(1) is silently unenforced (missing @Validated)

StepUpConfigProperties (src/main/java/.../security/StepUpConfigProperties.java) declares @Min(1) on ttlSeconds but the class itself isn't annotated @Validated. In this codebase, JSR-303 constraints on @ConfigurationProperties classes only get enforced when the class also carries @Validated — see the existing pattern in PasswordPolicyConfigProperties and UserSecurityConfigProperties, which both pair @Min with @Validated. Classes without any constraints (RememberMeConfigProperties, WebAuthnConfigProperties, MfaConfigProperties) correctly omit it. StepUpConfigProperties breaks that pairing.

Practical impact: user.security.stepUp.ttlSeconds=0 (or a negative value) will not fail startup as intended. With ttlSeconds=0, Duration.ofSeconds(0) makes the freshness window zero-length, so no factor issue time can ever satisfy validDuration — step-up becomes permanently unsatisfiable for every user, silently. That directly undercuts the stated design goal in StepUpAutoConfiguration.validateFactors ("fails startup rather than at the first gated request: a typo here would otherwise surface as an operation nobody can ever perform") — the same typo class (ttlSeconds=0) isn't covered by that philosophy.

Fix: add @Validated to StepUpConfigProperties.

2. Stale javadoc in WebAuthnManagementAPI

The class-level "Residual risk (passwordless accounts)" javadoc block (near the top of WebAuthnManagementAPI.java, not touched by this diff) still reads:

"...this library does not yet implement a WebAuthn step-up assertion. Passkey delete/rename on such accounts therefore remain session-only operations."

That's no longer accurate — this PR gates passkey delete/rename via StepUpService when configured. Worth updating to say the residual risk applies only when no StepUpService bean is present (matching the requireCredentialProof javadoc, which was updated correctly).

Minor / non-blocking

  • DSFactorFreshnessStepUpService and StepUpAutoConfiguration.validateFactors both call factorName.toUpperCase() without Locale.ROOT when resolving/validating factor names against FACTOR_AUTHORITIES. This mirrors the exact same pattern already in MfaConfiguration, so it's pre-existing style rather than something new — not asking for a fix here, just flagging in case a future cleanup wants to address it in one place (Turkish-locale JVMs could mis-map a value like authorization_code).
  • The "one ceremony authorizes any credential-altering operation on the session, not just the one that triggered it" trade-off is well-documented in the class javadoc and matches the linked design decision (Built-in WebAuthn step-up primitive (default StepUpService) for credential-altering operations #335), so not raising it as a finding — just confirming it reads as intentional, not overlooked.

Nice work overall — the FactorAuthorityNameValidator addition (catching FACTOR_*-named roles/privileges before they silently defeat MFA/step-up) is a good catch that goes beyond the immediate feature scope.

Step-up denied any user who had not recently proven presence, on the
assumption they could go and do so. That assumption fails for accounts
holding no credential the configured factors accept.

UserService.hasPassword() is a null check on the password column, and
DSOAuth2UserService/DSOidcUserService create social users without ever
setting one, so every OAuth2/OIDC account is "passwordless" here. With
user.security.stepUp.enabled=true such an account could never produce a
FACTOR_WEBAUTHN, and the else-if holding allowInitialPasswordSetWithoutStepUp
was unreachable whenever a StepUpService bean existed, so POST
/user/setPassword returned HTTP 401 permanently with no configuration
that recovered it. "Sign in with Google, then add a password" was broken.

Adds StepUpService.canSatisfyStepUp(User), a default method returning
true so existing consumer implementations are unaffected in both source
and behavior. The built-in service overrides it: WEBAUTHN needs a
registered passkey, PASSWORD needs a password, and factors delivered out
of band cannot be ruled out from here so they stay gated. UserAPI treats
false as "step-up does not apply" and falls back to the configured
default rather than denying.

Placing the check on the SPI rather than at the call site keeps it
correct for consumer implementations, whose mechanism may not involve
passkeys at all and for which a call-site hasCredentials() test would
silently skip a control that did apply.

./gradlew check: green on a forced full rerun.
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Review: WebAuthn step-up (SUF-02)

Solid design — the step-up-is-just-re-running-the-login-ceremony approach (no new endpoint/challenge/token) is elegant, and the security fix that fell out of it (FactorAuthorityNameValidator) is a real find: a FACTOR_*-named role/privilege would otherwise be an authentication-bypass vector once MFA or step-up is enabled. Off-by-default, well-tested (unit + autoconfig + integration), and CONFIG.md/MIGRATION.md are thorough. A few things worth a look before merge.

Correctness: StepUpConfigProperties is missing @validated, so @min(1) on ttlSeconds likely isn't enforced. See src/main/java/.../security/StepUpConfigProperties.java lines 26-27 and 59-60:

@DaTa
@ConfigurationProperties(prefix = "user.security.step-up")
public class StepUpConfigProperties {
...
@min(1)
private int ttlSeconds = 120;

Spring Boot only validates @ConfigurationProperties classes 'whenever they are annotated with @validated' per the reference docs — the JSR-303 annotations are otherwise inert. The two sibling classes in this same package do it correctly: UserSecurityConfigProperties.java:34-35 and PasswordPolicyConfigProperties.java:24-25 both pair @validated with @ConfigurationProperties. StepUpConfigProperties drops @validated while keeping @min(1), so a misconfigured user.security.stepUp.ttlSeconds=0 (or negative) would silently pass startup instead of failing loudly — which undercuts this PR's own stated goal for StepUpAutoConfiguration.validateFactors(): 'Fails startup rather than at the first gated request: a typo here would otherwise surface as an operation nobody can ever perform.' A ttlSeconds=0 has essentially the same failure mode (the TTL window becomes unsatisfiable in practice) but isn't caught. There's also no test exercising an invalid ttlSeconds, which would have caught this. Recommend adding @validated plus a test alongside the existing shouldFailStartupForUnknownFactor/shouldFailStartupForEmptyFactors cases.

Nits (stale Javadoc): A couple of doc comments weren't updated to reflect the new gate and now contradict the code right below them. WebAuthnManagementAPI.java lines 65-70 (class-level 'Residual risk (passwordless accounts)' section) and line 243 ('If the account is passwordless ... this check is a no-op') still describe passkey delete/rename as unconditionally session-only / step-up as unimplemented — but requireCredentialProof a few lines later throws WebAuthnStepUpRequiredException when a StepUpService is configured and unsatisfied. Worth updating to say 'no-op only when no StepUpService is configured.' Also, MfaFilterMergingConfiguration.java line 80 — 'The bean exists only when user.mfa.enabled=true' is now inaccurate; it's also active when step-up alone is enabled (correctly reflected in the class-level Javadoc above it and in the FactorMergingEnabled condition, just not this one line).

Design notes (not blockers, just flagging for visibility): The step-up proof is intentionally session+time bound but not single-use or per-action (documented clearly in DSFactorFreshnessStepUpService's class Javadoc and MIGRATION.md). That's a reasonable, disclosed trade-off given the short default TTL, but worth an explicit sign-off since it means any credential-altering call within the window is authorized, not just the one that triggered re-auth. Separately, WebAuthnManagementAPI.requireCredentialProof doesn't call canSatisfyStepUp() before gating delete/rename (unlike UserAPI.setPassword, which does). In practice this looks harmless — an account with no passkey has no credentials to delete/rename in the first place — but it does mean a stale/edge-case call would surface as 401 step-up-required rather than 404 not found; worth a comment noting this is intentional if it is. Finally, per the PR description, the full re-login flow (session fixation, duplicate InteractiveAuthenticationSuccessEvent, login JSON response mid-flow) is explicitly not yet verified against a real browser/demo app — tracked separately, but it's the part of this change with the most unknowns, so I'd treat that follow-up as load-bearing before calling step-up production-ready rather than optional polish.

Test coverage: Otherwise strong — freshness/TTL/factor-selection/caller-identity/satisfiability all covered in DSFactorFreshnessStepUpServiceTest, autoconfig backoff/failure modes in StepUpAutoConfigurationTest, the merging-condition interaction in MfaFilterMergingConfigurationTest, and the reserved-name validator in FactorAuthorityNameValidatorTest. The one gap is the ttlSeconds validation case noted above.

…config

Follow-up to 7f84430, from review of that commit.

requireCredentialProof did not consult canSatisfyStepUp. The reasoning
was that rename and delete imply the user holds a passkey, which is true
but answers the wrong question: what matters is whether they hold a
credential the *configured* factors accept. validateFactors accepts
factors=[PASSWORD] alone, and a passwordless passkey-holder then needs a
FACTOR_PASSWORD it can never produce, so rename and non-last delete
returned 401 permanently. That is the dead end 7f84430 removed from
setPassword, recreated one API over.

Also:

- canSatisfyStepUp gains the action parameter, matching isStepUpSatisfied,
  so an implementation whose requirements differ per operation can say so.
  Free now; breaking for implementors after release.
- Startup fails when WEBAUTHN is a step-up factor while WebAuthn is
  disabled, and PASSWORD logs a warning, mirroring MfaConfiguration. The
  rejected combination previously started clean and left step-up inert.
- A null user keeps the gate rather than releasing it. The SPI says user
  is never null, and releasing a security gate is the wrong answer to a
  contract violation.
- Locale.ROOT in buildManager and validateFactors. Under a Turkish default
  locale "authorization_code" uppercased to a dotted I and missed the
  factor map, failing startup on valid configuration.

Tests: the auto-configuration lambda is now pinned (present, absent, and
delegating to real credential state), the SPI default is pinned at true
so the compatibility promise cannot be flipped silently, and the locale
normalization is covered under @ResourceLock(LOCALE).

./gradlew check --rerun-tasks: green. ./gradlew javadoc: clean.
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Review: built-in WebAuthn step-up (SUF-02)

Overall this is a well-reasoned feature. The threat model is spelled out explicitly (session-cookie-only attacker vs. session-piggybacking attacker), it's off by default with no behavior change, the canSatisfyStepUp fix for OAuth-only accounts is a genuinely important correction (would otherwise have permanently 401'd/403'd social-login users), and the FACTOR_* authority-name collision fix is a real, independently valuable security find. Test coverage is thorough (auto-config wiring, satisfiability branches, locale edge case, factor merging).

A few things worth a look before merge:

1. StepUpConfigProperties.ttlSeconds's @Min(1) likely isn't enforced
StepUpConfigProperties (src/main/java/.../security/StepUpConfigProperties.java) puts @Min(1) on ttlSeconds but doesn't annotate the class @Validated. Compare with PasswordPolicyConfigProperties and UserSecurityConfigProperties, which pair the same style of constraint with @Validated on the class, and whose docblocks explicitly note the 'only enforced when a Bean Validation implementation is on the classpath' caveat. StepUpConfigProperties has neither the annotation nor that caveat documented.

On top of that, this library ships jakarta.validation-api as compileOnly and has no JSR-303 provider at all outside testImplementation — so even a consuming app with hibernate-validator on its classpath won't get this validated without @Validated on the class, since Spring Boot only runs JSR-303 checks on @ConfigurationProperties beans that carry it.

Net effect: user.security.stepUp.ttlSeconds=0 (or negative) currently binds without error. No test pins what happens downstream (Duration.ofSeconds(0)/negative fed into RequiredFactor...validDuration(...)) — best case it fails closed, but since this directly controls a security-freshness window, it'd be good to either add @Validated (matching the sibling classes) or add an explicit startup check in StepUpAutoConfiguration.validateFactors-style validation, plus a test asserting the failure mode for ttlSeconds <= 0.

2. DSFactorFreshnessStepUpService.setSecurityContextHolderStrategy looks unwired
This setter is defined but nothing in StepUpAutoConfiguration calls it, it's not @Autowired, and no test exercises it — a grep across src/main and src/test turns up only the declaration. If it's meant to pick up a custom SecurityContextHolderStrategy bean (as several core Spring Security classes do), it currently won't; if it's dead, it's worth trimming since it doesn't get exercised by any test.

3. Minor: Locale.ROOT inconsistency in FactorAuthorityNameValidator
findOffendingNames() calls name.toUpperCase() without Locale.ROOT, whereas this same PR fixes a Turkish-locale bug elsewhere (StepUpAutoConfiguration.validateFactors, DSFactorFreshnessStepUpService.buildManager/canSatisfyStepUp) precisely by adding Locale.ROOT. Not currently exploitable — 'FACTOR_' contains no dotted/dotless-i characters affected by the Turkish locale case — but worth aligning for consistency given the rest of the PR was careful about this exact class of bug.

Other notes (no action needed, just confirming reasoning checks out)

  • requireCredentialProof's step-up branch is only reachable from the passwordless path; removePassword always has hasPassword()==true as a precondition, so its remove-password action can never actually hit the step-up gate — that appears intentional (current-password check already covers that case) rather than a bug.
  • DSFactorFreshnessStepUpService.isStepUpSatisfied's authentication.getName() vs user.getEmail() comparison is safe given DSUserDetails.getUsername() returns email — confirmed against DSUserDetails.java:207.
  • The 'any one configured factor, session-scoped, not single-use, not per-action' binding model is a deliberate, well-documented tradeoff (short default TTL mitigates the residual session-piggyback risk) — no concerns there.

Groundwork for gating passkey enrollment on recent authentication. A
freshness gate can only work if every interactive login leaves a factor
to measure; verification found three paths that leave none.

Verified in Spring Security 7.1.0 bytecode: the password provider stamps
FACTOR_PASSWORD and OAuth2LoginAuthenticationProvider stamps
FACTOR_AUTHORIZATION_CODE, but OidcAuthorizationCodeAuthenticationProvider
stamps nothing. An OIDC login (Keycloak, or Google configured with the
openid scope) therefore produces a session carrying no factor at all.

UserService.authWithoutPassword has the same problem for a different
reason: it builds its own Authentication rather than running a provider.
It has three call sites, and only one is dev-only. The email-verification
link and post-registration auto-login are production paths, so without
this every newly registered user's first session would carry no factor
and they could not enroll their first passkey until logging out and back
in.

- AuthWithoutPasswordFactor names what each path actually proves:
  EMAIL_VERIFICATION is a one-time token, REGISTRATION follows a password
  submitted in that same request, DEV_LOGIN proves nothing and stamps
  nothing. The single-argument authWithoutPassword delegates to DEV_LOGIN,
  so existing callers are unchanged.
- LoginFactorStamper adds FACTOR_AUTHORIZATION_CODE only when no factor
  is present at all. The discriminator is deliberately not the token type:
  on both the OAuth2 and OIDC paths this framework's user services return
  a DSUserDetails carrying database roles, so the usual OidcUserAuthority
  versus OAuth2UserAuthority test does not apply here. Checking for the
  factor itself avoids duplicating the one the OAuth2 provider adds after
  the mapper runs.
- LoginSuccessService applies it, rebuilding only OAuth2AuthenticationToken
  and logging anything else rather than guessing how to reconstruct it.
  The context is written back explicitly, since
  AbstractAuthenticationProcessingFilter saves it before the success
  handler runs, matching WebAuthnAuthenticationSuccessHandler.

No gate yet, so nothing changes for users. ./gradlew check --rerun-tasks:
green. ./gradlew javadoc: clean.
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review

I ran a focused review of this PR (step-up authentication support, issue 335) against the repo conventions in CLAUDE.md. Overall the design is solid — building step-up on top of the existing factor-freshness/re-login mechanism rather than a bespoke challenge endpoint keeps the surface area small, and the FACTOR_-prefix authority validator is a good catch of a real authentication-bypass class of bug. A few issues worth addressing before merge:

Potential bugs

  1. Passwordless registration may stamp a FACTOR_PASSWORD authority for an account with no password (UserAPI.java around line 503/645-648)
    handleAutoLogin(user) unconditionally calls userService.authWithoutPassword(user, AuthWithoutPasswordFactor.REGISTRATION) after passwordless registration (PasswordlessRegistrationDto has no password field, and registerPasswordlessAccount() never calls setPassword()). AuthWithoutPasswordFactor.REGISTRATION stamps the PASSWORD factor authority on the assumption that "the password was submitted in the very request that produced this registration" — which is false on this path. If a deployment includes PASSWORD in user.security.stepUp.factors or user.mfa.factors, a freshly-registered, credential-less session would incorrectly satisfy a password-based step-up/MFA check. Worth either branching auto-login behavior for the passwordless registration path, or confirming (and testing) that this is intentionally out of scope.

  2. LoginSuccessService.stampFactorIfMissing() hardcodes new HttpSessionSecurityContextRepository() instead of using the application's configured SecurityContextRepository (line ~46). If a consuming app configures a custom repository (different session key, cookie-based storage, etc.), the stamped write may not be visible to SecurityContextHolderFilter on the next request, silently reverting the session to carrying no factor and defeating step-up/MFA gating without any error. Notably the class's own constructor javadoc already flags this exact class of bug for RequestCache — the same care doesn't appear to have been applied here for SecurityContextRepository.

Minor / style

  1. Import ordering — CLAUDE.md specifies alphabetical imports. AuthWithoutPasswordFactor is out of order in UserAPI.java, DevLoginController.java, and UserActionController.java, and a few imports in LoginSuccessService.java are similarly out of sequence.

  2. FactorAuthorityNameValidator.findOffendingNames() uses locale-default toUpperCase() rather than toUpperCase(Locale.ROOT), unlike the equivalent normalization in StepUpAutoConfiguration and DSFactorFreshnessStepUpService elsewhere in this PR. Under unusual default locales (e.g. Turkish) this could produce inconsistent classification versus the other checks. Minor, but worth aligning for consistency.

Test coverage

No test currently exercises which factor authority gets stamped on the passwordless registration auto-login path (only /user/registration is covered in UserAPIUnitTest), which is how issue 1 above went unnoticed. Given the security sensitivity of factor stamping, a test asserting the exact authority set after passwordless registration + auto-login would be valuable.

Nice work overall — the fail-closed defaults, startup validation of factor names, and the WebAuthnStepUpFactorAssumptionsTest pinning the framework behavior this design relies on are all good practices to see in a security-sensitive feature like this.

Enrolling a passkey was ungated, which meant step-up protected nothing.
An attacker holding only a stolen session cookie on a passwordless
account could register their own authenticator, assert with it to mint a
genuinely fresh FACTOR_WEBAUTHN, and thereby satisfy every gate the
feature adds. Worse, enabling step-up *opened* a path: for a social-login
account, POST /user/setPassword is closed by
allowInitialPasswordSetWithoutStepUp when step-up is off, but reachable
via a self-enrolled passkey when it is on.

POST /webauthn/register now requires an authentication factor issued
within user.security.stepUp.enrollmentTtlSeconds (default 600), applied
only while step-up is enabled. This follows GitHub's sudo mode, which
asks for a password before adding a security key.

Any factor counts, deliberately not the configured stepUp.factors list:
with the default [WEBAUTHN] that would demand a passkey in order to
register a first passkey, which nobody could satisfy. A plain authority
merely named like a factor carries no issue time and can never be fresh,
so it cannot forge recency.

The window is separate from and longer than ttlSeconds because
enrollment follows a login and a decision, whereas a step-up ceremony
immediately precedes its operation.

Wiring is an authorizeHttpRequests rule, which works because
WebAuthnRegistrationFilter is registered after AuthorizationFilter. That
ordering is the assumption the whole gate rests on, so
WebAuthnEnrollmentGateIntegrationTest exercises the real filter chain
rather than the manager alone; disabling the gate fails both of its
rejection cases.

Also corrects the threat-model claim this work started from. The
JavaDoc, MIGRATION.md and the ticket all asserted that an attacker with
a session cookie and no authenticator "can never produce a recent
factor". That was false while enrollment was open, and the sentence had
been copied into three places. It now states what is actually enforced,
including the two residuals that remain: same-session piggybacking, and
a session stolen within the enrollment window of a real login.

./gradlew check --rerun-tasks: green. ./gradlew javadoc: clean.
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review: built-in WebAuthn step-up (#365)

This is a well-reasoned feature with unusually thorough commit messages and good test coverage for the happy paths. I went deep on the freshness/merge mechanics since that's where the security properties live, and found a few issues worth fixing before merge — mostly around the OAuth2/OIDC edges of the "any login is a factor" design.

1. DSFactorFreshnessStepUpService.isStepUpSatisfied() always fails for OAuth2/OIDC principals

DSFactorFreshnessStepUpService.java (around line 109) checks the caller against the target user like this:

```java
if (user == null || user.getEmail() == null || !user.getEmail().equalsIgnoreCase(authentication.getName())) {
```

DSUserDetails implements both UserDetails and OidcUser, with getUsername() returning user.getEmail() but getName() (the OidcUser/AuthenticatedPrincipal method) returning user.getFullName() (DSUserDetails.java:206, :265-267). For form-login/WebAuthn Authentication objects, AbstractAuthenticationToken.getName()'s default logic checks UserDetails first and would resolve to the email — but AbstractOAuth2AuthenticationToken (the token type both OAuth2 and OIDC logins end up as in the SecurityContext) overrides getName() to delegate straight to principal.getName(), bypassing that resolution entirely.

So whenever the current authentication is an OAuth2AuthenticationToken, authentication.getName() returns the user's full name, user.getEmail().equalsIgnoreCase(...) never matches, and isStepUpSatisfied() returns false (logging a misleading "target user does not match the authenticated principal" warning) regardless of how fresh the factor actually is. This makes AUTHORIZATION_CODE step-up (a documented, valid user.security.stepUp.factors value) effectively unusable, and it will bite anyone who authenticates via OAuth2/OIDC and hits a step-up-gated endpoint. DSFactorFreshnessStepUpServiceTest doesn't catch this because it authenticates with a raw email string via TestingAuthenticationToken rather than a real DSUserDetails wrapped in OAuth2AuthenticationToken.

Suggest comparing against ((DSUserDetails) authentication.getPrincipal()).getUsername() or user.getId()/similar instead of getName().

2. OIDC sessions never refresh their step-up factor after the first login

LoginFactorStamper.ensureFactor() only stamps a fresh FACTOR_AUTHORIZATION_CODE when no FactorGrantedAuthority is present at all — it's an "ever stamped" check, not a "recently stamped" check. Per MfaConfiguration's own documented SS7 merge semantics, once mfaEnabled is on (which step-up turns on) and the principal is already authenticated, a second login additively merges the new authorities onto the old session's authorities before LoginSuccessService.onAuthenticationSuccess runs.

OidcAuthorizationCodeAuthenticationProvider stamps no factor of its own (per your own verified comment in LoginFactorStamper.java), so OIDC sessions depend entirely on LoginFactorStamper for a freshness signal. On first login it stamps FACTOR_AUTHORIZATION_CODE(t1) correctly. But on a second OIDC login while already authenticated — i.e. exactly the "re-run the login ceremony to refresh step-up" flow this feature is built on — the merged authorities already contain the stale FACTOR_AUTHORIZATION_CODE(t1) from the session, so ensureFactor's presence check is satisfied and no fresh stamp is added. The timestamp is frozen at the first login forever, for that session. WEBAUTHN/PASSWORD don't have this problem because their providers stamp a new factor on every completed login regardless of merge state.

This isn't covered by LoginSuccessFactorStampingTest (only "no factor" / "one factor" cases) or the integration tests (which don't exercise a double-login merge sequence). Given AUTHORIZATION_CODE is already called out as "not recommended" in the PR description, this may be an acceptable known limitation — but it's worth either documenting explicitly or fixing (e.g. re-stamping when the existing factor's issuedAt predates the current filter invocation).

3. Passwordless registration stamps a FACTOR_PASSWORD the account can never back up

UserAPI.handleAutoLogin() (line ~645) unconditionally does:

```java
// The password was submitted in the very request that produced this registration.
userService.authWithoutPassword(user, AuthWithoutPasswordFactor.REGISTRATION);
```

REGISTRATION maps to FactorGrantedAuthority.PASSWORD_AUTHORITY. But handleAutoLogin is shared by both registerUserAccount (line 157, password-based) and registerPasswordlessAccount (line 503) — and PasswordlessRegistrationDto has no password field at all. So a freshly-registered passkey-only account gets a genuine, fresh FACTOR_PASSWORD stamped on its session despite never having submitted a password.

canSatisfyStepUp() was specifically built to prevent an account from satisfying a factor it can't produce, but it isn't consulted here — the stamp happens regardless, and isStepUpSatisfied() doesn't call canSatisfyStepUp() either, it just checks whether a matching FactorGrantedAuthority is present and fresh. If an operator configures user.security.stepUp.factors to include PASSWORD (a documented, valid option), a brand-new passwordless account would satisfy PASSWORD step-up immediately after registration. Narrow (default factors is [WEBAUTHN] only), but worth a comment-guarded fix — the shared handleAutoLogin needs to know whether a password was actually set, e.g. by checking userService.hasPassword(user) before choosing the factor, or by giving the passwordless call site its own auto-login path.

4. Enrollment gate and factor merging are keyed off stepUp.enabled, not StepUpService bean presence — contradicts CONFIG.md

CONFIG.md states: "A StepUpService bean supplied by your application takes precedence over the built-in one, whatever enabled is set to." That's true for isStepUpSatisfied() calls (gated via @ConditionalOnMissingBean), but:

  • WebSecurityConfig.java:192 gates the /webauthn/register enrollment freshness check behind if (stepUpConfigProperties.isEnabled())
  • MfaFilterMergingConfiguration's FactorMergingEnabled condition is likewise keyed off user.security.step-up.enabled / user.mfa.enabled, not bean presence

So a consumer who supplies their own StepUpService (the "recommended" approach per CONFIG.md/MIGRATION.md) but leaves stepUp.enabled=false gets isStepUpSatisfied() enforcement on setPassword/delete/rename, but no enrollment gating on passkey registration and no factor merging. Given commit cd79559's own stated rationale for the enrollment gate (an attacker with a stolen session can otherwise self-enroll a passkey and mint a fresh factor to satisfy any custom step-up check that trusts FACTOR_WEBAUTHN freshness), this is a real gap for exactly the documented "bring your own StepUpService" configuration. Consider tying these two gates to bean presence (or "either enabled=true or a custom StepUpService bean exists") rather than only the property, or updating the docs to say the enrollment gate and merging require enabled=true regardless of custom implementations.

5. Minor: import ordering

CLAUDE.md specifies "Imports: Alphabetical, no wildcards." The new com.digitalsanctuary.spring.user.service.AuthWithoutPasswordFactor import is spliced into the middle of an already-alphabetized security.* block in:

  • UserAPI.java (between security.StepUpService and security.UserSecurityConfigProperties)
  • UserActionController.java
  • DevLoginController.java

and WebSecurityConfig.java inserts org.springframework.security.web.access.intercept.RequestAuthorizationContext before org.springframework.http.HttpMethod. Easy fix, just flagging for consistency with the rest of the codebase.


What's good here

  • The threat-model writing in the commit messages and Javadoc is excellent — explicit about what is and isn't closed (session-sharing piggyback, enrollment-window theft), and the follow-up commits fixing the passwordless-account dead-end and the enrollment gate show real adversarial self-review.
  • FactorAuthorityNameValidator closing the FACTOR_* authority-name collision is a genuine, independently valuable security fix that fell out of this work.
  • Locale.ROOT fix for the Turkish-locale factor-name-uppercasing bug is a nice catch.
  • Off-by-default, @ConditionalOnMissingBean for the SPI, and no behavior change for existing consumers — good rollout discipline for a security-sensitive change.
  • Test coverage is strong for the mechanisms that were directly unit-tested (FreshFactorAuthorizationManagerTest, StepUpAutoConfigurationTest, FactorAuthorityNameValidatorTest); the gaps above are all in cross-cutting interactions (SS7 merge semantics, OAuth2 token getName() resolution) that are inherently hard to hit without a full OAuth2AuthenticationToken/double-login integration test.

Given #1 and #2 both center on the OAuth2/OIDC path and #3 is registration-specific, none of these affect the default configuration (stepUp.enabled=false, factors=[WEBAUTHN]) — but they're worth addressing before recommending AUTHORIZATION_CODE or PASSWORD as step-up factors in the docs, since right now both are subtly broken.

A3 - passkey delete/rename keyed off StepUpService bean presence, so an
application that adopted the SPI in 5.3.1 for setPassword would have had
two more operations gated on upgrade, with two action values its
implementation never expected. The opt-in is now the property.

B3 - @min(1) on ttlSeconds was inert because StepUpConfigProperties was
not @validated, unlike its siblings. ttlSeconds=0 bound cleanly and made
every gated operation deny forever with a debug log as the only signal.
Removing @validated again fails the two new tests, so the annotation is
what enforces it. enrollmentTtlSeconds is covered too.

E3 - FactorAuthorityNameValidator read configuration only, but
AuthorityService grants from the role and privilege tables and
RolePrivilegeSetupService never deletes. A FACTOR_-prefixed row created
under an earlier configuration therefore survived its removal from YAML,
kept being granted, and passed the check clean, leaving the MFA bypass
the class exists to prevent. It now queries both tables as well, and the
error message says to delete the rows rather than only rename the config.

The check also moved from @PostConstruct to ContextRefreshedEvent:
nothing injects this bean, so under spring.main.lazy-initialization=true
it was never constructed and the check silently never ran. The event also
orders it after RolePrivilegeSetupService. Matches the pattern that
service and MfaConfiguration already use.

F1 - the 401 and its step-up-required code had no test, though the
distinct code is the entire reason the exception type exists. A second
test pins that it does not fall through to the base WebAuthnException
handler, which would return 400 with a null error field.

Also corrects the property path in the validator's message and in
MIGRATION.md: the real key is user.roles.roles-and-privileges, so an
operator hit by the failure would have grepped for a name that does not
exist.

./gradlew check --rerun-tasks: green. ./gradlew javadoc: clean.
@claude

claude Bot commented Aug 21, 2026

Copy link
Copy Markdown

Review

This is a well-reasoned, thoroughly-tested implementation of step-up auth (30 new tests, extensive commit-message documentation of the threat model). One finding below looks like a genuine bug that defeats the feature for OAuth2/OIDC users; the rest are minor.

🔴 Bug: AUTHORIZATION_CODE step-up is permanently broken for OAuth2/OIDC logins

DSFactorFreshnessStepUpService.isStepUpSatisfied():

if (user == null || user.getEmail() == null || !user.getEmail().equalsIgnoreCase(authentication.getName())) {

DSUserDetails implements OidcUser, and its getName() override returns user.getFullName(), not the email (DSUserDetails.java:265-267). OAuth2AuthenticationToken.getName() (via AbstractOAuth2AuthenticationToken) delegates straight to principal.getName() — it does not go through the UserDetails-aware branch that AbstractAuthenticationToken.getName() uses for form/WebAuthn logins. So for any OAuth2/OIDC session, authentication.getName() returns the user's full name, and comparing it against user.getEmail() is always false (unless full name happens to equal email).

Since AUTHORIZATION_CODE is a documented, configurable step-up factor (and this same PR's LoginSuccessService.stampFactorIfMissing() exists specifically to make OIDC logins produce that factor), this means step-up can never be satisfied via AUTHORIZATION_CODE — it silently fails closed for every social-login user, permanently, immediately after a fresh correct login.

The new tests (DSFactorFreshnessStepUpServiceTest) don't catch this because they authenticate with TestingAuthenticationToken(email, ...), whose principal is a bare String, so getName() trivially returns the email — sidestepping the real OAuth2AuthenticationToken.getName() override entirely. Worth adding a test that authenticates with an actual OAuth2AuthenticationToken/DSUserDetails principal.

🟡 LoginSuccessService.stampFactorIfMissing bypasses the configured SecurityContextRepository

private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();

This is hardcoded rather than injected/autowired. If a consuming app configures a non-default SecurityContextRepository (custom session attribute name, non-session-based store, etc. via HttpSecurity.securityContext(...)), the freshly-stamped FACTOR_AUTHORIZATION_CODE gets written to a repository the rest of the filter chain doesn't read from. It'd be visible for the current request only — the next request would see the session's real (unstamped) Authentication, and step-up/enrollment freshness checks would silently see no factor at all. Consider injecting the app's actual SecurityContextRepository bean (with a sane default) instead of a private hardcoded instance.

🟢 Minor: import ordering (CLAUDE.md: "Imports: Alphabetical, no wildcards")

AuthWithoutPasswordFactor (package service) is inserted before security.UserSecurityConfigProperties in both:

  • UserAPI.java (line ~37)
  • UserActionController.java (line ~14)

Alphabetically, security.* sorts before service.*, so these are out of order.

🟢 Minor: duplicated hasPassword logic

DSFactorFreshnessStepUpService.canSatisfyStepUp()'s PASSWORD branch re-implements the null/empty check that UserService.hasPassword() already encapsulates:

case "PASSWORD" -> {
    if (user.getPassword() != null && !user.getPassword().isEmpty()) {

vs. UserService.hasPassword():

return user.getPassword() != null && !user.getPassword().isEmpty();

Low risk today, but if hasPassword()'s definition of "has a password" ever changes, this copy would silently drift out of sync with the rest of the framework's password-presence checks (UserAPI.setPassword, WebAuthnManagementAPI.requireCredentialProof). Worth injecting UserService (or exposing the predicate) here instead.


Everything else — the FactorAuthorityNameValidator authority-shadowing fix, canSatisfyStepUp's fail-open-to-default behavior for OAuth-only accounts, the Locale.ROOT fix for factor-name normalization, moving the validator check to ContextRefreshedEvent for lazy-init compatibility, and the @Validated fix for ttlSeconds — all look correct and are well covered by the new tests. The default-off posture and @ConditionalOnMissingBean preserving consumer SPI implementations are good calls for a library.

Review generated with Claude Code.

devondragon added a commit that referenced this pull request Aug 21, 2026
Enrolling a passkey grants a durable new way into an account, and it
outlives a password change, because session invalidation ends sessions
rather than credentials. An attacker who reaches an authenticated
session can therefore leave themselves a way back in that survives the
victim's response. Until now that enrollment produced no audit record
and no notification: Spring Security owns POST /webauthn/register, and
nothing in this framework observed it.

The framework does own the JPA UserCredentialRepository every enrollment
writes through, so the event is published from there and catches any
path that registers a credential.

The trap, and why the discriminator matters: Spring Security also calls
UserCredentialRepository.save() inside authenticate(), to persist the
updated signature count. Publishing on every save would email the user
on each passkey login. save() already resolves the row with
findById(...).orElseGet(new), so an absent row identifies a genuine
registration with no extra query. A test pins this by asserting no event
on the update path; sabotaging the guard fails it.

- WebAuthnCredentialRegisteredEvent carrying the user, credential id, and label
- WebAuthnCredentialRegistrationListener publishing a PasskeyRegistration
  audit event, then emailing the owner
- user.webauthn.notifyOnRegistration (default true) gates the email only;
  the audit event is unconditional, and a mail failure cannot lose it
- New mail template and message keys naming the specific risk: a passkey
  outlives a password change, so both steps are needed to recover

This is detective, not preventive. Preventing enrollment from a
session-only actor is step-up (#335 / #365), which is off by default and
does not yet gate enrollment. This applies whether or not step-up is on.

./gradlew check: green. ./gradlew javadoc: clean.
@devondragon
devondragon merged commit 3b06ae3 into main Aug 21, 2026
4 checks passed
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.

2 participants