Skip to content

Commit aaf79de

Browse files
authored
feat(core): add organizationRequiredMfaPolicy guard (#7108)
add organizationRequiredMfaPolicy guard in experience API
1 parent ad0344a commit aaf79de

4 files changed

Lines changed: 319 additions & 12 deletions

File tree

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

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {
1313
MfaPolicy,
1414
type User,
1515
VerificationType,
16+
type Mfa as MfaSettings,
17+
OrganizationRequiredMfaPolicy,
1618
} from '@logto/schemas';
1719
import { generateStandardId } from '@logto/shared';
1820
import { deduplicate } from '@silverhand/essentials';
@@ -24,6 +26,7 @@ import type Libraries from '#src/tenants/Libraries.js';
2426
import type Queries from '#src/tenants/Queries.js';
2527
import assertThat from '#src/utils/assert-that.js';
2628

29+
import { EnvSet } from '../../../env-set/index.js';
2730
import { type InteractionContext } from '../types.js';
2831

2932
import { SignInExperienceValidator } from './libraries/sign-in-experience-validator.js';
@@ -150,10 +153,13 @@ export class Mfa {
150153
* @throws {RequestError} with status 422 if the MFA policy is not user controlled
151154
*/
152155
async skip() {
153-
const { policy } = await this.signInExperienceValidator.getMfaSettings();
156+
const mfaSettings = await this.signInExperienceValidator.getMfaSettings();
157+
const { policy } = mfaSettings;
158+
const user = await this.interactionContext.getIdentifiedUser();
154159

155160
assertThat(
156-
policy !== MfaPolicy.Mandatory,
161+
policy !== MfaPolicy.Mandatory &&
162+
!(await this.isMfaRequiredByUserOrganizations(mfaSettings, user.id)),
157163
new RequestError({
158164
code: 'session.mfa.mfa_policy_not_user_controlled',
159165
status: 422,
@@ -269,23 +275,39 @@ export class Mfa {
269275
* @throws {RequestError} with status 422 if the user has not bound the backup code but enabled in the sign-in experience
270276
* @throws {RequestError} with status 422 if the user existing backup codes is empty, new backup codes is required
271277
*/
278+
// eslint-disable-next-line complexity
272279
async assertUserMandatoryMfaFulfilled() {
273-
const { factors, policy } = await this.signInExperienceValidator.getMfaSettings();
280+
const mfaSettings = await this.signInExperienceValidator.getMfaSettings();
281+
const { policy, factors } = mfaSettings;
274282

275283
// If there are no factors, then there is nothing to check
276284
if (factors.length === 0) {
277285
return;
278286
}
279287

280-
const { mfaVerifications, logtoConfig } = await this.interactionContext.getIdentifiedUser();
288+
const {
289+
mfaVerifications,
290+
logtoConfig,
291+
id: userId,
292+
} = await this.interactionContext.getIdentifiedUser();
281293

282-
// If the policy is no prompt, then there is nothing to check
283-
if (policy === MfaPolicy.NoPrompt) {
294+
const isMfaRequiredByUserOrganizations = await this.isMfaRequiredByUserOrganizations(
295+
mfaSettings,
296+
userId
297+
);
298+
299+
// If the policy is no prompt, and mfa is not required by the user organizations, then there is nothing to check
300+
if (policy === MfaPolicy.NoPrompt && !isMfaRequiredByUserOrganizations) {
284301
return;
285302
}
286303

287-
// If the policy is not mandatory and the user has skipped MFA, then there is nothing to check
288-
if (policy !== MfaPolicy.Mandatory && (this.#mfaSkipped ?? isMfaSkipped(logtoConfig))) {
304+
// If the policy is not mandatory and the user has skipped MFA,
305+
// and MFA is not required by the user organizations, then there is nothing to check
306+
if (
307+
policy !== MfaPolicy.Mandatory &&
308+
(this.#mfaSkipped ?? isMfaSkipped(logtoConfig)) &&
309+
!isMfaRequiredByUserOrganizations
310+
) {
289311
return;
290312
}
291313

@@ -308,7 +330,7 @@ export class Mfa {
308330
availableFactors.some((factor) => linkedFactors.includes(factor)),
309331
new RequestError(
310332
{ code: 'user.missing_mfa', status: 422 },
311-
policy === MfaPolicy.Mandatory
333+
policy === MfaPolicy.Mandatory || isMfaRequiredByUserOrganizations
312334
? { availableFactors }
313335
: { availableFactors, skippable: true }
314336
)
@@ -340,4 +362,21 @@ export class Mfa {
340362

341363
assertThat(isFactorsEnabled, new RequestError({ code: 'session.mfa.mfa_factor_not_enabled' }));
342364
}
365+
366+
private async isMfaRequiredByUserOrganizations(mfaSettings: MfaSettings, userId: string) {
367+
// TODO: Remove this check when the feature is enabled for production
368+
if (!EnvSet.values.isDevFeaturesEnabled) {
369+
return false;
370+
}
371+
372+
if (mfaSettings.organizationRequiredMfaPolicy !== OrganizationRequiredMfaPolicy.Mandatory) {
373+
return false;
374+
}
375+
376+
const organizations = await this.queries.organizations.relations.users.getOrganizationsByUserId(
377+
userId
378+
);
379+
380+
return organizations.some(({ isMfaRequired }) => isMfaRequired);
381+
}
343382
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ import { experienceRoutes } from './const.js';
1919
import { type ExperienceInteractionRouterContext } from './types.js';
2020

2121
/**
22+
* This middleware is guards the current interaction status is MFA verified (if MFA is enabled)
23+
*
2224
* @throws {RequestError} with status 400 if current interaction is ForgotPassword
2325
* @throws {RequestError} with status 404 if current interaction is not identified
2426
* @throws {RequestError} with status 403 if MFA verification status is not verified
@@ -40,7 +42,6 @@ function verifiedInteractionGuard<
4042
})
4143
);
4244

43-
// Guard MFA verification status
4445
await experienceInteraction.guardMfaVerificationStatus();
4546

4647
return next();
@@ -73,7 +74,7 @@ export default function interactionProfileRoutes<T extends ExperienceInteraction
7374
})
7475
);
7576

76-
// Guard MFA verification status for SignIn interaction only
77+
// User profile updates require MFA verification (if MFA is enabled) during the sign-in event.
7778
if (interactionEvent === InteractionEvent.SignIn) {
7879
await experienceInteraction.guardMfaVerificationStatus();
7980
}

packages/integration-tests/src/helpers/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ type ExpectedErrorInfo = {
8686
code: string;
8787
status: number;
8888
messageIncludes?: string;
89+
unexpectedProperties?: string[];
8990
};
9091

9192
export const expectRejects = async <T = void>(
@@ -102,7 +103,7 @@ export const expectRejects = async <T = void>(
102103
};
103104

104105
const expectRequestError = async <T = void>(error: unknown, expected: ExpectedErrorInfo) => {
105-
const { code, status, messageIncludes } = expected;
106+
const { code, status, messageIncludes, unexpectedProperties = [] } = expected;
106107

107108
if (!(error instanceof HTTPError)) {
108109
fail('Error should be an instance of RequestError');
@@ -124,6 +125,10 @@ const expectRequestError = async <T = void>(error: unknown, expected: ExpectedEr
124125
expect(body.message.includes(messageIncludes)).toBeTruthy();
125126
}
126127

128+
for (const property of unexpectedProperties) {
129+
expect(body.data).not.toHaveProperty(property);
130+
}
131+
127132
return body.data;
128133
};
129134

0 commit comments

Comments
 (0)