refactor: move passkey enrollment to sign-in UI via Experience API
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:
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user