Skip to content

Commit 126b821

Browse files
authored
feat(core): bind Email MFA (#7653)
1 parent 5cf261d commit 126b821

File tree

10 files changed

+403
-34
lines changed

10 files changed

+403
-34
lines changed

packages/core/src/routes/experience/classes/experience-interaction.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ export default class ExperienceInteraction {
9393
getVerificationRecordByTypeAndId: (type, verificationId) =>
9494
this.getVerificationRecordByTypeAndId(type, verificationId),
9595
getVerificationRecordById: (verificationId) => this.getVerificationRecordById(verificationId),
96+
getCurrentProfile: () => this.profile.data,
9697
};
9798

9899
if (typeof interactionData === 'string') {

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

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@
66
*/
77

88
import { defaults, parseAffiliateData } from '@logto/affiliate';
9-
import { adminTenantId, MfaFactor, VerificationType, type User } from '@logto/schemas';
9+
import {
10+
adminTenantId,
11+
MfaFactor,
12+
VerificationType,
13+
type User,
14+
type Mfa,
15+
type MfaVerification,
16+
} from '@logto/schemas';
1017
import { conditional, trySafe } from '@silverhand/essentials';
1118
import { type IRouterContext } from 'koa-router';
1219

@@ -171,6 +178,57 @@ export const mergeUserMfaVerifications = (
171178
return [...userMfaVerifications, ...newMfaVerifications];
172179
};
173180

181+
/**
182+
* Filter out backup codes mfa verifications that have been used
183+
*/
184+
const filterOutEmptyBackupCodes = (
185+
mfaVerifications: User['mfaVerifications']
186+
): User['mfaVerifications'] =>
187+
mfaVerifications.filter((mfa) => {
188+
if (mfa.type === MfaFactor.BackupCode) {
189+
return mfa.codes.some((code) => !code.usedAt);
190+
}
191+
return true;
192+
});
193+
194+
/**
195+
* Get all enabled MFA verifications for a user (stored + implicit)
196+
* @param mfaSettings - MFA settings from sign-in experience
197+
* @param user - User object with mfaVerifications and profile data
198+
* @param currentProfile - Optional profile override (for current interaction contexts), in cases of MFA verification, this is not needed
199+
* @returns Array of all enabled MFA verifications
200+
*/
201+
export const getAllUserEnabledMfaVerifications = (
202+
mfaSettings: Mfa,
203+
user: User,
204+
currentProfile?: InteractionProfile
205+
): MfaVerification[] => {
206+
const storedVerifications = filterOutEmptyBackupCodes(user.mfaVerifications).filter(
207+
(verification) => mfaSettings.factors.includes(verification.type)
208+
);
209+
210+
if (!EnvSet.values.isDevFeaturesEnabled) {
211+
return storedVerifications;
212+
}
213+
214+
const email = currentProfile?.primaryEmail ?? user.primaryEmail;
215+
216+
const implicitVerifications = [
217+
// Email MFA Factor: user has primaryEmail + Email factor enabled in SIE
218+
...(mfaSettings.factors.includes(MfaFactor.EmailVerificationCode) && email
219+
? ([
220+
{
221+
id: 'implicit-email-mfa', // Fake ID for capability
222+
type: MfaFactor.EmailVerificationCode,
223+
createdAt: new Date(user.createdAt).toISOString(),
224+
},
225+
] satisfies MfaVerification[])
226+
: []),
227+
];
228+
229+
return [...storedVerifications, ...implicitVerifications];
230+
};
231+
174232
/**
175233
* Post affiliate data to the cloud service.
176234
*/

packages/core/src/routes/experience/classes/libraries/mfa-validator.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {
99
type User,
1010
} from '@logto/schemas';
1111

12+
import { getAllUserEnabledMfaVerifications } from '../helpers.js';
1213
import { type BackupCodeVerification } from '../verifications/backup-code-verification.js';
14+
import { type EmailCodeVerification } from '../verifications/code-verification.js';
1315
import { type VerificationRecord } from '../verifications/index.js';
1416
import { type TotpVerification } from '../verifications/totp-verification.js';
1517
import { type WebAuthnVerification } from '../verifications/web-authn-verification.js';
@@ -18,20 +20,27 @@ const mfaVerificationTypes = Object.freeze([
1820
VerificationType.TOTP,
1921
VerificationType.BackupCode,
2022
VerificationType.WebAuthn,
23+
VerificationType.EmailVerificationCode,
2124
]);
2225

2326
type MfaVerificationType =
2427
| VerificationType.TOTP
2528
| VerificationType.BackupCode
26-
| VerificationType.WebAuthn;
29+
| VerificationType.WebAuthn
30+
| VerificationType.EmailVerificationCode;
2731

2832
const mfaVerificationTypeToMfaFactorMap = Object.freeze({
2933
[VerificationType.TOTP]: MfaFactor.TOTP,
3034
[VerificationType.BackupCode]: MfaFactor.BackupCode,
3135
[VerificationType.WebAuthn]: MfaFactor.WebAuthn,
36+
[VerificationType.EmailVerificationCode]: MfaFactor.EmailVerificationCode,
3237
}) satisfies Record<MfaVerificationType, MfaFactor>;
3338

34-
type MfaVerificationRecord = TotpVerification | WebAuthnVerification | BackupCodeVerification;
39+
type MfaVerificationRecord =
40+
| TotpVerification
41+
| WebAuthnVerification
42+
| BackupCodeVerification
43+
| EmailCodeVerification;
3544

3645
const isMfaVerificationRecord = (
3746
verification: VerificationRecord
@@ -49,13 +58,10 @@ export class MfaValidator {
4958
* Get the enabled MFA factors for the user
5059
*
5160
* - Filter out MFA factors that are not configured in the sign-in experience
61+
* - Include implicit Email and Phone MFA factors if user has them and they're enabled in SIE
5262
*/
5363
get userEnabledMfaVerifications() {
54-
const { mfaVerifications } = this.user;
55-
56-
return mfaVerifications.filter((verification) =>
57-
this.mfaSettings.factors.includes(verification.type)
58-
);
64+
return getAllUserEnabledMfaVerifications(this.mfaSettings, this.user);
5965
}
6066

6167
/**

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

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import assertThat from '#src/utils/assert-that.js';
2828

2929
import { type InteractionContext } from '../types.js';
3030

31+
import { getAllUserEnabledMfaVerifications } from './helpers.js';
3132
import { SignInExperienceValidator } from './libraries/sign-in-experience-validator.js';
3233

3334
export type MfaData = {
@@ -73,19 +74,6 @@ const isMfaSkipped = (logtoConfig: JsonObject): boolean => {
7374
return parsed.success ? parsed.data[userMfaDataKey].skipped === true : false;
7475
};
7576

76-
/**
77-
* Filter out backup codes mfa verifications that have been used
78-
*/
79-
const filterOutEmptyBackupCodes = (
80-
mfaVerifications: User['mfaVerifications']
81-
): User['mfaVerifications'] =>
82-
mfaVerifications.filter((mfa) => {
83-
if (mfa.type === MfaFactor.BackupCode) {
84-
return mfa.codes.some((code) => !code.usedAt);
85-
}
86-
return true;
87-
});
88-
8977
/**
9078
* This class stores all the pending new MFA settings for a user.
9179
*/
@@ -261,11 +249,11 @@ export class Mfa {
261249

262250
await this.checkMfaFactorsEnabledInSignInExperience([MfaFactor.BackupCode]);
263251

264-
const { mfaVerifications } = await this.interactionContext.getIdentifiedUser();
265-
const userHasOtherMfa = mfaVerifications.some((mfa) => mfa.type !== MfaFactor.BackupCode);
266-
const hasOtherNewMfa = Boolean(this.#totp ?? this.#webAuthn?.length);
252+
const userFactors = await this.getUserMfaFactors();
253+
const hasOtherMfaFactors = userFactors.some((factor) => factor !== MfaFactor.BackupCode);
254+
267255
assertThat(
268-
userHasOtherMfa || hasOtherNewMfa,
256+
hasOtherMfaFactors,
269257
new RequestError({
270258
code: 'session.mfa.backup_code_can_not_be_alone',
271259
status: 422,
@@ -298,11 +286,7 @@ export class Mfa {
298286
return;
299287
}
300288

301-
const {
302-
mfaVerifications,
303-
logtoConfig,
304-
id: userId,
305-
} = await this.interactionContext.getIdentifiedUser();
289+
const { logtoConfig, id: userId } = await this.interactionContext.getIdentifiedUser();
306290

307291
const isMfaRequiredByUserOrganizations = await this.isMfaRequiredByUserOrganizations(
308292
mfaSettings,
@@ -334,7 +318,7 @@ export class Mfa {
334318

335319
const availableFactors = factors.filter((factor) => factor !== MfaFactor.BackupCode);
336320

337-
const factorsInUser = filterOutEmptyBackupCodes(mfaVerifications).map(({ type }) => type);
321+
const factorsInUser = await this.getUserMfaFactors();
338322
const factorsInBind = this.bindMfaFactorsArray.map(({ type }) => type);
339323
const linkedFactors = deduplicate([...factorsInUser, ...factorsInBind]);
340324

@@ -395,4 +379,21 @@ export class Mfa {
395379

396380
return organizations.some(({ isMfaRequired }) => isMfaRequired);
397381
}
382+
383+
private async getUserMfaFactors(): Promise<MfaFactor[]> {
384+
const mfaSettings = await this.signInExperienceValidator.getMfaSettings();
385+
const user = await this.interactionContext.getIdentifiedUser();
386+
const currentProfile = this.interactionContext.getCurrentProfile();
387+
388+
const existingVerifications = getAllUserEnabledMfaVerifications(
389+
mfaSettings,
390+
user,
391+
currentProfile
392+
);
393+
return [
394+
...existingVerifications.map(({ type }) => type),
395+
...(this.#totp ? [MfaFactor.TOTP] : []),
396+
...(this.#webAuthn?.length ? [MfaFactor.WebAuthn] : []),
397+
].filter(Boolean);
398+
}
398399
}

packages/core/src/routes/experience/classes/verifications/code-verification.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,17 @@ abstract class CodeVerification<T extends CodeVerificationType>
8888
return this.verified;
8989
}
9090

91+
get isNewBindMfaVerification() {
92+
// For EmailCodeVerification and PhoneCodeVerification, the binding is always completed before submitting the interaction.
93+
// So this method always returns false.
94+
// So that it can be used right after the new Email/Phone is bound to the user.
95+
// The flow: user binds a new email/phone -> user info updated to the DB -> user submits the interaction
96+
// -> check user enabled MFA verifications -> the new email/phone is included in the enabled MFA verifications
97+
// -> but the user does not need to verify the new email/phone again -> reuse the verification record
98+
// So this verification record are used for the new bind MFA verification, and also can be used for the verification.
99+
return false;
100+
}
101+
91102
/**
92103
* Send the verification code to the current `identifier`
93104
*

packages/core/src/routes/experience/profile-routes.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import koaGuard from '#src/middleware/koa-guard.js';
1414
import type TenantContext from '#src/tenants/TenantContext.js';
1515
import assertThat from '#src/utils/assert-that.js';
1616

17+
import { EnvSet } from '../../env-set/index.js';
18+
1719
import { experienceRoutes } from './const.js';
1820
import { type ExperienceInteractionRouterContext } from './types.js';
1921

@@ -216,9 +218,20 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
216218
await experienceInteraction.mfa.addBackupCodeByVerificationId(verificationId, log);
217219
break;
218220
}
219-
case MfaFactor.EmailVerificationCode:
221+
case MfaFactor.EmailVerificationCode: {
222+
if (!EnvSet.values.isDevFeaturesEnabled) {
223+
throw new Error('Not implemented yet');
224+
}
225+
226+
await experienceInteraction.profile.setProfileByVerificationId(
227+
SignInIdentifier.Email,
228+
verificationId,
229+
log
230+
);
231+
break;
232+
}
220233
case MfaFactor.PhoneVerificationCode: {
221-
// TODO: Implement email and SMS verification code MFA binding
234+
// TODO: Implement SMS verification code MFA binding
222235
throw new Error('Not implemented yet');
223236
}
224237
}

packages/core/src/routes/experience/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ export type InteractionContext = {
150150
type: K,
151151
verificationId: string
152152
) => VerificationRecordMap[K];
153+
getCurrentProfile: () => InteractionProfile;
153154
};
154155

155156
export type ExperienceInteractionRouterContext<ContextT extends WithLogContext = WithLogContext> =

packages/experience/src/apis/experience/mfa.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ export const bindMfa = async (payload: BindMfaPayload, verificationId: string) =
8787
}
8888
case MfaFactor.EmailVerificationCode:
8989
case MfaFactor.PhoneVerificationCode: {
90-
// TODO: Implement email and phone verification code binding
90+
// Email/Phone MFA factors use special binding logic, but don't submit immediately
91+
// to allow additional MFA factors to be bound in the same session
9192
break;
9293
}
9394
}

packages/integration-tests/src/helpers/sign-in-experience.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,22 @@ export const enableMandatoryMfaWithTotpAndBackupCode = async () =>
169169
},
170170
});
171171

172+
export const enableMandatoryMfaWithEmail = async () =>
173+
updateSignInExperience({
174+
mfa: {
175+
factors: [MfaFactor.EmailVerificationCode],
176+
policy: MfaPolicy.Mandatory,
177+
},
178+
});
179+
180+
export const enableMandatoryMfaWithEmailAndBackupCode = async () =>
181+
updateSignInExperience({
182+
mfa: {
183+
factors: [MfaFactor.EmailVerificationCode, MfaFactor.BackupCode],
184+
policy: MfaPolicy.Mandatory,
185+
},
186+
});
187+
172188
export const enableMandatoryMfaWithWebAuthnAndBackupCode = async () =>
173189
updateSignInExperience({
174190
mfa: {

0 commit comments

Comments
 (0)