Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions .changeset/cold-clowns-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/localizations': minor
'@clerk/clerk-js': minor
'@clerk/nextjs': minor
'@clerk/clerk-react': minor
'@clerk/shared': minor
---

Introduces MFA setup session task for handling require MFA after sign-in and sign-up
7 changes: 7 additions & 0 deletions integration/presets/envs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ const withSessionTasksResetPassword = base
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-reset-password').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-reset-password').pk);

const withSessionTasksSetupMfa = base
.clone()
.setId('withSessionTasksSetupMfa')
.setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-session-tasks-setup-mfa').sk)
.setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-session-tasks-setup-mfa').pk);

const withBillingJwtV2 = base
.clone()
.setId('withBillingJwtV2')
Expand Down Expand Up @@ -210,6 +216,7 @@ export const envs = {
withReverification,
withSessionTasks,
withSessionTasksResetPassword,
withSessionTasksSetupMfa,
withSignInOrUpEmailLinksFlow,
withSignInOrUpFlow,
withSignInOrUpwithRestrictedModeFlow,
Expand Down
1 change: 1 addition & 0 deletions integration/presets/longRunningApps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const createLongRunningApps = () => {
{ id: 'next.appRouter.withSignInOrUpEmailLinksFlow', config: next.appRouter, env: envs.withSignInOrUpEmailLinksFlow },
{ id: 'next.appRouter.withSessionTasks', config: next.appRouter, env: envs.withSessionTasks },
{ id: 'next.appRouter.withSessionTasksResetPassword', config: next.appRouter, env: envs.withSessionTasksResetPassword },
{ id: 'next.appRouter.withSessionTasksSetupMfa', config: next.appRouter, env: envs.withSessionTasksSetupMfa },
{ id: 'next.appRouter.withLegalConsent', config: next.appRouter, env: envs.withLegalConsent },

/**
Expand Down
207 changes: 207 additions & 0 deletions integration/tests/session-tasks-setup-mfa.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../presets';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';
import { stringPhoneNumber } from '../testUtils/phoneUtils';
import { fakerPhoneNumber } from '../testUtils/usersService';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withSessionTasksSetupMfa] })(
'session tasks setup-mfa flow @nextjs',
({ app }) => {
test.describe.configure({ mode: 'parallel' });

test.afterAll(async () => {
await app.teardown();
});

test.afterEach(async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.signOut();
await u.page.context().clearCookies();
});

test('setup MFA with new phone number - happy path', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

const testPhoneNumber = fakerPhoneNumber();
await u.po.signIn.getPhoneNumberInput().fill(testPhoneNumber);
await u.page.getByRole('button', { name: /continue/i }).click();

await u.po.signIn.enterTestOtpCode();

await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 });

await u.po.signIn.continue();

await u.page.waitForAppUrl('/page-protected');
await u.po.expect.toBeSignedIn();

await user.deleteIfExists();
});

test('setup MFA with existing phone number - happy path', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

const formattedPhoneNumber = stringPhoneNumber(user.phoneNumber);
await u.page
.getByRole('button', {
name: formattedPhoneNumber,
})
.click();

await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 });

await u.po.signIn.continue();

await u.page.waitForAppUrl('/page-protected');
await u.po.expect.toBeSignedIn();

await user.deleteIfExists();
});

test('setup MFA with invalid phone number - error handling', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

const invalidPhoneNumber = '123091293193091311';
await u.po.signIn.getPhoneNumberInput().fill(invalidPhoneNumber);
await u.po.signIn.continue();
// we need to improve this error message
await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();

const validPhoneNumber = fakerPhoneNumber();
await u.po.signIn.getPhoneNumberInput().fill(validPhoneNumber);
await u.po.signIn.continue();

await u.po.signIn.enterTestOtpCode();

await u.page.getByText(/save these backup codes/i).waitFor({ state: 'visible', timeout: 10000 });

await u.po.signIn.continue();

await u.page.waitForAppUrl('/page-protected');
await u.po.expect.toBeSignedIn();

await user.deleteIfExists();
});

test('setup MFA with invalid verification code - error handling', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

const testPhoneNumber = fakerPhoneNumber();
await u.po.signIn.getPhoneNumberInput().fill(testPhoneNumber);
await u.po.signIn.continue();

await u.po.signIn.enterOtpCode('111111', {
awaitPrepare: true,
awaitAttempt: true,
});

await expect(u.page.getByTestId('form-feedback-error')).toBeVisible();

await user.deleteIfExists();
});

test('can navigate back during MFA setup', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
const user = u.services.users.createFakeUser({
fictionalEmail: true,
withPhoneNumber: true,
withPassword: true,
});
await u.services.users.createBapiUser(user);

await u.po.signIn.goTo();
await u.po.signIn.waitForMounted();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: user.email, password: user.password });
await u.po.expect.toBeSignedIn();

await u.page.goToRelative('/page-protected');

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });

await u.page.getByRole('button', { name: /sms code/i }).click();

const formattedPhoneNumber = stringPhoneNumber(user.phoneNumber);
await u.page
.getByRole('button', {
name: formattedPhoneNumber,
})
.waitFor({ state: 'visible' });

await u.page
.getByRole('button', { name: /cancel/i })
.first()
.click();

await u.page.getByText(/set up two-step verification/i).waitFor({ state: 'visible' });
await u.page.getByRole('button', { name: /sms code/i }).waitFor({ state: 'visible' });

await user.deleteIfExists();
});
},
);
2 changes: 1 addition & 1 deletion packages/clerk-js/bundlewatch.config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"files": [
{ "path": "./dist/clerk.js", "maxSize": "928KB" },
{ "path": "./dist/clerk.js", "maxSize": "929KB" },
{ "path": "./dist/clerk.browser.js", "maxSize": "87KB" },
{ "path": "./dist/clerk.legacy.browser.js", "maxSize": "129KB" },
{ "path": "./dist/clerk.headless*.js", "maxSize": "66KB" },
Expand Down
21 changes: 21 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import type {
SignUpResource,
TaskChooseOrganizationProps,
TaskResetPasswordProps,
TaskSetupMFAProps,
TasksRedirectOptions,
UnsubscribeCallback,
UserAvatarProps,
Expand Down Expand Up @@ -1447,6 +1448,26 @@ export class Clerk implements ClerkInterface {
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
};

public mountTaskSetupMfa = (node: HTMLDivElement, props?: TaskSetupMFAProps) => {
this.assertComponentsReady(this.#componentControls);

void this.#componentControls.ensureMounted({ preloadHint: 'TaskSetupMFA' }).then(controls =>
controls.mountComponent({
name: 'TaskSetupMFA',
appearanceKey: 'taskSetupMfa',
node,
props,
}),
);

this.telemetry?.record(eventPrebuiltComponentMounted('TaskSetupMfa', props));
};

public unmountTaskSetupMfa = (node: HTMLDivElement) => {
this.assertComponentsReady(this.#componentControls);
void this.#componentControls.ensureMounted().then(controls => controls.unmountComponent({ node }));
};

/**
* `setActive` can be used to set the active session and/or organization.
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/clerk-js/src/core/resources/UserSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export class UserSettings extends BaseResource implements UserSettingsResource {
legal_consent_enabled: false,
mode: 'public',
progressive: true,
mfa: {
required: false,
},
};
social: OAuthProviders = {} as OAuthProviders;
usernameSettings: UsernameSettingsData = {} as UsernameSettingsData;
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/sessionTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { buildURL, forwardClerkQueryParams } from '../utils';
export const INTERNAL_SESSION_TASK_ROUTE_BY_KEY: Record<SessionTask['key'], string> = {
'choose-organization': 'choose-organization',
'reset-password': 'reset-password',
'setup-mfa': 'setup-mfa',
} as const;

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/clerk-js/src/test/fixture-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,10 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => {
us.sign_up.mode = SIGN_UP_MODES.WAITLIST;
};

const withMfaRequired = (required: boolean = true) => {
us.sign_up.mfa = { required };
};

// TODO: Add the rest, consult pkg/generate/auth_config.go

return {
Expand All @@ -606,5 +610,6 @@ const createUserSettingsFixtureHelpers = (environment: EnvironmentJSON) => {
withRestrictedMode,
withLegalConsent,
withWaitlistMode,
withMfaRequired,
};
};
3 changes: 3 additions & 0 deletions packages/clerk-js/src/test/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ const createBaseUserSettings = (): UserSettingsJSON => {
captcha_enabled: false,
disable_hibp: false,
mode: 'public',
mfa: {
required: false,
},
},
restrictions: {
allowlist: {
Expand Down
7 changes: 6 additions & 1 deletion packages/clerk-js/src/ui/common/Wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Animated } from '../elements/Animated';

type WizardProps = React.PropsWithChildren<{
step: number;
animate?: boolean;
}>;

type UseWizardProps = {
Expand All @@ -26,7 +27,11 @@ export const useWizard = (params: UseWizardProps = {}) => {
};

export const Wizard = (props: WizardProps) => {
const { step, children } = props;
const { step, children, animate = true } = props;

if (!animate) {
return React.Children.toArray(children)[step];
}

return <Animated>{React.Children.toArray(children)[step]}</Animated>;
};
Loading