Skip to content

Commit 23467d9

Browse files
authored
fix(core): skip MFA suggestion when user has factors from non-reg (#7782)
fix(core): skip MFA suggestion when user has factors from non-registration
1 parent b65859d commit 23467d9

File tree

2 files changed

+124
-5
lines changed

2 files changed

+124
-5
lines changed

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
type Mfa as MfaSettings,
1717
OrganizationRequiredMfaPolicy,
1818
MfaFactor,
19+
SignInIdentifier,
1920
} from '@logto/schemas';
2021
import { generateStandardId, maskEmail, maskPhone } from '@logto/shared';
2122
import { cond, condObject, deduplicate, pick } from '@silverhand/essentials';
@@ -298,6 +299,7 @@ export class Mfa {
298299
* when conditions are met.
299300
* The purpose is to suggest another MFA factor if the user has only one Email or Phone factor.
300301
*/
302+
// eslint-disable-next-line complexity
301303
async guardAdditionalBindingSuggestion(
302304
factorsInUser: MfaFactor[],
303305
availableFactors: MfaFactor[]
@@ -315,6 +317,22 @@ export class Mfa {
315317
return;
316318
}
317319

320+
const { signUp } = await this.signInExperienceValidator.getSignInExperienceData();
321+
// If the user has email, but not registered by email, no suggestion
322+
if (
323+
factorsInUser.includes(MfaFactor.EmailVerificationCode) &&
324+
!signUp.identifiers.includes(SignInIdentifier.Email)
325+
) {
326+
return;
327+
}
328+
// If the user has phone, but not registered by phone, no suggestion
329+
if (
330+
factorsInUser.includes(MfaFactor.PhoneVerificationCode) &&
331+
!signUp.identifiers.includes(SignInIdentifier.Phone)
332+
) {
333+
return;
334+
}
335+
318336
const additionalFactors = availableFactors
319337
.filter((factor) => !factorsInUser.includes(factor))
320338
.slice()

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

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,28 @@ import { ConnectorType } from '@logto/connector-kit';
22
import { InteractionEvent, MfaFactor, MfaPolicy, SignInIdentifier } from '@logto/schemas';
33
import { authenticator } from 'otplib';
44

5+
import { deleteUser } from '#src/api/admin-user.js';
56
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
67
import { initExperienceClient, logoutClient, processSession } from '#src/helpers/client.js';
7-
import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js';
8+
import {
9+
clearConnectorsByTypes,
10+
setEmailConnector,
11+
setSmsConnector,
12+
} from '#src/helpers/connector.js';
813
import {
914
successfullySendVerificationCode,
1015
successfullyVerifyVerificationCode,
1116
} from '#src/helpers/experience/verification-code.js';
1217
import { expectRejects } from '#src/helpers/index.js';
1318
import { resetMfaSettings } from '#src/helpers/sign-in-experience.js';
1419
import { generateNewUserProfile } from '#src/helpers/user.js';
20+
import { generatePhone } from '#src/utils.js';
1521

1622
describe('Register interaction - optional additional MFA suggestion', () => {
1723
beforeAll(async () => {
18-
await clearConnectorsByTypes([ConnectorType.Email]);
24+
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
1925
await setEmailConnector();
26+
await setSmsConnector();
2027
// Set up sign-in experience upfront (refer to email-with-signup.test.ts pattern)
2128
await updateSignInExperience({
2229
signUp: {
@@ -42,7 +49,7 @@ describe('Register interaction - optional additional MFA suggestion', () => {
4249
});
4350

4451
afterAll(async () => {
45-
await clearConnectorsByTypes([ConnectorType.Email]);
52+
await clearConnectorsByTypes([ConnectorType.Email, ConnectorType.Sms]);
4653
await resetMfaSettings();
4754
});
4855

@@ -91,8 +98,9 @@ describe('Register interaction - optional additional MFA suggestion', () => {
9198

9299
// Submit again should succeed
93100
const { redirectTo } = await client.submitInteraction();
94-
await processSession(client, redirectTo);
101+
const userId = await processSession(client, redirectTo);
95102
await logoutClient(client);
103+
await deleteUser(userId);
96104
});
97105

98106
it('should allow binding TOTP instead of skipping and then complete', async () => {
@@ -128,7 +136,100 @@ describe('Register interaction - optional additional MFA suggestion', () => {
128136

129137
// Now submit should succeed
130138
const { redirectTo } = await client.submitInteraction();
131-
await processSession(client, redirectTo);
139+
const userId = await processSession(client, redirectTo);
140+
await logoutClient(client);
141+
await deleteUser(userId);
142+
});
143+
144+
it('should not suggest MFA after fulfilling phone verification when both email and SMS factors are enabled', async () => {
145+
// Configure MFA with email, phone, and TOTP factors
146+
await updateSignInExperience({
147+
signUp: {
148+
identifiers: [SignInIdentifier.Email],
149+
password: true,
150+
verify: true,
151+
},
152+
signIn: {
153+
methods: [
154+
{
155+
identifier: SignInIdentifier.Email,
156+
password: true,
157+
verificationCode: false,
158+
isPasswordPrimary: false,
159+
},
160+
],
161+
},
162+
mfa: {
163+
factors: [MfaFactor.EmailVerificationCode, MfaFactor.PhoneVerificationCode, MfaFactor.TOTP],
164+
policy: MfaPolicy.Mandatory,
165+
},
166+
});
167+
168+
const { primaryEmail, password } = generateNewUserProfile({
169+
primaryEmail: true,
170+
password: true,
171+
});
172+
const phoneNumber = generatePhone();
173+
const client = await initExperienceClient({ interactionEvent: InteractionEvent.Register });
174+
175+
// Register with email
176+
const { verificationId, code } = await successfullySendVerificationCode(client, {
177+
identifier: { type: SignInIdentifier.Email, value: primaryEmail },
178+
interactionEvent: InteractionEvent.Register,
179+
});
180+
181+
await successfullyVerifyVerificationCode(client, {
182+
identifier: { type: SignInIdentifier.Email, value: primaryEmail },
183+
verificationId,
184+
code,
185+
});
186+
187+
// Fulfill required password before identifying the user
188+
await client.updateProfile({ type: 'password', value: password });
189+
await client.identifyUser({ verificationId });
190+
191+
// Submit should trigger MFA suggestion
192+
await expectRejects<{
193+
availableFactors: MfaFactor[];
194+
skippable: boolean;
195+
maskedIdentifiers?: Record<string, string>;
196+
suggestion?: boolean;
197+
}>(client.submitInteraction(), {
198+
code: 'session.mfa.suggest_additional_mfa',
199+
status: 422,
200+
expectData: (data) => {
201+
// Should include Email, Phone and TOTP
202+
expect(data.availableFactors).toEqual([
203+
MfaFactor.EmailVerificationCode,
204+
MfaFactor.PhoneVerificationCode,
205+
MfaFactor.TOTP,
206+
]);
207+
expect(data.maskedIdentifiers).toBeDefined();
208+
expect(data.maskedIdentifiers?.[MfaFactor.EmailVerificationCode]).toMatch(/\*{4}/);
209+
expect(data.skippable).toBe(true);
210+
expect(data.suggestion).toBe(true);
211+
},
212+
});
213+
214+
// Fulfill phone verification instead of skipping
215+
const { verificationId: phoneVerificationId, code: phoneCode } =
216+
await successfullySendVerificationCode(client, {
217+
identifier: { type: SignInIdentifier.Phone, value: phoneNumber },
218+
interactionEvent: InteractionEvent.Register,
219+
});
220+
221+
const finalPhoneVerificationId = await successfullyVerifyVerificationCode(client, {
222+
identifier: { type: SignInIdentifier.Phone, value: phoneNumber },
223+
verificationId: phoneVerificationId,
224+
code: phoneCode,
225+
});
226+
227+
await client.bindMfa(MfaFactor.PhoneVerificationCode, finalPhoneVerificationId);
228+
229+
// Now submit should succeed without MFA suggestion
230+
const { redirectTo } = await client.submitInteraction();
231+
const userId = await processSession(client, redirectTo);
132232
await logoutClient(client);
233+
await deleteUser(userId);
133234
});
134235
});

0 commit comments

Comments
 (0)