From cba420fbeb45dabb0c61bd5fced4f5bec999f797 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:59:21 +0200 Subject: [PATCH] fix: always offer MFA+passkey enrollment, separate availability from enforcement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fundamental fixes: - user.missing_mfa now triggers MfaEnrollmentError (enroll UI) instead of MfaRequiredError (verify UI). Users without MFA were shown a TOTP code prompt they couldn't fill. - Logto MFA factors always set to [Totp, WebAuthn, BackupCode] with UserControlled policy on startup. Availability is always-on for all users. The vendor auth policy controls enforcement (via MfaEnforcementFilter), not what Logto offers during sign-in. - Removed syncMfaConfigToLogto from VendorAuthPolicyController — vendor policy changes no longer modify Logto's sign-in experience. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../saas/config/LogtoStartupConfig.java | 37 ++++-------------- .../vendor/VendorAuthPolicyController.java | 39 +------------------ ui/sign-in/src/experience-api.ts | 6 +-- 3 files changed, 11 insertions(+), 71 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java index 88a04b3..f81f738 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java @@ -1,20 +1,19 @@ package net.siegeln.cameleer.saas.config; import net.siegeln.cameleer.saas.identity.LogtoManagementClient; -import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; -import java.util.ArrayList; import java.util.List; import java.util.Map; /** - * Syncs Logto configuration on startup: ensures MFA factors - * (including WebAuthn) match the vendor policy. + * Ensures Logto sign-in experience always offers TOTP + WebAuthn + BackupCode + * on startup. Availability is always-on; enforcement is handled separately by + * MfaEnforcementFilter based on the vendor auth policy. */ @Component public class LogtoStartupConfig { @@ -22,40 +21,18 @@ public class LogtoStartupConfig { private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class); private final LogtoManagementClient logtoClient; - private final VendorAuthPolicyRepository authPolicyRepository; - public LogtoStartupConfig(LogtoManagementClient logtoClient, - VendorAuthPolicyRepository authPolicyRepository) { + public LogtoStartupConfig(LogtoManagementClient logtoClient) { this.logtoClient = logtoClient; - this.authPolicyRepository = authPolicyRepository; } @EventListener(ApplicationReadyEvent.class) public void onStartup() { - syncMfaFactors(); - } - - private void syncMfaFactors() { try { - var policy = authPolicyRepository.getPolicy(); - String mfaMode = policy.getMfaMode(); - boolean mfaEnabled = !"off".equals(mfaMode); - boolean passkeyEnabled = policy.isPasskeyEnabled(); - - List factors = new ArrayList<>(); - if (mfaEnabled) { - factors.add("Totp"); - } - if (mfaEnabled || passkeyEnabled) { - factors.add("WebAuthn"); - factors.add("BackupCode"); - } - - String logtoPolicy = "required".equals(mfaMode) ? "Mandatory" : "UserControlled"; - + List factors = List.of("Totp", "WebAuthn", "BackupCode"); logtoClient.updateSignInExperience(Map.of( - "mfa", Map.of("factors", factors, "policy", logtoPolicy))); - log.info("Synced MFA factors to Logto: {} (policy={})", factors, logtoPolicy); + "mfa", Map.of("factors", factors, "policy", "UserControlled"))); + log.info("Logto MFA factors set to {} (UserControlled)", factors); } catch (Exception e) { log.warn("Failed to sync MFA factors on startup: {}", e.getMessage()); } diff --git a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java index 751c3dd..fa03ee3 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java @@ -1,15 +1,9 @@ package net.siegeln.cameleer.saas.vendor; -import net.siegeln.cameleer.saas.identity.LogtoManagementClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; import java.util.Set; @RestController @@ -17,17 +11,13 @@ import java.util.Set; @PreAuthorize("hasAuthority('SCOPE_platform:admin')") public class VendorAuthPolicyController { - private static final Logger log = LoggerFactory.getLogger(VendorAuthPolicyController.class); private static final Set VALID_MFA_MODES = Set.of("off", "optional", "required"); private static final Set VALID_PASSKEY_MODES = Set.of("optional", "preferred", "required"); private final VendorAuthPolicyRepository repository; - private final LogtoManagementClient logtoClient; - public VendorAuthPolicyController(VendorAuthPolicyRepository repository, - LogtoManagementClient logtoClient) { + public VendorAuthPolicyController(VendorAuthPolicyRepository repository) { this.repository = repository; - this.logtoClient = logtoClient; } public record AuthPolicyResponse(String mfaMode, boolean passkeyEnabled, String passkeyMode) { @@ -64,33 +54,6 @@ public class VendorAuthPolicyController { } repository.save(policy); - syncMfaConfigToLogto(policy); return ResponseEntity.ok(AuthPolicyResponse.from(policy)); } - - /** Sync the vendor auth policy MFA settings to the Logto sign-in experience. */ - private void syncMfaConfigToLogto(VendorAuthPolicyEntity policy) { - try { - String mfaMode = policy.getMfaMode(); - boolean mfaEnabled = !"off".equals(mfaMode); - boolean passkeyEnabled = policy.isPasskeyEnabled(); - - List factors = new ArrayList<>(); - if (mfaEnabled) { - factors.add("Totp"); - } - // Passkeys are always available when enabled, regardless of MFA mode - if (mfaEnabled || passkeyEnabled) { - factors.add("WebAuthn"); - factors.add("BackupCode"); - } - - String logtoPolicy = "required".equals(mfaMode) ? "Mandatory" : "UserControlled"; - - logtoClient.updateSignInExperience(Map.of( - "mfa", Map.of("factors", factors, "policy", logtoPolicy))); - } catch (Exception e) { - log.warn("Failed to sync MFA config to Logto: {}", e.getMessage()); - } - } } diff --git a/ui/sign-in/src/experience-api.ts b/ui/sign-in/src/experience-api.ts index df1ca44..4045bd6 100644 --- a/ui/sign-in/src/experience-api.ts +++ b/ui/sign-in/src/experience-api.ts @@ -99,13 +99,13 @@ export async function signIn(identifier: string, password: string): Promise