fix: add password re-verification before passkey registration
All checks were successful
CI / build (push) Successful in 2m28s
CI / docker (push) Successful in 1m32s

Logto Account API requires identity verification (logto-verification-id
header) for sensitive MFA operations. Adds a password prompt modal before
the WebAuthn ceremony — verifies password first, then proceeds with
passkey registration using the verification record ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 18:10:47 +02:00
parent c360d9ad5f
commit 2aa5100530
2 changed files with 95 additions and 14 deletions

View File

@@ -10,21 +10,44 @@ async function accountApi(
path: string, path: string,
token: string, token: string,
body?: unknown, body?: unknown,
extraHeaders?: Record<string, string>,
): Promise<Response> { ): Promise<Response> {
return fetch(`/api${path}`, { return fetch(`/api${path}`, {
method, method,
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
...(body ? { 'Content-Type': 'application/json' } : {}), ...(body ? { 'Content-Type': 'application/json' } : {}),
...extraHeaders,
}, },
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
} }
export async function registerPasskey(getAccountToken: () => Promise<string>): Promise<void> { /** 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(); const token = await getAccountToken();
// Step 1: Get registration options from Logto Account API // 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); const optionsRes = await accountApi('POST', '/verifications/web-authn/registration', token);
if (!optionsRes.ok) { if (!optionsRes.ok) {
const err = await optionsRes.json().catch(() => ({})); const err = await optionsRes.json().catch(() => ({}));
@@ -32,10 +55,10 @@ export async function registerPasskey(getAccountToken: () => Promise<string>): P
} }
const { registrationOptions, verificationRecordId } = await optionsRes.json(); const { registrationOptions, verificationRecordId } = await optionsRes.json();
// Step 2: Browser WebAuthn ceremony // Step 3: Browser WebAuthn ceremony
const credential = await startRegistration({ optionsJSON: registrationOptions }); const credential = await startRegistration({ optionsJSON: registrationOptions });
// Step 3: Verify the registration with Logto // Step 4: Verify the registration with Logto
const verifyRes = await accountApi( const verifyRes = await accountApi(
'POST', 'POST',
'/verifications/web-authn/registration/verify', '/verifications/web-authn/registration/verify',
@@ -49,11 +72,17 @@ export async function registerPasskey(getAccountToken: () => Promise<string>): P
const verifyData = await verifyRes.json(); const verifyData = await verifyRes.json();
const verifiedRecordId = verifyData.verificationRecordId; const verifiedRecordId = verifyData.verificationRecordId;
// Step 4: Bind the passkey to the user's account // Step 5: Bind the passkey — requires logto-verification-id header for sensitive op
const bindRes = await accountApi('POST', '/my-account/mfa-verifications', token, { const bindRes = await accountApi(
'POST',
'/my-account/mfa-verifications',
token,
{
type: 'WebAuthn', type: 'WebAuthn',
newIdentifierVerificationRecordId: verifiedRecordId, newIdentifierVerificationRecordId: verifiedRecordId,
}); },
{ 'logto-verification-id': identityVerificationId },
);
if (!bindRes.ok) { if (!bindRes.ok) {
const err = await bindRes.json().catch(() => ({})); const err = await bindRes.json().catch(() => ({}));
throw new Error(err.message || `Failed to bind passkey (${bindRes.status})`); throw new Error(err.message || `Failed to bind passkey (${bindRes.status})`);

View File

@@ -5,7 +5,9 @@ import {
Alert, Alert,
Button, Button,
Card, Card,
FormField,
Input, Input,
Modal,
useToast, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
import { import {
@@ -51,6 +53,9 @@ export function PasskeySection() {
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
const [registering, setRegistering] = useState(false); 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 { function parseAgent(agent: string | null): string {
if (!agent) return 'Unknown device'; if (!agent) return 'Unknown device';
@@ -76,20 +81,32 @@ export function PasskeySection() {
} }
} }
async function handleRegister() { function openRegister() {
setRegPassword('');
setRegError(null);
setShowPasswordModal(true);
}
async function handleRegister(e: React.FormEvent) {
e.preventDefault();
setRegError(null);
setRegistering(true); setRegistering(true);
try { try {
await registerPasskey(async () => { await registerPasskey(async () => {
const token = await getAccessToken(); const token = await getAccessToken();
if (!token) throw new Error('Not authenticated'); if (!token) throw new Error('Not authenticated');
return token; return token;
}); }, regPassword);
setShowPasswordModal(false);
await refetch(); await refetch();
toast({ title: 'Passkey registered', variant: 'success' }); toast({ title: 'Passkey registered', variant: 'success' });
} catch (err) { } catch (err) {
// User cancelled the WebAuthn prompt — not an error // User cancelled the WebAuthn prompt — not an error
if (err instanceof Error && err.name === 'NotAllowedError') return; if (err instanceof Error && err.name === 'NotAllowedError') {
toast({ title: 'Passkey registration failed', description: errorMessage(err), variant: 'error' }); setRegistering(false);
return;
}
setRegError(errorMessage(err));
} finally { } finally {
setRegistering(false); setRegistering(false);
} }
@@ -109,12 +126,13 @@ export function PasskeySection() {
const credentials = passkeys ?? []; const credentials = passkeys ?? [];
return ( return (
<>
<Card title="Passkeys"> <Card title="Passkeys">
<p className={styles.description} style={{ marginTop: 0 }}> <p className={styles.description} style={{ marginTop: 0 }}>
Use your fingerprint, face, or security key to sign in faster. Use your fingerprint, face, or security key to sign in faster.
</p> </p>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<Button variant="primary" onClick={handleRegister} loading={registering}> <Button variant="primary" onClick={openRegister}>
Register passkey Register passkey
</Button> </Button>
</div> </div>
@@ -160,5 +178,39 @@ export function PasskeySection() {
</div> </div>
)} )}
</Card> </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>
</>
); );
} }