Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
0ae492d
test(mosaic): add stateful MFA feature fixtures
austincalvelage Oct 3, 2026
b8b9dc6
feat(mosaic): add the Clerk MFA model
austincalvelage Oct 3, 2026
99d9853
feat(mosaic): add MFA enrollment and management controllers
austincalvelage Oct 3, 2026
b392afc
feat(mosaic): connect the MFA section with feature coverage
austincalvelage Oct 3, 2026
1114494
feat(swingset): add the live MFA sandbox
austincalvelage Oct 3, 2026
2ea1725
test(mosaic): cover SMS retries after phone input edits
austincalvelage Oct 3, 2026
9845afd
fix(mosaic): reuse account phones when retrying SMS setup
austincalvelage Oct 3, 2026
a21fa7a
test(mosaic): wait for SMS retry input to become editable
austincalvelage Oct 3, 2026
fc48381
test(mosaic): await completed SMS preparations before ending tests
austincalvelage Oct 3, 2026
0990598
refactor(mosaic): group MFA section files
austincalvelage Oct 6, 2026
654dc91
refactor(mosaic): simplify MFA composition and cover recovery
austincalvelage Oct 6, 2026
6f06018
fix(mosaic): preserve safe localized MFA errors
austincalvelage Oct 6, 2026
d48fe19
test(mosaic): await MFA refresh before fixture teardown
austincalvelage Oct 6, 2026
4d4dabb
chore: keep one empty changeset
austincalvelage Oct 6, 2026
52f53a8
test(mosaic): use current password renderer in MFA coverage
austincalvelage Oct 7, 2026
61ca206
refactor(mosaic): align MFA with shared flow patterns
austincalvelage Oct 8, 2026
3b6923f
fix(mosaic): match legacy MFA parity for phones, defaults and backup …
austincalvelage Oct 8, 2026
da12630
refactor(mosaic): open MFA removal through the confirmation handle
austincalvelage Oct 8, 2026
d16682a
refactor(mosaic): let MFA reverification cancellation propagate as a …
austincalvelage Oct 8, 2026
4746c45
refactor(mosaic): remove MFA reverification wiring
austincalvelage Oct 8, 2026
a4dc3be
test(mosaic): align MFA pending assertions with focusable inputs
austincalvelage Oct 8, 2026
8c10f05
test(mosaic): remove sequential MFA deletion focus test
austincalvelage Oct 9, 2026
245aca7
test(mosaic): stabilize async assertions and remove deletion focus test
austincalvelage Oct 9, 2026
4696d2d
fix(mosaic): keep backup-code actions visible while generating
austincalvelage Oct 9, 2026
5fb1acd
refactor(mosaic): let the MFA machine own only the setup flow
austincalvelage Oct 9, 2026
5dcdde7
test(mosaic): reconcile MFA fixtures with main
austincalvelage Oct 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/bright-mfa-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';

import { fapiUrl, serveFapi } from './fake-fapi';
import { fapiClient, fapiPhoneNumber, fapiSession, fapiUser, fapiVerification } from './fapi';

describe('fake FAPI MFA session routing', () => {
it('updates the requested user across their sessions without changing the active user', async () => {
const requestedUser = fapiUser({
id: 'user_2',
phone_numbers: [
fapiPhoneNumber({
id: 'phone_2',
phone_number: '+15555550202',
verification: fapiVerification('phone_code', { status: 'verified' }),
}),
],
});
const fapi = serveFapi({
client: fapiClient([
fapiSession({ id: 'sess_1', user: fapiUser({ id: 'user_1' }) }),
fapiSession({ id: 'sess_2', user: requestedUser }),
fapiSession({ id: 'sess_3', user: requestedUser }),
]),
});
const response = await fetch(`${fapiUrl('/v1/me/phone_numbers/phone_2')}?_method=PATCH&_clerk_session_id=sess_2`, {
method: 'POST',
body: new URLSearchParams({ reserved_for_second_factor: 'true' }),
});

expect(response.ok).toBe(true);
expect(fapi.client.sessions[0]?.user.phone_numbers).toHaveLength(0);
for (const session of fapi.client.sessions.slice(1)) {
expect(session.user.phone_numbers[0]?.reserved_for_second_factor).toBe(true);
expect(session.user.two_factor_enabled).toBe(true);
}
});
});
8 changes: 6 additions & 2 deletions packages/mosaic/src/__tests__/feature/fake-fapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import { http, HttpResponse } from 'msw';
import { setupWorker } from 'msw/browser';

