feat: always enable WebAuthn in MFA factors and add passkey registration
All checks were successful
CI / build (push) Successful in 2m3s
CI / docker (push) Successful in 1m26s

- 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) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 17:01:58 +02:00
parent a5c20830a7
commit c22580e124
5 changed files with 209 additions and 3 deletions

View File

@@ -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<String> 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());
}
}
}

View File

@@ -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<String, Object> customData) {
if (!isAvailable()) return;

View File

@@ -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<String> VALID_MFA_MODES = Set.of("off", "optional", "required");
private static final Set<String> 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<String> 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());
}
}
}

View File

@@ -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<Response> {
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<string>): Promise<void> {
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})`);
}
}

View File

@@ -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<string | null>(null);
const [editName, setEditName] = useState('');
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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() {
<p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster.
</p>
<div style={{ marginBottom: 12 }}>
<Button variant="primary" onClick={handleRegister} loading={registering}>
Register passkey
</Button>
</div>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered. Passkeys can be registered during sign-in when prompted.
No passkeys registered yet.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>