Skip to content

Commit 75982ee

Browse files
authored
fix(core): handle emailOrPhone for MFA suggestion (#7980)
1 parent 621fa19 commit 75982ee

File tree

2 files changed

+87
-3
lines changed

2 files changed

+87
-3
lines changed

packages/core/src/routes/experience/classes/mfa.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
OrganizationRequiredMfaPolicy,
1818
MfaFactor,
1919
SignInIdentifier,
20+
AlternativeSignUpIdentifier,
2021
} from '@logto/schemas';
2122
import { generateStandardId, maskEmail, maskPhone } from '@logto/shared';
2223
import { cond, condObject, deduplicate, pick } from '@silverhand/essentials';
@@ -322,15 +323,23 @@ export class Mfa {
322323
if (
323324
factorsInUser.includes(MfaFactor.EmailVerificationCode) &&
324325
!signUp.identifiers.includes(SignInIdentifier.Email) &&
325-
!signUp.secondaryIdentifiers?.some(({ identifier }) => identifier === SignInIdentifier.Email)
326+
!signUp.secondaryIdentifiers?.some(
327+
({ identifier }) =>
328+
identifier === SignInIdentifier.Email ||
329+
identifier === AlternativeSignUpIdentifier.EmailOrPhone
330+
)
326331
) {
327332
return;
328333
}
329334
// If the user has phone, but not registered by phone, no suggestion
330335
if (
331336
factorsInUser.includes(MfaFactor.PhoneVerificationCode) &&
332337
!signUp.identifiers.includes(SignInIdentifier.Phone) &&
333-
!signUp.secondaryIdentifiers?.some(({ identifier }) => identifier === SignInIdentifier.Phone)
338+
!signUp.secondaryIdentifiers?.some(
339+
({ identifier }) =>
340+
identifier === SignInIdentifier.Phone ||
341+
identifier === AlternativeSignUpIdentifier.EmailOrPhone
342+
)
334343
) {
335344
return;
336345
}

packages/integration-tests/src/tests/api/experience-api/bind-mfa/mfa-suggestion.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { ConnectorType } from '@logto/connector-kit';
2-
import { InteractionEvent, MfaFactor, MfaPolicy, SignInIdentifier } from '@logto/schemas';
2+
import {
3+
AlternativeSignUpIdentifier,
4+
InteractionEvent,
5+
MfaFactor,
6+
MfaPolicy,
7+
SignInIdentifier,
8+
} from '@logto/schemas';
39
import { authenticator } from 'otplib';
410

511
import { deleteUser } from '#src/api/admin-user.js';
@@ -214,6 +220,75 @@ describe('Register interaction - optional additional MFA suggestion', () => {
214220
await deleteUser(userId);
215221
});
216222

223+
it('should suggest additional MFA when email or phone is required as a secondary identifier', async () => {
224+
await updateSignInExperience({
225+
signUp: {
226+
identifiers: [SignInIdentifier.Username],
227+
password: true,
228+
verify: true,
229+
secondaryIdentifiers: [
230+
{
231+
identifier: AlternativeSignUpIdentifier.EmailOrPhone,
232+
verify: true,
233+
},
234+
],
235+
},
236+
signIn: {
237+
methods: [
238+
{
239+
identifier: SignInIdentifier.Username,
240+
password: true,
241+
verificationCode: false,
242+
isPasswordPrimary: false,
243+
},
244+
],
245+
},
246+
mfa: {
247+
factors: [MfaFactor.EmailVerificationCode, MfaFactor.TOTP],
248+
policy: MfaPolicy.Mandatory,
249+
},
250+
});
251+
252+
const { username, password, primaryEmail } = generateNewUserProfile({
253+
username: true,
254+
password: true,
255+
primaryEmail: true,
256+
});
257+
258+
const client = await initExperienceClient({ interactionEvent: InteractionEvent.Register });
259+
260+
await client.updateProfile({ type: SignInIdentifier.Username, value: username });
261+
await client.updateProfile({ type: 'password', value: password });
262+
263+
await fulfillUserEmail(client, primaryEmail);
264+
265+
await client.identifyUser();
266+
267+
await expectRejects<{
268+
availableFactors: MfaFactor[];
269+
skippable: boolean;
270+
maskedIdentifiers?: Record<string, string>;
271+
suggestion?: boolean;
272+
}>(client.submitInteraction(), {
273+
code: 'session.mfa.suggest_additional_mfa',
274+
status: 422,
275+
expectData: (data) => {
276+
expect(data.availableFactors).toEqual([MfaFactor.TOTP, MfaFactor.EmailVerificationCode]);
277+
expect(data.maskedIdentifiers).toBeDefined();
278+
expect(data.maskedIdentifiers?.[MfaFactor.EmailVerificationCode]).toMatch(/\*{4}/);
279+
expect(data.skippable).toBe(true);
280+
expect(data.suggestion).toBe(true);
281+
},
282+
});
283+
284+
await client.skipMfaSuggestion();
285+
286+
const { redirectTo } = await client.submitInteraction();
287+
const userId = await processSession(client, redirectTo);
288+
await logoutClient(client);
289+
await deleteUser(userId);
290+
});
291+
217292
it('should not suggest MFA after fulfilling phone verification when both email and SMS factors are enabled', async () => {
218293
// Configure MFA with email, phone, and TOTP factors
219294
await updateSignInExperience({

0 commit comments

Comments
 (0)