import { enterpriseHandlers, type FakeEnterpriseLinking } from './fake-fapi/enterprise';
import { createMfaState, type FakeMfaState, mfaHandlers } from './fake-fapi/mfa';
import { type FakePasskeysSeed, passkeyHandlers } from './fake-fapi/passkeys';
import { envelope, error, findSession, missing, updateUser } from './fake-fapi/shared';
import { envelope, error, findSession, missing, updateUser, VERIFICATION_CODE } from './fake-fapi/shared';
import {
createVerificationState,
type FakeVerificationSeed,
Expand Down Expand Up @@ -60,6 +61,7 @@ export interface FakeFapiState {
passwordUpdates: URLSearchParams[];
enterpriseConnections: EnterpriseConnectionJSON[];
enterpriseLinking: FakeEnterpriseLinking;
mfa: FakeMfaState;
activeDevices?: ActiveDeviceRecord[];
deviceTrackingEnabled: boolean;
}
Expand Down Expand Up @@ -206,7 +208,7 @@ function rejected(code: string, message: string) {
return HttpResponse.json({ errors: [{ code, message, long_message: message }] }, { status: 400 });
}

export const VERIFICATION_CODE = '424242';
export { VERIFICATION_CODE } from './fake-fapi/shared';
export const PROFILE_IMAGE_URL = 'https://img.clerk.com/uploaded.png';

const USER_FIELDS = [
Expand Down Expand Up @@ -290,6 +292,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
passwordUpdates: [],
enterpriseConnections: [],
deviceTrackingEnabled: true,
mfa: createMfaState(),
...rest,
verification: createVerificationState(verification),
enterpriseLinking: {
Expand All @@ -306,6 +309,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState {
...verificationHandlers(state, fapiUrl),
...enterpriseHandlers(state, fapiUrl),
...passkeyHandlers(state, fapiUrl, passkeys),
...mfaHandlers(state, fapiUrl),
http.get(fapiUrl('/v1/environment'), () =>
HttpResponse.json(state.environment, state.country ? { headers: { 'x-country': state.country } } : undefined),
),
Expand Down
283 changes: 283 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi/mfa.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
import type { ClientJSON, PhoneNumberJSON, UserJSON } from '@clerk/shared/types';
import { http, HttpResponse } from 'msw';

import { type FapiEnvironment, fapiPhoneNumber, fapiVerification } from '../fapi';
import { envelope, missing, requestUser, updateUser, VERIFICATION_CODE } from './shared';

export interface FakeMfaState {
totpCreations: number;
totpAttempts: string[];
totpRemovals: number;
backupCodeCreations: number;
phoneCreations: string[];
phonePreparations: string[];
phoneAttempts: { id: string; code: string }[];
phoneUpdates: { id: string; reserved?: boolean; default?: boolean }[];
codes: string[];
}

export function createMfaState(): FakeMfaState {
return {
totpCreations: 0,
totpAttempts: [],
totpRemovals: 0,
backupCodeCreations: 0,
phoneCreations: [],
phonePreparations: [],
phoneAttempts: [],
phoneUpdates: [],
codes: Array.from({ length: 10 }, (_, index) => `CODE00${String(index + 1).padStart(2, '0')}`),
};
}

export function mfaHandlers(
state: { client: ClientJSON; environment: FapiEnvironment; mfa: FakeMfaState },
fapiUrl: (path: string) => string,
) {
const backupEnabled = () =>
state.environment.user_settings.attributes.backup_code.enabled &&
state.environment.user_settings.attributes.backup_code.used_for_second_factor;
const usableFactors = (user: UserJSON) =>
Number(user.totp_enabled) +
user.phone_numbers.filter(phone => phone.reserved_for_second_factor && phone.verification?.status === 'verified')
.length;
const error = (code: string, message: string, status = 400) =>
HttpResponse.json({ errors: [{ code, message, long_message: message }] }, { status });
const updatePhone = (user: UserJSON, id: string, transform: (phone: PhoneNumberJSON) => PhoneNumberJSON) => {
const phone = user.phone_numbers.find(phone => phone.id === id);
if (!phone) {
return undefined;
}
const updated = transform(phone);
updateUser(state, {
...user,
phone_numbers: user.phone_numbers.map(phone => (phone.id === id ? updated : phone)),
});
return updated;
};

return [
http.post(fapiUrl('/v1/me/totp'), ({ request }) => {
const currentUser = requestUser(state, request);
if (!currentUser) {
return missing();
}
if (new URL(request.url).searchParams.get('_method') === 'DELETE') {
if (
!currentUser.totp_enabled ||
(state.environment.user_settings.sign_up.mfa?.required && usableFactors(currentUser) <= 1)
) {
return error('second_factor_deletion_not_allowed', 'This method cannot be removed.');
}
state.mfa.totpRemovals += 1;
updateUser(state, {
...currentUser,
totp_enabled: false,
two_factor_enabled: currentUser.phone_numbers.some(phone => phone.reserved_for_second_factor),
backup_code_enabled:
currentUser.phone_numbers.some(phone => phone.reserved_for_second_factor) &&
currentUser.backup_code_enabled,
});
return envelope({ object: 'deleted', id: 'totp_1' }, state.client);
}
if (
currentUser.totp_enabled ||
!state.environment.user_settings.attributes.authenticator_app.used_for_second_factor
) {
return error('form_param_value_invalid', 'Authenticator is unavailable.', 422);
}
state.mfa.totpCreations += 1;
return envelope(
{
object: 'totp',
id: 'totp_1',
secret: `SECRET${state.mfa.totpCreations}`,
uri: `otpauth://totp/Acme:user?secret=SECRET${state.mfa.totpCreations}`,
verified: false,
created_at: Date.now(),
updated_at: Date.now(),
},
state.client,
);
}),
http.post(fapiUrl('/v1/me/totp/attempt_verification'), async ({ request }) => {
const currentUser = requestUser(state, request);
if (!currentUser) {
return missing();
}
if (state.mfa.totpCreations === 0 || currentUser.totp_enabled) {
return error('form_param_value_invalid', 'No authenticator setup is pending.', 422);
}
const code = new URLSearchParams(await request.text()).get('code') ?? '';
state.mfa.totpAttempts.push(code);
if (code !== '123456') {
return HttpResponse.json(
{ errors: [{ code: 'form_code_incorrect', message: 'Incorrect code', long_message: 'Incorrect code' }] },
{ status: 422 },
);
}
const newCodes = backupEnabled() && !currentUser.backup_code_enabled;
updateUser(state, {
...currentUser,
totp_enabled: true,
two_factor_enabled: true,
backup_code_enabled: currentUser.backup_code_enabled || newCodes,
});
return envelope(
{
object: 'totp',
id: 'totp_1',
verified: true,
...(newCodes ? { backup_codes: state.mfa.codes } : {}),
created_at: Date.now(),
updated_at: Date.now(),
},
state.client,
);
}),
http.post(fapiUrl('/v1/me/backup_codes/'), ({ request }) => {
const currentUser = requestUser(state, request);
if (!currentUser || usableFactors(currentUser) === 0 || !backupEnabled()) {
return error('form_param_value_invalid', 'Set up a verification method first.', 422);
}
state.mfa.backupCodeCreations += 1;
state.mfa.codes = Array.from(
{ length: 10 },
(_, index) => `CODE${String(state.mfa.backupCodeCreations).padStart(2, '0')}${String(index).padStart(2, '0')}`,
);
updateUser(state, { ...currentUser, backup_code_enabled: true });
return envelope(
{
object: 'backup_code',
id: 'backup_1',
codes: state.mfa.codes,
created_at: Date.now(),
updated_at: Date.now(),
},
state.client,
);
}),
http.post(fapiUrl('/v1/me/phone_numbers/'), async ({ request }) => {
const currentUser = requestUser(state, request);
if (!currentUser) {
return missing();
}
const number = new URLSearchParams(await request.text()).get('phone_number') ?? '';
state.mfa.phoneCreations.push(number);
if (currentUser.phone_numbers.some(phone => phone.phone_number === number)) {
return HttpResponse.json(
{
errors: [
{
code: 'form_identifier_exists',
message: 'This phone number already exists.',
long_message: 'This phone number already exists.',
meta: { param_name: 'phone_number' },
},
],
},
{ status: 422 },
);
}
const existingIds = new Set(currentUser.phone_numbers.map(phone => phone.id));
let nextPhoneNumber = state.mfa.phoneCreations.length;
while (existingIds.has(`phone_${nextPhoneNumber}`)) {
nextPhoneNumber += 1;
}
const phone = fapiPhoneNumber({
id: `phone_${nextPhoneNumber}`,
phone_number: number,
verification: fapiVerification('phone_code'),
});
updateUser(state, { ...currentUser, phone_numbers: [...currentUser.phone_numbers, phone] });
return envelope(phone, state.client);
}),
http.post(fapiUrl('/v1/me/phone_numbers/:id/prepare_verification'), ({ params, request }) => {
const currentUser = requestUser(state, request);
if (!currentUser) {
return missing();
}
const id = String(params.id);
state.mfa.phonePreparations.push(id);
const phone = updatePhone(currentUser, id, current => ({
...current,
verification: fapiVerification('phone_code'),
}));
return phone ? envelope(phone, state.client) : missing();
}),
http.post(fapiUrl('/v1/me/phone_numbers/:id/attempt_verification'), async ({ params, request }) => {
const currentUser = requestUser(state, request);
if (!currentUser) {
return missing();
}
const id = String(params.id);
const code = new URLSearchParams(await request.text()).get('code') ?? '';
state.mfa.phoneAttempts.push({ id, code });
if (code !== VERIFICATION_CODE) {
return error('form_code_incorrect', 'Incorrect code');
}
const phone = updatePhone(currentUser, id, current => ({
...current,
verification: fapiVerification('phone_code', { status: 'verified' }),
}));
return phone ? envelope(phone, state.client) : missing();
}),
http.post(fapiUrl('/v1/me/phone_numbers/:id'), async ({ params, request }) => {
if (new URL(request.url).searchParams.get('_method') !== 'PATCH') {
return undefined;
}
const id = String(params.id);
const body = new URLSearchParams(await request.text());
const reserved = body.has('reserved_for_second_factor')
? body.get('reserved_for_second_factor') === 'true'
: undefined;
const isDefault = body.has('default_second_factor') ? body.get('default_second_factor') === 'true' : undefined;
state.mfa.phoneUpdates.push({ id, reserved, default: isDefault });
const user = requestUser(state, request);
const existingPhone = user?.phone_numbers.find(phone => phone.id === id);
if (!user || !existingPhone) {
return missing();
}
if (reserved === true && existingPhone.verification?.status !== 'verified') {
return error(
'identification_update_second_factor_unverified',
'Cannot update second factor attributes for unverified identification',
);
}
if (reserved === false && state.environment.user_settings.sign_up.mfa?.required && usableFactors(user) <= 1) {
return error('second_factor_deletion_not_allowed', 'This method cannot be removed.');
}
const firstReservedPhone = !user.phone_numbers.some(item => item.reserved_for_second_factor);
const newCodes = reserved && backupEnabled() && !user.backup_code_enabled;
const phone = {
...existingPhone,
reserved_for_second_factor: reserved ?? existingPhone.reserved_for_second_factor,
default_second_factor:
isDefault ??
(reserved && firstReservedPhone ? true : reserved === false ? false : existingPhone.default_second_factor),
backup_codes: newCodes ? state.mfa.codes : undefined,
};
let phones = user.phone_numbers.map(item => {
if (item.id === id) {
return phone;
}
return isDefault ? { ...item, default_second_factor: false } : item;
});
if (reserved === false && existingPhone.default_second_factor) {
const next = phones.find(item => item.reserved_for_second_factor);
phones = phones.map(item => (item.id === next?.id ? { ...item, default_second_factor: true } : item));
}
const hasFactor = user.totp_enabled || phones.some(item => item.reserved_for_second_factor);
updateUser(state, {
...user,
phone_numbers: phones,
two_factor_enabled: reserved === undefined ? user.two_factor_enabled : hasFactor,
backup_code_enabled:
reserved === undefined
? user.backup_code_enabled
: hasFactor && (user.backup_code_enabled || Boolean(newCodes)),
});
return envelope(phone, state.client);
}),
];
}
2 changes: 2 additions & 0 deletions packages/mosaic/src/__tests__/feature/fake-fapi/shared.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { ClientJSON, SessionJSON, UserJSON } from '@clerk/shared/types';
import { HttpResponse, type JsonBodyType } from 'msw';

export const VERIFICATION_CODE = '424242';

type ClientState = { client: ClientJSON };

export function envelope(response: JsonBodyType, client: ClientJSON | null) {
Expand Down
Loading
Loading