fix: add password re-verification before passkey registration
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:
@@ -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(
|
||||||
type: 'WebAuthn',
|
'POST',
|
||||||
newIdentifierVerificationRecordId: verifiedRecordId,
|
'/my-account/mfa-verifications',
|
||||||
});
|
token,
|
||||||
|
{
|
||||||
|
type: 'WebAuthn',
|
||||||
|
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})`);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user