From 18e6f32f90a6ed9ac988d328260b69fa6f152362 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:33:46 +0200 Subject: [PATCH] refactor: move passkey enrollment to sign-in UI via Experience API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../saas/account/AccountController.java | 11 --- .../cameleer/saas/account/AccountService.java | 4 - .../saas/config/LogtoStartupConfig.java | 5 +- .../saas/identity/LogtoManagementClient.java | 34 -------- ui/sign-in/src/SignInPage.tsx | 71 +++++++++++++++- ui/sign-in/src/experience-api.ts | 59 ++++++++++--- ui/src/api/logto-account-api.ts | 83 ------------------ ui/src/components/account/PasskeySection.tsx | 84 +------------------ 8 files changed, 120 insertions(+), 231 deletions(-) delete mode 100644 ui/src/api/logto-account-api.ts diff --git a/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java b/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java index 6df0749..21718ec 100644 --- a/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java +++ b/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java @@ -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 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") diff --git a/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java b/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java index 22b2df7..62483a2 100644 --- a/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java +++ b/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java @@ -150,10 +150,6 @@ public class AccountService { } } - public void bindWebAuthnPasskey(String userId, String verificationRecordId, String identityVerificationId) { - logtoClient.bindWebAuthnMfa(userId, verificationRecordId, identityVerificationId); - } - // --- Passkeys --- public List listPasskeys(String userId) { diff --git a/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java index 68e7f8d..5c6b221 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/LogtoStartupConfig.java @@ -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(); } diff --git a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java index f79d128..f870d80 100644 --- a/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java +++ b/src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java @@ -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 customData) { if (!isAvailable()) return; diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx index b1e2c3c..788580b 100644 --- a/ui/sign-in/src/SignInPage.tsx +++ b/ui/sign-in/src/SignInPage.tsx @@ -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); + 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 = ( + + + + )} diff --git a/ui/sign-in/src/experience-api.ts b/ui/sign-in/src/experience-api.ts index e9c1651..df1ca44 100644 --- a/ui/sign-in/src/experience-api.ts +++ b/ui/sign-in/src/experience-api.ts @@ -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): Prom const data = await res.json(); return data.verificationId; } + +// --- MFA Enrollment (during sign-in) --- + +export async function startWebAuthnRegistration(): Promise<{ verificationId: string; registrationOptions: Record }> { + 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): Promise { + 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 { + 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 { + await skipMfaBinding(); + const result = await trySubmit(); + if (result.ok) return result.redirectTo; + throw new Error(result.message); +} diff --git a/ui/src/api/logto-account-api.ts b/ui/src/api/logto-account-api.ts deleted file mode 100644 index 6cd6298..0000000 --- a/ui/src/api/logto-account-api.ts +++ /dev/null @@ -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, -): Promise { - 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 { - 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: 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, - }); -} diff --git a/ui/src/components/account/PasskeySection.tsx b/ui/src/components/account/PasskeySection.tsx index 582b67b..b89cc2f 100644 --- a/ui/src/components/account/PasskeySection.tsx +++ b/ui/src/components/account/PasskeySection.tsx @@ -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(null); 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'; @@ -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 ( - <>

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

-
- -
{credentials.length === 0 ? (

- No passkeys registered yet. + No passkeys registered. You can register a passkey during sign-in.

) : (
@@ -178,39 +132,5 @@ 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" - /> - -
- - -
-
-
- ); }