fix(sign-in): TOTP enrollment QR branding and verification failure
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) <noreply@anthropic.com>
This commit is contained in:
10
ui/sign-in/package-lock.json
generated
10
ui/sign-in/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.1.54",
|
"@cameleer/design-system": "^0.1.54",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
@@ -1905,6 +1906,15 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cameleer/design-system": "^0.1.54",
|
"@cameleer/design-system": "^0.1.54",
|
||||||
"@simplewebauthn/browser": "^13.3.0",
|
"@simplewebauthn/browser": "^13.3.0",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
|||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
|
||||||
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
|
||||||
|
import { QRCodeSVG } from 'qrcode.react';
|
||||||
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
|
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
|
||||||
import {
|
import {
|
||||||
signIn, startRegistration, completeRegistration,
|
signIn, startRegistration, completeRegistration,
|
||||||
@@ -311,7 +312,7 @@ export function SignInPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// --- MFA enrollment ---
|
// --- 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('');
|
const [totpCode, setTotpCode] = useState('');
|
||||||
|
|
||||||
async function handleEnrollPasskey() {
|
async function handleEnrollPasskey() {
|
||||||
@@ -343,7 +344,10 @@ export function SignInPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await createTotpSecret();
|
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('');
|
setTotpCode('');
|
||||||
setMode('mfaEnrollTotp');
|
setMode('mfaEnrollTotp');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -358,7 +362,7 @@ export function SignInPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const verifiedId = await verifyTotpSetup(totpCode);
|
const verifiedId = await verifyTotpSetup(totpCode, totpSetup!.verificationId);
|
||||||
await bindMfaProfile('Totp', verifiedId);
|
await bindMfaProfile('Totp', verifiedId);
|
||||||
const bc = await generateBackupCodes();
|
const bc = await generateBackupCodes();
|
||||||
await bindMfaProfile('BackupCode', bc.verificationId);
|
await bindMfaProfile('BackupCode', bc.verificationId);
|
||||||
@@ -848,7 +852,7 @@ export function SignInPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '8px 0' }}>
|
||||||
<img src={totpSetup.secretQrCode} alt="TOTP QR Code" width={180} height={180} />
|
<QRCodeSVG value={totpSetup.otpauthUri} size={180} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
textAlign: 'center', padding: '6px 10px',
|
textAlign: 'center', padding: '6px 10px',
|
||||||
|
|||||||
@@ -347,8 +347,8 @@ export async function createTotpSecret(): Promise<{ secret: string; secretQrCode
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyTotpSetup(code: string): Promise<string> {
|
export async function verifyTotpSetup(code: string, verificationId: string): Promise<string> {
|
||||||
const res = await request('POST', '/verification/totp/verify', { code });
|
const res = await request('POST', '/verification/totp/verify', { code, verificationId });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({}));
|
const err = await res.json().catch(() => ({}));
|
||||||
throw new Error(err.message || `TOTP verification failed (${res.status})`);
|
throw new Error(err.message || `TOTP verification failed (${res.status})`);
|
||||||
|
|||||||
Reference in New Issue
Block a user