1+ /* eslint-disable max-lines */
12import { type ToZodObject } from '@logto/connector-kit' ;
23import {
34 type BindBackupCode ,
@@ -26,13 +27,19 @@ import type Libraries from '#src/tenants/Libraries.js';
2627import type Queries from '#src/tenants/Queries.js' ;
2728import assertThat from '#src/utils/assert-that.js' ;
2829
30+ import { EnvSet } from '../../../env-set/index.js' ;
2931import { type InteractionContext } from '../types.js' ;
3032
3133import { getAllUserEnabledMfaVerifications } from './helpers.js' ;
3234import { SignInExperienceValidator } from './libraries/sign-in-experience-validator.js' ;
3335
3436export type MfaData = {
3537 mfaSkipped ?: boolean ;
38+ /**
39+ * Whether user skipped the optional suggestion to add another MFA factor during registration.
40+ * This flag lives only in the current interaction and should NOT be persisted to user profile.
41+ */
42+ additionalBindingSuggestionSkipped ?: boolean ;
3643 totp ?: BindTotp ;
3744 webAuthn ?: BindWebAuthn [ ] ;
3845 backupCode ?: BindBackupCode ;
@@ -47,6 +54,7 @@ export type SanitizedMfaData = {
4754
4855export const mfaDataGuard = z . object ( {
4956 mfaSkipped : z . boolean ( ) . optional ( ) ,
57+ additionalBindingSuggestionSkipped : z . boolean ( ) . optional ( ) ,
5058 totp : bindTotpGuard . optional ( ) ,
5159 webAuthn : z . array ( bindWebAuthnGuard ) . optional ( ) ,
5260 backupCode : bindBackupCodeGuard . optional ( ) ,
@@ -80,6 +88,7 @@ const isMfaSkipped = (logtoConfig: JsonObject): boolean => {
8088export class Mfa {
8189 private readonly signInExperienceValidator : SignInExperienceValidator ;
8290 #mfaSkipped?: boolean ;
91+ #additionalBindingSuggestionSkipped?: boolean ;
8392 #totp?: BindTotp ;
8493 #webAuthn?: BindWebAuthn [ ] ;
8594 #backupCode?: BindBackupCode ;
@@ -91,9 +100,10 @@ export class Mfa {
91100 private readonly interactionContext : InteractionContext
92101 ) {
93102 this . signInExperienceValidator = new SignInExperienceValidator ( libraries , queries ) ;
94- const { mfaSkipped, totp, webAuthn, backupCode } = data ;
103+ const { mfaSkipped, additionalBindingSuggestionSkipped , totp, webAuthn, backupCode } = data ;
95104
96105 this . #mfaSkipped = mfaSkipped ;
106+ this . #additionalBindingSuggestionSkipped = additionalBindingSuggestionSkipped ;
97107 this . #totp = totp ;
98108 this . #webAuthn = webAuthn ;
99109 this . #backupCode = backupCode ;
@@ -103,6 +113,10 @@ export class Mfa {
103113 return this . #mfaSkipped;
104114 }
105115
116+ get additionalBindingSuggestionSkipped ( ) {
117+ return this . #additionalBindingSuggestionSkipped;
118+ }
119+
106120 get bindMfaFactorsArray ( ) : BindMfa [ ] {
107121 return [ this . #totp, ...( this . #webAuthn ?? [ ] ) , this . #backupCode] . filter ( Boolean ) ;
108122 }
@@ -263,6 +277,14 @@ export class Mfa {
263277 this . #backupCode = verificationRecord . toBindMfa ( ) ;
264278 }
265279
280+ /**
281+ * Mark the optional suggestion as skipped for this interaction.
282+ * No persistence to user account.
283+ */
284+ skipAdditionalBindingSuggestion ( ) {
285+ this . #additionalBindingSuggestionSkipped = true ;
286+ }
287+
266288 /**
267289 * @throws {RequestError } with status 400 if the mfa factors are not enabled in the sign-in experience
268290 */
@@ -271,6 +293,52 @@ export class Mfa {
271293 await this . checkMfaFactorsEnabledInSignInExperience ( newBindMfaFactors ) ;
272294 }
273295
296+ /**
297+ * Optionally suggest user to bind additional MFA factors during registration.
298+ * Encapsulates suggestion logic and throws a 422 with `session.mfa.suggest_additional_mfa`
299+ * when conditions are met.
300+ * The purpose is to suggest another MFA factor if the user has only one Email or Phone factor.
301+ */
302+ async guardAdditionalBindingSuggestion (
303+ factorsInUser : MfaFactor [ ] ,
304+ availableFactors : MfaFactor [ ]
305+ ) {
306+ // Only suggest during registration flow
307+ if ( this . interactionContext . getInteractionEvent ( ) !== InteractionEvent . Register ) {
308+ return ;
309+ }
310+
311+ // If no Email/Phone factor in use, then the user is not registered by Email/Phone
312+ if (
313+ ! factorsInUser . includes ( MfaFactor . EmailVerificationCode ) &&
314+ ! factorsInUser . includes ( MfaFactor . PhoneVerificationCode )
315+ ) {
316+ return ;
317+ }
318+
319+ const additionalFactors = availableFactors . filter ( ( factor ) => ! factorsInUser . includes ( factor ) ) ;
320+
321+ // Respect user's choice to skip suggestion for this interaction
322+ if ( this . additionalBindingSuggestionSkipped ) {
323+ return ;
324+ }
325+
326+ // If user already bound an MFA in this interaction, don't suggest again
327+ if ( this . bindMfaFactorsArray . length > 0 ) {
328+ return ;
329+ }
330+
331+ // No available factors to suggest
332+ if ( additionalFactors . length === 0 ) {
333+ return ;
334+ }
335+
336+ throw new RequestError (
337+ { code : 'session.mfa.suggest_additional_mfa' , status : 422 } ,
338+ { availableFactors : additionalFactors , skippable : true , suggestion : true }
339+ ) ;
340+ }
341+
274342 /**
275343 * @throws {RequestError } with status 422 if the user has not bound the required MFA factors
276344 * @throws {RequestError } with status 422 if the user has not bound the backup code but enabled in the sign-in experience
@@ -333,6 +401,11 @@ export class Mfa {
333401 )
334402 ) ;
335403
404+ if ( EnvSet . values . isDevFeaturesEnabled ) {
405+ // Optional suggestion: Let Mfa decide whether to suggest additional binding during registration
406+ await this . guardAdditionalBindingSuggestion ( factorsInUser , availableFactors ) ;
407+ }
408+
336409 // Assert backup code
337410 assertThat (
338411 ! factors . includes ( MfaFactor . BackupCode ) || linkedFactors . includes ( MfaFactor . BackupCode ) ,
@@ -346,6 +419,7 @@ export class Mfa {
346419 get data ( ) : MfaData {
347420 return {
348421 mfaSkipped : this . mfaSkipped ,
422+ additionalBindingSuggestionSkipped : this . additionalBindingSuggestionSkipped ,
349423 totp : this . #totp,
350424 webAuthn : this . #webAuthn,
351425 backupCode : this . #backupCode,
@@ -397,3 +471,4 @@ export class Mfa {
397471 ] . filter ( Boolean ) ;
398472 }
399473}
474+ /* eslint-enable max-lines */
0 commit comments