refactor: move passkey enrollment to sign-in UI via Experience API
All checks were successful
CI / build (push) Successful in 2m12s
CI / docker (push) Successful in 1m49s

Remove the SaaS backend proxy approach for passkey registration (Account
API binding, Management API proxy, password modal in PasskeySection).
Instead, offer passkey enrollment natively during sign-in via Logto's
Experience API — the correct architectural layer.

Sign-in flow: when Logto returns MFA enrollment available (422), show a
"Secure your account" screen with Register passkey / Set up later. Uses
Experience API WebAuthn registration endpoints. Works for all users
(SaaS and future server users) since the sign-in UI is shared.

PasskeySection in account settings now only manages existing passkeys
(list/rename/delete) and directs users to register during sign-in.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 18:33:46 +02:00
parent 4df6fc9e03
commit 18e6f32f90
8 changed files with 120 additions and 231 deletions

View File

@@ -103,17 +103,6 @@ public class AccountController {
return ResponseEntity.noContent().build();
}
// --- WebAuthn bind (proxied via Management API since Account API /my-account/ returns 401) ---
record WebAuthnBindRequest(String verificationRecordId, String identityVerificationId) {}
@PostMapping("/mfa/webauthn/bind")
public ResponseEntity<Void> bindWebAuthn(@AuthenticationPrincipal Jwt jwt,
@RequestBody WebAuthnBindRequest request) {
accountService.bindWebAuthnPasskey(jwt.getSubject(), request.verificationRecordId(), request.identityVerificationId());
return ResponseEntity.noContent().build();
}
// --- MFA Preference ---
@PostMapping("/mfa/method-preference")

View File

