From fcb25778e1f880f08eb5b961461b9234d8760ffe Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:34:52 +0200 Subject: [PATCH] fix(sign-in): TOTP enrollment QR branding and verification failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in the sign-in UI's TOTP MFA enrollment flow: 1. Auth app displayed the PC hostname and "Platform Owner" instead of "Cameleer" and the user's email. The sign-in UI was rendering Logto's pre-generated QR code which uses the ENDPOINT hostname as issuer. Now generates our own otpauth:// URI with proper branding, rendered client-side via qrcode.react. 2. TOTP code verification returned 400 "Invalid TOTP code". The verifyTotpSetup() call was missing the required verificationId parameter — Logto's Experience API needs it to locate the pending secret during enrollment. Co-Authored-By: Claude Opus 4.6 (1M context) --- ui/sign-in/package-lock.json | 10 ++++++++++ ui/sign-in/package.json | 1 + ui/sign-in/src/SignInPage.tsx | 12 ++++++++---- ui/sign-in/src/experience-api.ts | 4 ++-- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/ui/sign-in/package-lock.json b/ui/sign-in/package-lock.json index f9b7c11..4f2bae1 100644 --- a/ui/sign-in/package-lock.json +++ b/ui/sign-in/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@cameleer/design-system": "^0.1.54", "@simplewebauthn/browser": "^13.3.0", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, @@ -1905,6 +1906,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", diff --git a/ui/sign-in/package.json b/ui/sign-in/package.json index f28d3bd..96b3a54 100644 --- a/ui/sign-in/package.json +++ b/ui/sign-in/package.json @@ -11,6 +11,7 @@ "dependencies": { "@cameleer/design-system": "^0.1.54", "@simplewebauthn/browser": "^13.3.0", + "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx index f8703d9..3713fc7 100644 --- a/ui/sign-in/src/SignInPage.tsx +++ b/ui/sign-in/src/SignInPage.tsx @@ -2,6 +2,7 @@ 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 { QRCodeSVG } from 'qrcode.react'; import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser'; import { signIn, startRegistration, completeRegistration, @@ -311,7 +312,7 @@ export function SignInPage() { }; // --- MFA enrollment --- - const [totpSetup, setTotpSetup] = useState<{ secret: string; secretQrCode: string; verificationId: string } | null>(null); + const [totpSetup, setTotpSetup] = useState<{ secret: string; otpauthUri: string; verificationId: string } | null>(null); const [totpCode, setTotpCode] = useState(''); async function handleEnrollPasskey() { @@ -343,7 +344,10 @@ export function SignInPage() { setLoading(true); try { const data = await createTotpSecret(); - setTotpSetup(data); + const account = identifier || 'user'; + const issuer = 'Cameleer'; + const otpauthUri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${data.secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; + setTotpSetup({ secret: data.secret, otpauthUri, verificationId: data.verificationId }); setTotpCode(''); setMode('mfaEnrollTotp'); } catch (err) { @@ -358,7 +362,7 @@ export function SignInPage() { setError(null); setLoading(true); try { - const verifiedId = await verifyTotpSetup(totpCode); + const verifiedId = await verifyTotpSetup(totpCode, totpSetup!.verificationId); await bindMfaProfile('Totp', verifiedId); const bc = await generateBackupCodes(); await bindMfaProfile('BackupCode', bc.verificationId); @@ -848,7 +852,7 @@ export function SignInPage() {

- TOTP QR Code +
{ - const res = await request('POST', '/verification/totp/verify', { code }); +export async function verifyTotpSetup(code: string, verificationId: string): Promise { + const res = await request('POST', '/verification/totp/verify', { code, verificationId }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `TOTP verification failed (${res.status})`);