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

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