diff --git a/ui/src/api/logto-account-api.ts b/ui/src/api/logto-account-api.ts index bd49e3d..51a7d11 100644 --- a/ui/src/api/logto-account-api.ts +++ b/ui/src/api/logto-account-api.ts @@ -10,21 +10,44 @@ async function accountApi( path: string, token: string, body?: unknown, + extraHeaders?: Record, ): Promise { return fetch(`/api${path}`, { method, headers: { Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}), + ...extraHeaders, }, body: body ? JSON.stringify(body) : undefined, }); } -export async function registerPasskey(getAccountToken: () => Promise): Promise { +/** Verify user's password via Account API. Returns a verification record ID. */ +export async function verifyPassword( + token: string, + password: string, +): Promise { + 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, + password: string, +): Promise { 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); if (!optionsRes.ok) { const err = await optionsRes.json().catch(() => ({})); @@ -32,10 +55,10 @@ export async function registerPasskey(getAccountToken: () => Promise): P } const { registrationOptions, verificationRecordId } = await optionsRes.json(); - // Step 2: Browser WebAuthn ceremony + // Step 3: Browser WebAuthn ceremony const credential = await startRegistration({ optionsJSON: registrationOptions }); - // Step 3: Verify the registration with Logto + // Step 4: Verify the registration with Logto const verifyRes = await accountApi( 'POST', '/verifications/web-authn/registration/verify', @@ -49,11 +72,17 @@ export async function registerPasskey(getAccountToken: () => Promise): P 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, - }); + // Step 5: Bind the passkey — requires logto-verification-id header for sensitive op + const bindRes = await accountApi( + 'POST', + '/my-account/mfa-verifications', + token, + { + type: 'WebAuthn', + newIdentifierVerificationRecordId: verifiedRecordId, + }, + { 'logto-verification-id': identityVerificationId }, + ); 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 838d715..582b67b 100644 --- a/ui/src/components/account/PasskeySection.tsx +++ b/ui/src/components/account/PasskeySection.tsx @@ -5,7 +5,9 @@ import { Alert, Button, Card, + FormField, Input, + Modal, useToast, } from '@cameleer/design-system'; import { @@ -51,6 +53,9 @@ export function PasskeySection() { const [editName, setEditName] = useState(''); const [confirmDeleteId, setConfirmDeleteId] = useState(null); const [registering, setRegistering] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [regPassword, setRegPassword] = useState(''); + const [regError, setRegError] = useState(null); function parseAgent(agent: string | null): string { 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); 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') return; - toast({ title: 'Passkey registration failed', description: errorMessage(err), variant: 'error' }); + if (err instanceof Error && err.name === 'NotAllowedError') { + setRegistering(false); + return; + } + setRegError(errorMessage(err)); } finally { setRegistering(false); } @@ -109,12 +126,13 @@ export function PasskeySection() { const credentials = passkeys ?? []; return ( + <>

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

-
@@ -160,5 +178,39 @@ export function PasskeySection() { )}
+ + { if (!registering) setShowPasswordModal(false); }} + title="Confirm identity" + size="sm" + > +

+ Enter your password to register a new passkey. +

+ {regError && {regError}} +
+ + setRegPassword(e.target.value)} + placeholder="Enter your password" + autoFocus + autoComplete="current-password" + /> + +
+ + +
+
+
+ ); }