diff --git a/packages/experience/src/hooks/use-mfa-error-handler.ts b/packages/experience/src/hooks/use-mfa-error-handler.ts index 39ead48e5..729839bbb 100644 --- a/packages/experience/src/hooks/use-mfa-error-handler.ts +++ b/packages/experience/src/hooks/use-mfa-error-handler.ts @@ -28,6 +28,19 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { (flow: UserMfaFlow, state: MfaFlowState) => { const { availableFactors } = state; + if (flow === UserMfaFlow.MfaVerification) { + // In MFA verification flow, the user will be redirected to the last used factor which is the first one in the array. + const factor = availableFactors[0]; + + if (!factor) { + return; + } + + navigate({ pathname: `/${flow}/${factor}` }, { replace, state }); + return; + } + + // Binding flow if (availableFactors.length > 1) { navigate({ pathname: `/${flow}` }, { replace, state }); return; @@ -39,7 +52,7 @@ const useMfaErrorHandler = ({ replace }: Options = {}) => { return; } - if (factor === MfaFactor.TOTP && flow === UserMfaFlow.MfaBinding) { + if (factor === MfaFactor.TOTP) { void startTotpBinding(state); return; } diff --git a/packages/integration-tests/src/tests/experience/mfa/backup-code/index.test.ts b/packages/integration-tests/src/tests/experience/mfa/backup-code/index.test.ts index 23ec5ab5d..55ac63ae7 100644 --- a/packages/integration-tests/src/tests/experience/mfa/backup-code/index.test.ts +++ b/packages/integration-tests/src/tests/experience/mfa/backup-code/index.test.ts @@ -9,8 +9,8 @@ import { enableMandatoryMfaWithWebAuthnAndBackupCode, resetMfaSettings, } from '#src/helpers/sign-in-experience.js'; -import ExpectBackupCodeExperience from '#src/ui-helpers/expect-backup-code-experience.js'; -import { generateUsername, waitFor } from '#src/utils.js'; +import ExpectWebAuthnExperience from '#src/ui-helpers/expect-webauthn-experience.js'; +import { generateUsername } from '#src/utils.js'; describe('MFA - Backup Code', () => { beforeAll(async () => { @@ -43,7 +43,7 @@ describe('MFA - Backup Code', () => { const password = 'l0gt0_T3st_P@ssw0rd'; it('should bind backup codes when registering and verify backup codes when signing in', async () => { - const experience = new ExpectBackupCodeExperience(await browser.newPage()); + const experience = new ExpectWebAuthnExperience(await browser.newPage()); await experience.setupVirtualAuthenticator(); await experience.startWith(demoAppUrl, 'register'); await experience.toFillInput('identifier', username, { submit: true }); @@ -68,9 +68,15 @@ describe('MFA - Backup Code', () => { }, { submit: true } ); - // Wait for the page to process submitting request. - await waitFor(500); + + await experience.page.waitForNetworkIdle(); + // Should navigate to the WebAuthn verification page + experience.toBeAt('mfa-verification/WebAuthn'); + // Nav back to the list page + await experience.toClickSwitchFactorsLink({ isBinding: false }); experience.toBeAt('mfa-verification'); + + // Select backup code await experience.toClick('button', 'Backup code'); experience.toBeAt('mfa-verification/BackupCode'); await experience.toFillInput('code', backupCodes.at(0) ?? '', { submit: true }); diff --git a/packages/integration-tests/src/tests/experience/mfa/factor-switching.test.ts b/packages/integration-tests/src/tests/experience/mfa/multi-factors.test.ts similarity index 63% rename from packages/integration-tests/src/tests/experience/mfa/factor-switching.test.ts rename to packages/integration-tests/src/tests/experience/mfa/multi-factors.test.ts index 0cbe6eaf2..8c4f3cc61 100644 --- a/packages/integration-tests/src/tests/experience/mfa/factor-switching.test.ts +++ b/packages/integration-tests/src/tests/experience/mfa/multi-factors.test.ts @@ -7,10 +7,10 @@ import { demoAppUrl } from '#src/constants.js'; import { clearConnectorsByTypes, setSocialConnector } from '#src/helpers/connector.js'; import { resetMfaSettings } from '#src/helpers/sign-in-experience.js'; import { generateNewUser } from '#src/helpers/user.js'; -import ExpectTotpExperience from '#src/ui-helpers/expect-totp-experience.js'; +import ExpectWebAuthnExperience from '#src/ui-helpers/expect-webauthn-experience.js'; import { waitFor } from '#src/utils.js'; -describe('MFA - Factor switching', () => { +describe('MFA - Multi factors', () => { beforeAll(async () => { await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms, ConnectorType.Social]); await setSocialConnector(); @@ -41,12 +41,12 @@ describe('MFA - Factor switching', () => { await resetMfaSettings(); }); - it('should be able to switch between different factors', async () => { + it('should be able to complete MFA flow with multi factors', async () => { const { userProfile, user } = await generateNewUser({ username: true, password: true }); - const experience = new ExpectTotpExperience(await browser.newPage()); + const experience = new ExpectWebAuthnExperience(await browser.newPage()); await experience.startWith(demoAppUrl, 'sign-in'); - + await experience.setupVirtualAuthenticator(); await experience.toFillForm( { identifier: userProfile.username, @@ -65,22 +65,48 @@ describe('MFA - Factor switching', () => { experience.toBeAt('mfa-binding/Totp'); // Navigate back - await experience.toClick('a[class*=switchLink'); + await experience.toClickSwitchFactorsLink({ isBinding: true }); experience.toBeAt('mfa-binding'); await expect(experience.page).toMatchElement('div[class$=title]', { text: 'Add 2-step authentication', }); - // Select WebAuthn + // Switch to WebAuthn await experience.toClick('button div[class$=name]', 'Passkey'); experience.toBeAt('mfa-binding/WebAuthn'); - await experience.toClick('a[class*=switchLink'); + await experience.toClickSwitchFactorsLink({ isBinding: true }); experience.toBeAt('mfa-binding'); await expect(experience.page).toMatchElement('div[class$=title]', { text: 'Add 2-step authentication', }); - await experience.page.close(); + // Bind WebAuthn + await experience.toClick('button div[class$=name]', 'Passkey'); + // Wait the WebAuthn to be prepared + await experience.page.waitForNetworkIdle(); + experience.toBeAt('mfa-binding/WebAuthn'); + await experience.toCreatePasskey(); + + // Backup codes page + await experience.toClick('button', 'Continue'); + await experience.verifyThenEnd(false); + + // Sign in with latest used factor + await experience.startWith(demoAppUrl, 'sign-in'); + await experience.toFillForm( + { + identifier: userProfile.username, + password: userProfile.password, + }, + { submit: true } + ); + + await experience.page.waitForNetworkIdle(); + experience.toBeAt('mfa-verification/WebAuthn'); + await experience.toVerifyViaPasskey(); + + await experience.clearVirtualAuthenticator(); + await experience.verifyThenEnd(); await deleteUser(user.id); }); }); diff --git a/packages/integration-tests/src/ui-helpers/expect-backup-code-experience.ts b/packages/integration-tests/src/ui-helpers/expect-mfa-experience.ts similarity index 68% rename from packages/integration-tests/src/ui-helpers/expect-backup-code-experience.ts rename to packages/integration-tests/src/ui-helpers/expect-mfa-experience.ts index 85843d452..20d8ed32e 100644 --- a/packages/integration-tests/src/ui-helpers/expect-backup-code-experience.ts +++ b/packages/integration-tests/src/ui-helpers/expect-mfa-experience.ts @@ -1,10 +1,11 @@ import { cls } from '#src/utils.js'; -import ExpectWebAuthnExperience from './expect-webauthn-experience.js'; +import ExpectExperience from './expect-experience.js'; + /** * Note: The backup code tests are based on the WebAuthn experience flow since the backup code factor cannot be enabled alone. */ -export default class ExpectBackupCodeExperience extends ExpectWebAuthnExperience { +export default class ExpectMfaExperience extends ExpectExperience { constructor(thePage = global.page) { super(thePage); } @@ -22,4 +23,11 @@ export default class ExpectBackupCodeExperience extends ExpectWebAuthnExperience }) ); } + + async toClickSwitchFactorsLink({ isBinding }: { isBinding: boolean }) { + await this.toClick( + 'a', + isBinding ? 'Link another 2-step authentication' : 'Try another method to verify' + ); + } } diff --git a/packages/integration-tests/src/ui-helpers/expect-totp-experience.ts b/packages/integration-tests/src/ui-helpers/expect-totp-experience.ts index 7df91c732..95b8b64c3 100644 --- a/packages/integration-tests/src/ui-helpers/expect-totp-experience.ts +++ b/packages/integration-tests/src/ui-helpers/expect-totp-experience.ts @@ -2,9 +2,9 @@ import { authenticator } from 'otplib'; import { waitFor, dcls } from '#src/utils.js'; -import ExpectExperience from './expect-experience.js'; +import ExpectMfaExperience from './expect-mfa-experience.js'; -export default class ExpectTotpExperience extends ExpectExperience { +export default class ExpectTotpExperience extends ExpectMfaExperience { constructor(thePage = global.page) { super(thePage); } diff --git a/packages/integration-tests/src/ui-helpers/expect-webauthn-experience.ts b/packages/integration-tests/src/ui-helpers/expect-webauthn-experience.ts index dbf10d5cd..b0fac905b 100644 --- a/packages/integration-tests/src/ui-helpers/expect-webauthn-experience.ts +++ b/packages/integration-tests/src/ui-helpers/expect-webauthn-experience.ts @@ -1,8 +1,8 @@ import { type CDPSession } from 'puppeteer'; -import ExpectExperience from './expect-experience.js'; +import ExpectMfaExperience from './expect-mfa-experience.js'; -export default class ExpectWebAuthnExperience extends ExpectExperience { +export default class ExpectWebAuthnExperience extends ExpectMfaExperience { private authenticatorId?: string; private _cdpClient?: CDPSession;