diff --git a/packages/core/src/routes/experience/classes/helpers.test.ts b/packages/core/src/routes/experience/classes/helpers.test.ts index ce796bb6552b..e3451de1b815 100644 --- a/packages/core/src/routes/experience/classes/helpers.test.ts +++ b/packages/core/src/routes/experience/classes/helpers.test.ts @@ -7,7 +7,7 @@ import { mockUserWebAuthnMfaVerification, } from '#src/__mocks__/user.js'; -import { getAllUserEnabledMfaVerifications } from './helpers.js'; +import { getAllUserEnabledMfaVerifications, sortMfaFactors } from './helpers.js'; describe('getAllUserEnabledMfaVerifications', () => { it('puts WebAuthn first when available', () => { @@ -41,3 +41,23 @@ describe('getAllUserEnabledMfaVerifications', () => { expect(result).toEqual([MfaFactor.WebAuthn, MfaFactor.TOTP, MfaFactor.BackupCode]); }); }); + +describe('sortMfaFactors', () => { + it('sorts factors by priority order', () => { + expect( + sortMfaFactors([ + MfaFactor.EmailVerificationCode, + MfaFactor.BackupCode, + MfaFactor.TOTP, + MfaFactor.WebAuthn, + MfaFactor.PhoneVerificationCode, + ]) + ).toEqual([ + MfaFactor.WebAuthn, + MfaFactor.TOTP, + MfaFactor.PhoneVerificationCode, + MfaFactor.EmailVerificationCode, + MfaFactor.BackupCode, + ]); + }); +}); diff --git a/packages/core/src/routes/experience/classes/helpers.ts b/packages/core/src/routes/experience/classes/helpers.ts index 278ed51743c1..f0424f3092c3 100644 --- a/packages/core/src/routes/experience/classes/helpers.ts +++ b/packages/core/src/routes/experience/classes/helpers.ts @@ -61,6 +61,29 @@ export const getNewUserProfileFromVerificationRecord = async ( } }; +/** + * Sort MFA factors by display priority to keep client experience consistent. + * Order: WebAuthn -> TOTP -> Phone -> Email -> Backup code -> others. + */ +export const sortMfaFactors = (factors: MfaFactor[]): MfaFactor[] => { + const order: MfaFactor[] = [ + MfaFactor.WebAuthn, + MfaFactor.TOTP, + MfaFactor.PhoneVerificationCode, + MfaFactor.EmailVerificationCode, + MfaFactor.BackupCode, + ]; + + return factors.slice().sort((factorA, factorB) => { + const indexA = order.indexOf(factorA); + const indexB = order.indexOf(factorB); + const normalizedIndexA = indexA === -1 ? order.length : indexA; + const normalizedIndexB = indexB === -1 ? order.length : indexB; + + return normalizedIndexA - normalizedIndexB; + }); +}; + /** * @throws {RequestError} -400 if the verification record type is not supported for user identification. * @throws {RequestError} -400 if the verification record is not verified. diff --git a/packages/core/src/routes/experience/classes/mfa.ts b/packages/core/src/routes/experience/classes/mfa.ts index 0ccf8ee0fd3e..23c18e7f28c2 100644 --- a/packages/core/src/routes/experience/classes/mfa.ts +++ b/packages/core/src/routes/experience/classes/mfa.ts @@ -30,7 +30,7 @@ import assertThat from '#src/utils/assert-that.js'; import { type InteractionContext } from '../types.js'; -import { getAllUserEnabledMfaVerifications } from './helpers.js'; +import { getAllUserEnabledMfaVerifications, sortMfaFactors } from './helpers.js'; import { SignInExperienceValidator } from './libraries/sign-in-experience-validator.js'; export type MfaData = { @@ -335,22 +335,7 @@ export class Mfa { return; } - const sortedFactors = availableFactors.slice().sort((factorA, factorB) => { - // Sort order: webauthn -> totp -> sms -> email -> backup code - const order = [ - MfaFactor.WebAuthn, - MfaFactor.TOTP, - MfaFactor.PhoneVerificationCode, - MfaFactor.EmailVerificationCode, - MfaFactor.BackupCode, - ]; - - const indexA = order.indexOf(factorA); - const indexB = order.indexOf(factorB); - - // Unrecognized factors at the end - return (indexA === -1 ? order.length : indexA) - (indexB === -1 ? order.length : indexB); - }); + const sortedFactors = sortMfaFactors(availableFactors); const additionalFactors = sortedFactors.filter((factor) => !factorsInUser.includes(factor)); @@ -441,7 +426,9 @@ export class Mfa { return; } - const availableFactors = factors.filter((factor) => factor !== MfaFactor.BackupCode); + const availableFactors = sortMfaFactors( + factors.filter((factor) => factor !== MfaFactor.BackupCode) + ); const factorsInUser = await this.getUserMfaFactors(); const factorsInBind = this.bindMfaFactorsArray.map(({ type }) => type);