The registration flow hit a 422 on /api/experience/submit when MFA policy is UserControlled. Adds the same trySubmit + skipMfaBinding pattern already used in the sign-in flow — Logto confirms mfa-skipped works for both SignIn and Register interaction events. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
276 lines
8.8 KiB
TypeScript
276 lines
8.8 KiB
TypeScript
const BASE = '/api/experience';
|
|
|
|
async function request(method: string, path: string, body?: unknown): Promise<Response> {
|
|
const res = await fetch(`${BASE}${path}`, {
|
|
method,
|
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
credentials: 'same-origin',
|
|
});
|
|
return res;
|
|
}
|
|
|
|
// --- Shared ---
|
|
|
|
export async function identifyUser(verificationId: string): Promise<void> {
|
|
const res = await request('POST', '/identification', { verificationId });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.message || `Identification failed (${res.status})`);
|
|
}
|
|
}
|
|
|
|
export async function submitInteraction(): Promise<string> {
|
|
const res = await request('POST', '/submit');
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.message || `Submit failed (${res.status})`);
|
|
}
|
|
const data = await res.json();
|
|
return data.redirectTo;
|
|
}
|
|
|
|
// --- Sign-in ---
|
|
|
|
export async function initInteraction(): Promise<void> {
|
|
const res = await request('PUT', '', { interactionEvent: 'SignIn' });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.message || `Failed to initialize sign-in (${res.status})`);
|
|
}
|
|
}
|
|
|
|
function detectIdentifierType(input: string): 'email' | 'username' {
|
|
return input.includes('@') ? 'email' : 'username';
|
|
}
|
|
|
|
export async function verifyPassword(
|
|
identifier: string,
|
|
password: string
|
|
): Promise<string> {
|
|
const type = detectIdentifierType(identifier);
|
|
const res = await request('POST', '/verification/password', {
|
|
identifier: { type, value: identifier },
|
|
password,
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
if (res.status === 422) {
|
|
throw new Error('Invalid credentials');
|
|
}
|
|
throw new Error(err.message || `Authentication failed (${res.status})`);
|
|
}
|
|
const data = await res.json();
|
|
return data.verificationId;
|
|
}
|
|
|
|
export class MfaRequiredError extends Error {
|
|
constructor() {
|
|
super('MFA verification required');
|
|
this.name = 'MfaRequiredError';
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const data = await res.json();
|
|
return { ok: true, redirectTo: data.redirectTo };
|
|
}
|
|
const err = await res.json().catch(() => ({}));
|
|
return { ok: false, status: res.status, code: err.code ?? '', message: err.message ?? `Submit failed (${res.status})` };
|
|
}
|
|
|
|
async function skipMfaBinding(): Promise<void> {
|
|
await request('POST', '/profile/mfa/mfa-skipped');
|
|
}
|
|
|
|
export async function signIn(identifier: string, password: string): Promise<string> {
|
|
await initInteraction();
|
|
const verificationId = await verifyPassword(identifier, password);
|
|
await identifyUser(verificationId);
|
|
const result = await trySubmit();
|
|
if (result.ok) return result.redirectTo;
|
|
|
|
// MFA already enrolled — user must verify (show TOTP input)
|
|
if (result.code === 'user.missing_mfa' || result.code === 'session.mfa.require_mfa_verification') {
|
|
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.
|
|
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 Error(result.message);
|
|
}
|
|
|
|
// --- Registration ---
|
|
|
|
export async function initRegistration(): Promise<void> {
|
|
const res = await request('PUT', '', { interactionEvent: 'Register' });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.message || `Failed to initialize registration (${res.status})`);
|
|
}
|
|
}
|
|
|
|
export async function sendVerificationCode(email: string): Promise<string> {
|
|
const res = await request('POST', '/verification/verification-code', {
|
|
identifier: { type: 'email', value: email },
|
|
interactionEvent: 'Register',
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
if (res.status === 422) {
|
|
throw new Error('This email is already registered');
|
|
}
|
|
throw new Error(err.message || `Failed to send verification code (${res.status})`);
|
|
}
|
|
const data = await res.json();
|
|
return data.verificationId;
|
|
}
|
|
|
|
export async function verifyCode(
|
|
email: string,
|
|
verificationId: string,
|
|
code: string
|
|
): Promise<string> {
|
|
const res = await request('POST', '/verification/verification-code/verify', {
|
|
identifier: { type: 'email', value: email },
|
|
verificationId,
|
|
code,
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
if (res.status === 422) {
|
|
throw new Error('Invalid or expired verification code');
|
|
}
|
|
throw new Error(err.message || `Verification failed (${res.status})`);
|
|
}
|
|
const data = await res.json();
|
|
return data.verificationId;
|
|
}
|
|
|
|
export async function addProfile(type: string, value: string): Promise<void> {
|
|
const res = await request('POST', '/profile', { type, value });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.message || `Failed to update profile (${res.status})`);
|
|
}
|
|
}
|
|
|
|
/** Phase 1: init registration + send verification email. Returns verificationId for phase 2. */
|
|
export async function startRegistration(email: string): Promise<string> {
|
|
await initRegistration();
|
|
return sendVerificationCode(email);
|
|
}
|
|
|
|
/** Phase 2: verify code, set password, create user, submit. Returns redirect URL. */
|
|
export async function completeRegistration(
|
|
email: string,
|
|
password: string,
|
|
verificationId: string,
|
|
code: string
|
|
): Promise<string> {
|
|
const verifiedId = await verifyCode(email, verificationId, code);
|
|
await addProfile('password', password);
|
|
await identifyUser(verifiedId);
|
|
|
|
const result = await trySubmit();
|
|
if (result.ok) return result.redirectTo;
|
|
|
|
// MFA not enrolled, UserControlled policy — skip the binding prompt
|
|
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 Error(result.message);
|
|
}
|
|
|
|
// --- Forgot Password ---
|
|
|
|
export async function initForgotPassword(): Promise<void> {
|
|
const res = await request('PUT', '', { interactionEvent: 'ForgotPassword' });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
throw new Error(err.message || `Failed to initialize password reset (${res.status})`);
|
|
}
|
|
}
|
|
|
|
export async function forgotPasswordSendCode(email: string): Promise<string> {
|
|
const res = await request('POST', '/verification/verification-code', {
|
|
identifier: { type: 'email', value: email },
|
|
interactionEvent: 'ForgotPassword',
|
|
});
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
if (res.status === 422) {
|
|
throw new Error('No account found with this email');
|
|
}
|
|
throw new Error(err.message || `Failed to send reset code (${res.status})`);
|
|
}
|
|
const data = await res.json();
|
|
return data.verificationId;
|
|
}
|
|
|
|
export async function forgotPasswordVerifyAndReset(
|
|
email: string,
|
|
verificationId: string,
|
|
code: string,
|
|
newPassword: string,
|
|
): Promise<void> {
|
|
const verifiedId = await verifyCode(email, verificationId, code);
|
|
await identifyUser(verifiedId);
|
|
await addProfile('password', newPassword);
|
|
await submitInteraction();
|
|
}
|
|
|
|
export async function startForgotPassword(email: string): Promise<string> {
|
|
await initForgotPassword();
|
|
return forgotPasswordSendCode(email);
|
|
}
|
|
|
|
// --- MFA Verification ---
|
|
|
|
export async function verifyTotp(code: string): Promise<string> {
|
|
const res = await request('POST', '/verification/totp/verify', { code });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
if (res.status === 422) {
|
|
throw new Error('Invalid code, please try again');
|
|
}
|
|
throw new Error(err.message || `TOTP verification failed (${res.status})`);
|
|
}
|
|
const data = await res.json();
|
|
return data.verificationId;
|
|
}
|
|
|
|
export async function verifyBackupCode(code: string): Promise<string> {
|
|
const res = await request('POST', '/verification/backup-code/verify', { code });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({}));
|
|
if (res.status === 422) {
|
|
const msg = err.code === 'backup_code_consumed'
|
|
? 'This backup code has already been used'
|
|
: 'Invalid backup code';
|
|
throw new Error(msg);
|
|
}
|
|
throw new Error(err.message || `Backup code verification failed (${res.status})`);
|
|
}
|
|
const data = await res.json();
|
|
return data.verificationId;
|
|
}
|
|
|
|
export async function submitMfa(verificationId: string): Promise<string> {
|
|
await identifyUser(verificationId);
|
|
return submitInteraction();
|
|
}
|