@@ -150,10 +150,6 @@ public class AccountService {
}
}
public void bindWebAuthnPasskey(String userId, String verificationRecordId, String identityVerificationId) {
logtoClient.bindWebAuthnMfa(userId, verificationRecordId, identityVerificationId);
}
// --- Passkeys ---
public List<PasskeyCredential> listPasskeys(String userId) {

View File

@@ -13,8 +13,8 @@ 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.
* Syncs Logto configuration on startup: ensures MFA factors
* (including WebAuthn) match the vendor policy.
*/
@Component
public class LogtoStartupConfig {
@@ -32,7 +32,6 @@ public class LogtoStartupConfig {
@EventListener(ApplicationReadyEvent.class)
public void onStartup() {
logtoClient.enableAccountCenter();
syncMfaFactors();
}

View File

@@ -653,40 +653,6 @@ public class LogtoManagementClient {
}
}
/** Bind a WebAuthn passkey to a user via the Management API. */
public void bindWebAuthnMfa(String userId, String verificationRecordId, String identityVerificationId) {
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
restClient.post()
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
.header("Authorization", "Bearer " + getAccessToken())
.contentType(MediaType.APPLICATION_JSON)
.body(Map.of("type", "WebAuthn", "verificationRecordId", verificationRecordId))
.retrieve()
.toBodilessEntity();
}
/** 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", "Edit"
)
))
.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

@@ -2,17 +2,19 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
import { startAuthentication } from '@simplewebauthn/browser';
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
import {
signIn, startRegistration, completeRegistration,
startForgotPassword, forgotPasswordVerifyAndReset,
verifyTotp, verifyBackupCode, submitMfa,
startWebAuthnAuth, verifyWebAuthnAuth,
MfaRequiredError,
startWebAuthnRegistration, verifyWebAuthnRegistration, bindMfaProfile,
skipMfaEnrollment, submitInteraction,
MfaRequiredError, MfaEnrollmentError,
} from './experience-api';
import styles from './SignInPage.module.css';
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker';
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode' | 'mfaWebauthn' | 'mfaMethodPicker' | 'mfaEnroll';
const SIGN_IN_SUBTITLES = [
"Prove you're not a mirage",
@@ -137,6 +139,11 @@ export function SignInPage() {
setLoading(false);
return;
}
if (err instanceof MfaEnrollmentError) {
setMode('mfaEnroll');
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Sign-in failed');
setLoading(false);
}
@@ -179,6 +186,11 @@ export function SignInPage() {
const redirectTo = await completeRegistration(identifier, password, verificationId, code);
window.location.replace(redirectTo);
} catch (err) {
if (err instanceof MfaEnrollmentError) {
setMode('mfaEnroll');
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
@@ -293,6 +305,38 @@ export function SignInPage() {
}
};
// --- MFA enrollment: passkey registration ---
async function handleEnrollPasskey() {
setError(null);
setLoading(true);
try {
const { verificationId, registrationOptions } = await startWebAuthnRegistration();
const credential = await startWebAuthnReg({ optionsJSON: registrationOptions as any });
const verifiedId = await verifyWebAuthnRegistration(verificationId, credential as unknown as Record<string, unknown>);
await bindMfaProfile('WebAuthn', verifiedId);
const result = await submitInteraction();
window.location.replace(result);
} catch (err) {
if (err instanceof Error && err.name === 'NotAllowedError') {
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Passkey registration failed');
setLoading(false);
}
}
async function handleSkipEnrollment() {
setLoading(true);
try {
const redirectTo = await skipMfaEnrollment();
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to continue');
setLoading(false);
}
}
const passwordToggle = (
<button
type="button"
@@ -713,6 +757,27 @@ export function SignInPage() {
</div>
</div>
)}
{/* --- MFA enrollment: offer passkey registration --- */}
{mode === 'mfaEnroll' && (
<div className={styles.fields}>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>Secure your account</h2>
<p style={{ color: 'var(--text-muted)', margin: 0, fontSize: '0.875rem' }}>
Add a passkey to sign in faster with your fingerprint, face, or security key.
</p>
</div>
{error && <Alert variant="error">{error}</Alert>}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button variant="primary" onClick={handleEnrollPasskey} loading={loading}>
Register passkey
</Button>
<Button variant="secondary" onClick={handleSkipEnrollment} disabled={loading}>
Set up later
</Button>
</div>
</div>
)}
</div>
</Card>
</div>

View File

@@ -71,6 +71,13 @@ export class MfaRequiredError extends Error {
}
}
export class MfaEnrollmentError extends Error {
constructor() {
super('MFA enrollment available');
this.name = 'MfaEnrollmentError';
}
}
async function trySubmit(): Promise<{ ok: true; redirectTo: string } | { ok: false; status: number; code: string; message: string }> {
const res = await request('POST', '/submit');
if (res.ok) {
@@ -97,13 +104,9 @@ export async function signIn(identifier: string, password: string): Promise<stri
throw new MfaRequiredError();
}
// MFA not enrolled, UserControlled policy — skip the binding prompt.
// Also fallback: any 422 with an MFA-related code we don't recognize — try skip before failing.
// MFA not enrolled — offer enrollment (passkey / TOTP)
if (result.status === 422 && result.code.includes('mfa')) {
await skipMfaBinding();
const retry = await trySubmit();
if (retry.ok) return retry.redirectTo;
throw new Error(retry.message);
throw new MfaEnrollmentError();
}
throw new Error(result.message);
@@ -184,12 +187,9 @@ export async function completeRegistration(
const result = await trySubmit();
if (result.ok) return result.redirectTo;
// MFA not enrolled, UserControlled policy — skip the binding prompt
// MFA not enrolled — offer enrollment (passkey / TOTP)
if (result.status === 422 && result.code.includes('mfa')) {
await skipMfaBinding();
const retry = await trySubmit();
if (retry.ok) return retry.redirectTo;
throw new Error(retry.message);
throw new MfaEnrollmentError();
}
throw new Error(result.message);
@@ -299,3 +299,40 @@ export async function verifyWebAuthnAuth(payload: Record<string, unknown>): Prom
const data = await res.json();
return data.verificationId;
}
// --- MFA Enrollment (during sign-in) ---
export async function startWebAuthnRegistration(): Promise<{ verificationId: string; registrationOptions: Record<string, unknown> }> {
const res = await request('POST', '/verification/web-authn/registration');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to start passkey registration (${res.status})`);
}
return res.json();
}
export async function verifyWebAuthnRegistration(verificationId: string, payload: Record<string, unknown>): Promise<string> {
const body = { ...payload, type: 'WebAuthn' };
const res = await request('POST', '/verification/web-authn/registration/verify', { verificationId, payload: body });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Passkey registration verification failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function bindMfaProfile(type: string, verificationId: string): Promise<void> {
const res = await request('POST', '/profile/mfa', { type, verificationId });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to bind MFA (${res.status})`);
}
}
export async function skipMfaEnrollment(): Promise<string> {
await skipMfaBinding();
const result = await trySubmit();
if (result.ok) return result.redirectTo;
throw new Error(result.message);
}

View File

