@@ -2,21 +2,28 @@ import { ConnectorType } from '@logto/connector-kit';
22import { InteractionEvent , MfaFactor , MfaPolicy , SignInIdentifier } from '@logto/schemas' ;
33import { authenticator } from 'otplib' ;
44
5+ import { deleteUser } from '#src/api/admin-user.js' ;
56import { updateSignInExperience } from '#src/api/sign-in-experience.js' ;
67import { 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' ;
813import {
914 successfullySendVerificationCode ,
1015 successfullyVerifyVerificationCode ,
1116} from '#src/helpers/experience/verification-code.js' ;
1217import { expectRejects } from '#src/helpers/index.js' ;
1318import { resetMfaSettings } from '#src/helpers/sign-in-experience.js' ;
1419import { generateNewUserProfile } from '#src/helpers/user.js' ;
20+ import { generatePhone } from '#src/utils.js' ;
1521
1622describe ( '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