From c22580e1243be2e68eba97bc5660e781a8db92b2 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 17:01:58 +0200 Subject: [PATCH] feat: always enable WebAuthn in MFA factors and add passkey registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync vendor auth policy to Logto sign-in experience on save and on startup. Always include WebAuthn + TOTP + BackupCode in MFA factors when MFA is enabled — no reason to gate passkeys behind a toggle. - Enable Logto Account Center on startup for user-facing MFA management. - Add passkey registration to account settings via Logto Account API. Frontend calls Logto directly (same domain) for the WebAuthn ceremony: generate options, browser credential creation, verify, and bind. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../saas/config/LogtoStartupConfig.java | 61 +++++++++++++++++++ .../saas/identity/LogtoManagementClient.java | 22 +++++++ .../vendor/VendorAuthPolicyController.java | 36 ++++++++++- ui/src/api/logto-account-api.ts | 61 +++++++++++++++++++ ui/src/components/account/PasskeySection.tsx | 32 +++++++++- 5 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java create mode 100644 ui/src/api/logto-account-api.ts diff --git a/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java new file mode 100644 index 0000000..68e7f8d --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java @@ -0,0 +1,61 @@ +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: enables the Account Center + * and ensures MFA factors (including WebAuthn) match the vendor policy. + */ +@Component +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) { + this.logtoClient = logtoClient; + this.authPolicyRepository = authPolicyRepository; + } + + @EventListener(ApplicationReadyEvent.class) + public void onStartup() { + logtoClient.enableAccountCenter(); + syncMfaFactors(); + } + + private void syncMfaFactors() { + try { + var policy = authPolicyRepository.getPolicy(); + String mfaMode = policy.getMfaMode(); + boolean mfaEnabled = !"off".equals(mfaMode); + + if (!mfaEnabled) { + logtoClient.updateSignInExperience(Map.of( + "mfa", Map.of("factors", List.of(), "policy", "UserControlled"))); + return; + } + + List factors = new ArrayList<>(List.of("Totp", "WebAuthn", "BackupCode")); + String logtoPolicy = "required".equals(mfaMode) ? "Mandatory" : "UserControlled"; + + logtoClient.updateSignInExperience(Map.of( + "mfa", Map.of("factors", factors, "policy", logtoPolicy))); + log.info("Synced MFA factors to Logto: {} (policy={})", factors, logtoPolicy); + } catch (Exception e) { + log.warn("Failed to sync MFA factors on startup: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index f870d80..923785a 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -653,6 +653,28 @@ public class LogtoManagementClient { } } + /** Enable the Account Center API for MFA management (WebAuthn passkey registration). */ + public void enableAccountCenter() { + if (!isAvailable()) return; + try { + restClient.patch() + .uri(config.getLogtoEndpoint() + "/api/account-center") + .header("Authorization", "Bearer " + getAccessToken()) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of( + "enabled", true, + "fields", Map.of( + "mfa", Map.of("edit", "ReadonlyProfile") + ) + )) + .retrieve() + .toBodilessEntity(); + log.info("Account Center enabled for MFA management"); + } catch (Exception e) { + log.warn("Failed to enable Account Center: {}", e.getMessage()); + } + } + /** Update user custom data (partial merge). Used for mfa_method_preference. */ public void updateUserCustomData(String userId, Map customData) { if (!isAvailable()) return; 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 fa03ee3..965c358 100644 --- a/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java +++ b/src/main/java/net/siegeln/cameleer/saas/vendor/VendorAuthPolicyController.java @@ -1,9 +1,15 @@ 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 @@ -11,13 +17,17 @@ 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) { + public VendorAuthPolicyController(VendorAuthPolicyRepository repository, + LogtoManagementClient logtoClient) { this.repository = repository; + this.logtoClient = logtoClient; } public record AuthPolicyResponse(String mfaMode, boolean passkeyEnabled, String passkeyMode) { @@ -54,6 +64,30 @@ 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); + + if (!mfaEnabled) { + logtoClient.updateSignInExperience(Map.of( + "mfa", Map.of("factors", List.of(), "policy", "UserControlled"))); + return; + } + + List factors = new ArrayList<>(List.of("Totp", "WebAuthn", "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/src/api/logto-account-api.ts b/ui/src/api/logto-account-api.ts new file mode 100644 index 0000000..bcf5759 --- /dev/null +++ b/ui/src/api/logto-account-api.ts @@ -0,0 +1,61 @@ +import { startRegistration } from '@simplewebauthn/browser'; + +/** + * Logto Account API client for WebAuthn passkey registration. + * Calls Logto's Account API endpoints directly (same domain). + */ + +async function accountApi( + method: string, + path: string, + token: string, + body?: unknown, +): Promise { + return fetch(`/api${path}`, { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +export async function registerPasskey(getAccountToken: () => Promise): Promise { + const token = await getAccountToken(); + + // Step 1: Get registration options from Logto Account API + const optionsRes = await accountApi('POST', '/verifications/web-authn/registration', token); + if (!optionsRes.ok) { + const err = await optionsRes.json().catch(() => ({})); + throw new Error(err.message || `Failed to start passkey registration (${optionsRes.status})`); + } + const { registrationOptions, verificationRecordId } = await optionsRes.json(); + + // Step 2: Browser WebAuthn ceremony + const credential = await startRegistration({ optionsJSON: registrationOptions }); + + // Step 3: Verify the registration with Logto + const verifyRes = await accountApi( + 'POST', + '/verifications/web-authn/registration/verify', + token, + { verificationRecordId, payload: credential }, + ); + if (!verifyRes.ok) { + const err = await verifyRes.json().catch(() => ({})); + throw new Error(err.message || `Passkey verification failed (${verifyRes.status})`); + } + const verifyData = await verifyRes.json(); + const verifiedRecordId = verifyData.verificationRecordId; + + // Step 4: Bind the passkey to the user's account + const bindRes = await accountApi('POST', '/my-account/mfa-verifications', token, { + type: 'WebAuthn', + newIdentifierVerificationRecordId: verifiedRecordId, + }); + if (!bindRes.ok) { + const err = await bindRes.json().catch(() => ({})); + throw new Error(err.message || `Failed to bind passkey (${bindRes.status})`); + } +} diff --git a/ui/src/components/account/PasskeySection.tsx b/ui/src/components/account/PasskeySection.tsx index 2c16441..838d715 100644 --- a/ui/src/components/account/PasskeySection.tsx +++ b/ui/src/components/account/PasskeySection.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useLogto } from '@logto/react'; import { errorMessage } from '../../api/client'; import { Alert, @@ -13,6 +14,7 @@ import { useAccountRenamePasskey, useAccountDeletePasskey, } from '../../api/account-hooks'; +import { registerPasskey } from '../../api/logto-account-api'; import styles from '../../styles/platform.module.css'; export function PasskeyNudgeBanner() { @@ -41,12 +43,14 @@ export function PasskeyNudgeBanner() { export function PasskeySection() { const { toast } = useToast(); - const { data: passkeys, isLoading } = useAccountPasskeyList(); + const { getAccessToken } = useLogto(); + const { data: passkeys, isLoading, refetch } = useAccountPasskeyList(); const renamePasskey = useAccountRenamePasskey(); const deletePasskey = useAccountDeletePasskey(); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(''); const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [registering, setRegistering] = useState(false); function parseAgent(agent: string | null): string { if (!agent) return 'Unknown device'; @@ -72,6 +76,25 @@ export function PasskeySection() { } } + async function handleRegister() { + setRegistering(true); + try { + await registerPasskey(async () => { + const token = await getAccessToken(); + if (!token) throw new Error('Not authenticated'); + return token; + }); + await refetch(); + toast({ title: 'Passkey registered', variant: 'success' }); + } catch (err) { + // User cancelled the WebAuthn prompt — not an error + if (err instanceof Error && err.name === 'NotAllowedError') return; + toast({ title: 'Passkey registration failed', description: errorMessage(err), variant: 'error' }); + } finally { + setRegistering(false); + } + } + async function handleDelete(id: string) { try { await deletePasskey.mutateAsync(id); @@ -90,9 +113,14 @@ export function PasskeySection() {

Use your fingerprint, face, or security key to sign in faster.

+
+ +
{credentials.length === 0 ? (

- No passkeys registered. Passkeys can be registered during sign-in when prompted. + No passkeys registered yet.

) : (