@@ -1,83 +0,0 @@
import { startRegistration } from '@simplewebauthn/browser';
import { api } from './client';
/**
* Logto Account API client for WebAuthn passkey registration.
* Calls Logto's Account API endpoints directly (same domain).
* The bind step goes through the SaaS backend (Management API).
*/
async function accountApi(
method: string,
path: string,
token: string,
body?: unknown,
extraHeaders?: Record<string, string>,
): Promise<Response> {
return fetch(`/api${path}`, {
method,
headers: {
Authorization: `Bearer ${token}`,
...(body ? { 'Content-Type': 'application/json' } : {}),
...extraHeaders,
},
body: body ? JSON.stringify(body) : undefined,
});
}
/** Verify user's password via Account API. Returns a verification record ID. */
export async function verifyPassword(
token: string,
password: string,
): Promise<string> {
const res = await accountApi('POST', '/verifications/password', token, { password });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || 'Password verification failed');
}
const data = await res.json();
return data.verificationRecordId;
}
/** Full passkey registration flow: verify password → WebAuthn ceremony → bind. */
export async function registerPasskey(
getAccountToken: () => Promise<string>,
password: string,
): Promise<void> {
const token = await getAccountToken();
// Step 1: Verify password for sensitive operation
const identityVerificationId = await verifyPassword(token, password);
// Step 2: 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 3: Browser WebAuthn ceremony
const credential = await startRegistration({ optionsJSON: registrationOptions });
// Step 4: Verify the registration with Logto
const verifyRes = await accountApi(
'POST',
'/verifications/web-authn/registration/verify',
token,
{ verificationRecordId, payload: { ...credential, type: 'WebAuthn' } },
);
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 5: Bind via SaaS backend (Management API) — Logto's /api/my-account/
// rejects the opaque token, so we proxy through our backend.
await api.post('/account/mfa/webauthn/bind', {
verificationRecordId: verifiedRecordId,
identityVerificationId,
});
}

View File

@@ -1,13 +1,10 @@
import { useState } from 'react';
import { useLogto } from '@logto/react';
import { errorMessage } from '../../api/client';
import {
Alert,
Button,
Card,
FormField,
Input,
Modal,
useToast,
} from '@cameleer/design-system';
import {
@@ -16,7 +13,6 @@ import {
useAccountRenamePasskey,
useAccountDeletePasskey,
} from '../../api/account-hooks';
import { registerPasskey } from '../../api/logto-account-api';
import styles from '../../styles/platform.module.css';
export function PasskeyNudgeBanner() {
@@ -45,17 +41,12 @@ export function PasskeyNudgeBanner() {
export function PasskeySection() {
const { toast } = useToast();
const { getAccessToken } = useLogto();
const { data: passkeys, isLoading, refetch } = useAccountPasskeyList();
const { data: passkeys, isLoading } = 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);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const [regPassword, setRegPassword] = useState('');
const [regError, setRegError] = useState<string | null>(null);
function parseAgent(agent: string | null): string {
if (!agent) return 'Unknown device';
@@ -81,37 +72,6 @@ export function PasskeySection() {
}
}
function openRegister() {
setRegPassword('');
setRegError(null);
setShowPasswordModal(true);
}
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
setRegError(null);
setRegistering(true);
try {
await registerPasskey(async () => {
const token = await getAccessToken();
if (!token) throw new Error('Not authenticated');
return token;
}, regPassword);
setShowPasswordModal(false);
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') {
setRegistering(false);
return;
}
setRegError(errorMessage(err));
} finally {
setRegistering(false);
}
}
async function handleDelete(id: string) {
try {
await deletePasskey.mutateAsync(id);
@@ -126,19 +86,13 @@ export function PasskeySection() {
const credentials = passkeys ?? [];
return (
<>
<Card title="Passkeys">
<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={openRegister}>
Register passkey
</Button>
</div>
{credentials.length === 0 ? (
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
No passkeys registered yet.
No passkeys registered. You can register a passkey during sign-in.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
@@ -178,39 +132,5 @@ export function PasskeySection() {
</div>
)}
</Card>
<Modal
open={showPasswordModal}
onClose={() => { if (!registering) setShowPasswordModal(false); }}
title="Confirm identity"
size="sm"
>
<p className={styles.description} style={{ marginTop: 0 }}>
Enter your password to register a new passkey.
</p>
{regError && <Alert variant="error">{regError}</Alert>}
<form onSubmit={handleRegister} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Password" htmlFor="passkey-reg-password">
<Input
id="passkey-reg-password"
type="password"
value={regPassword}
onChange={(e) => setRegPassword(e.target.value)}
placeholder="Enter your password"
autoFocus
autoComplete="current-password"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={registering} disabled={!regPassword}>
Continue
</Button>
<Button type="button" variant="secondary" onClick={() => setShowPasswordModal(false)} disabled={registering}>
Cancel
</Button>
</div>
</form>
</Modal>
</>
);
}