mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core,console): new mfa prompt policy
This commit is contained in:
parent
2178589507
commit
a38031caf8
20 changed files with 289 additions and 67 deletions
19
.changeset/hot-oranges-join.md
Normal file
19
.changeset/hot-oranges-join.md
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
---
|
||||||
|
"@logto/experience-legacy": minor
|
||||||
|
"@logto/integration-tests": minor
|
||||||
|
"@logto/experience": minor
|
||||||
|
"@logto/console": minor
|
||||||
|
"@logto/phrases": minor
|
||||||
|
"@logto/schemas": minor
|
||||||
|
"@logto/core": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
new MFA prompt policy
|
||||||
|
|
||||||
|
You can now cutomize the MFA prompt policy in the Console:
|
||||||
|
1. MfaPolicy.Mandatory: MFA is required for all users
|
||||||
|
2. MfaPolicy.NoPrompt: Do not ask users to set up MFA - new
|
||||||
|
3. MfaPolicy.PromptAtSignInAndSignUp: Ask users to set up MFA during registration (skippable, one-time prompt) - new
|
||||||
|
4. MfaPolicy.PromptOnlyAtSignIn: Ask users to set up MFA on their sign-in after registration (skippable, one-time prompt) - the old policy
|
||||||
|
|
||||||
|
`MfaPolicy.UserControlled` is deprecated, use `MfaPolicy.PromptAtSignInAndSignUp` instead.
|
|
@ -1,7 +0,0 @@
|
||||||
import { type AdminConsoleKey } from '@logto/phrases';
|
|
||||||
import { MfaPolicy } from '@logto/schemas';
|
|
||||||
|
|
||||||
export const policyOptionTitleMap: Record<MfaPolicy, AdminConsoleKey> = {
|
|
||||||
[MfaPolicy.UserControlled]: 'mfa.user_controlled',
|
|
||||||
[MfaPolicy.Mandatory]: 'mfa.mandatory',
|
|
||||||
};
|
|
|
@ -13,7 +13,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import InlineNotification from '@/ds-components/InlineNotification';
|
import InlineNotification from '@/ds-components/InlineNotification';
|
||||||
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
|
import Select from '@/ds-components/Select';
|
||||||
import Switch from '@/ds-components/Switch';
|
import Switch from '@/ds-components/Switch';
|
||||||
import useApi from '@/hooks/use-api';
|
import useApi from '@/hooks/use-api';
|
||||||
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
import useDocumentationUrl from '@/hooks/use-documentation-url';
|
||||||
|
@ -24,7 +24,6 @@ import { type MfaConfigForm, type MfaConfig } from '../types';
|
||||||
|
|
||||||
import FactorLabel from './FactorLabel';
|
import FactorLabel from './FactorLabel';
|
||||||
import UpsellNotice from './UpsellNotice';
|
import UpsellNotice from './UpsellNotice';
|
||||||
import { policyOptionTitleMap } from './constants';
|
|
||||||
import styles from './index.module.scss';
|
import styles from './index.module.scss';
|
||||||
import { convertMfaFormToConfig, convertMfaConfigToForm, validateBackupCodeFactor } from './utils';
|
import { convertMfaFormToConfig, convertMfaConfigToForm, validateBackupCodeFactor } from './utils';
|
||||||
|
|
||||||
|
@ -69,6 +68,21 @@ function MfaForm({ data, onMfaUpdated }: Props) {
|
||||||
return factors.length === 0;
|
return factors.length === 0;
|
||||||
}, [formValues, isMfaDisabled]);
|
}, [formValues, isMfaDisabled]);
|
||||||
|
|
||||||
|
const mfaPolicyOptions = [
|
||||||
|
{
|
||||||
|
value: MfaPolicy.NoPrompt,
|
||||||
|
title: t('mfa.no_prompt'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
|
title: t('mfa.prompt_at_sign_in_and_sign_up'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: MfaPolicy.PromptOnlyAtSignIn,
|
||||||
|
title: t('mfa.prompt_only_at_sign_in'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const onSubmit = handleSubmit(
|
const onSubmit = handleSubmit(
|
||||||
trySubmitSafe(async (formData) => {
|
trySubmitSafe(async (formData) => {
|
||||||
const mfaConfig = convertMfaFormToConfig(formData);
|
const mfaConfig = convertMfaFormToConfig(formData);
|
||||||
|
@ -143,28 +157,37 @@ function MfaForm({ data, onMfaUpdated }: Props) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</FormCard>
|
</FormCard>
|
||||||
<FormCard title="mfa.policy">
|
<FormCard
|
||||||
<FormField title="mfa.two_step_sign_in_policy" headlineSpacing="large">
|
title="mfa.policy"
|
||||||
<Controller
|
description="mfa.policy_description"
|
||||||
control={control}
|
learnMoreLink={{
|
||||||
name="policy"
|
href: getDocumentationUrl('/docs/recipes/multi-factor-auth/configure-mfa'),
|
||||||
render={({ field: { onChange, value, name } }) => (
|
targetBlank: 'noopener',
|
||||||
<RadioGroup name={name} value={value} onChange={onChange}>
|
}}
|
||||||
{Object.values(MfaPolicy).map((policy) => {
|
>
|
||||||
const title = policyOptionTitleMap[policy];
|
<FormField title="mfa.require_mfa" headlineSpacing="large">
|
||||||
return (
|
<Switch
|
||||||
<Radio
|
disabled={isPolicySettingsDisabled}
|
||||||
key={policy}
|
label={t('mfa.require_mfa_label')}
|
||||||
isDisabled={isPolicySettingsDisabled}
|
{...register('isMandatory')}
|
||||||
title={title}
|
|
||||||
value={policy}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
|
{!formValues.isMandatory && (
|
||||||
|
<FormField title="mfa.set_up_prompt" headlineSpacing="large">
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name="setUpPrompt"
|
||||||
|
render={({ field: { onChange, value } }) => (
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
options={mfaPolicyOptions}
|
||||||
|
isReadOnly={isPolicySettingsDisabled}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
</FormCard>
|
</FormCard>
|
||||||
</DetailsForm>
|
</DetailsForm>
|
||||||
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />
|
||||||
|
|
|
@ -1,17 +1,24 @@
|
||||||
import { MfaFactor } from '@logto/schemas';
|
import { MfaFactor, MfaPolicy } from '@logto/schemas';
|
||||||
import { conditional } from '@silverhand/essentials';
|
import { conditional } from '@silverhand/essentials';
|
||||||
|
|
||||||
import { type MfaConfig, type MfaConfigForm } from '../types';
|
import { type SignInPrompt, type MfaConfig, type MfaConfigForm } from '../types';
|
||||||
|
|
||||||
|
const isSignInPrompt = (policy: MfaPolicy): policy is SignInPrompt =>
|
||||||
|
[MfaPolicy.NoPrompt, MfaPolicy.PromptAtSignInAndSignUp, MfaPolicy.PromptOnlyAtSignIn].includes(
|
||||||
|
policy
|
||||||
|
);
|
||||||
|
|
||||||
export const convertMfaConfigToForm = ({ policy, factors }: MfaConfig): MfaConfigForm => ({
|
export const convertMfaConfigToForm = ({ policy, factors }: MfaConfig): MfaConfigForm => ({
|
||||||
policy,
|
isMandatory: policy === MfaPolicy.Mandatory,
|
||||||
|
setUpPrompt: isSignInPrompt(policy) ? policy : MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
totpEnabled: factors.includes(MfaFactor.TOTP),
|
totpEnabled: factors.includes(MfaFactor.TOTP),
|
||||||
webAuthnEnabled: factors.includes(MfaFactor.WebAuthn),
|
webAuthnEnabled: factors.includes(MfaFactor.WebAuthn),
|
||||||
backupCodeEnabled: factors.includes(MfaFactor.BackupCode),
|
backupCodeEnabled: factors.includes(MfaFactor.BackupCode),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const convertMfaFormToConfig = (mfaConfigForm: MfaConfigForm): MfaConfig => {
|
export const convertMfaFormToConfig = (mfaConfigForm: MfaConfigForm): MfaConfig => {
|
||||||
const { policy, totpEnabled, webAuthnEnabled, backupCodeEnabled } = mfaConfigForm;
|
const { isMandatory, setUpPrompt, totpEnabled, webAuthnEnabled, backupCodeEnabled } =
|
||||||
|
mfaConfigForm;
|
||||||
|
|
||||||
const factors = [
|
const factors = [
|
||||||
conditional(totpEnabled && MfaFactor.TOTP),
|
conditional(totpEnabled && MfaFactor.TOTP),
|
||||||
|
@ -21,7 +28,7 @@ export const convertMfaFormToConfig = (mfaConfigForm: MfaConfigForm): MfaConfig
|
||||||
].filter((factor): factor is MfaFactor => Boolean(factor));
|
].filter((factor): factor is MfaFactor => Boolean(factor));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
policy,
|
policy: isMandatory ? MfaPolicy.Mandatory : setUpPrompt,
|
||||||
factors,
|
factors,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import { type MfaPolicy, type SignInExperience } from '@logto/schemas';
|
import { type MfaPolicy, type SignInExperience } from '@logto/schemas';
|
||||||
|
|
||||||
export type MfaConfig = SignInExperience['mfa'];
|
export type MfaConfig = SignInExperience['mfa'];
|
||||||
|
export type SignInPrompt = Exclude<MfaPolicy, MfaPolicy.UserControlled | MfaPolicy.Mandatory>;
|
||||||
|
|
||||||
export type MfaConfigForm = {
|
export type MfaConfigForm = {
|
||||||
policy: MfaPolicy;
|
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
webAuthnEnabled: boolean;
|
webAuthnEnabled: boolean;
|
||||||
backupCodeEnabled: boolean;
|
backupCodeEnabled: boolean;
|
||||||
|
isMandatory: boolean;
|
||||||
|
setUpPrompt: SignInPrompt;
|
||||||
};
|
};
|
||||||
|
|
|
@ -95,7 +95,7 @@ export const mockSignInExperience: SignInExperience = {
|
||||||
customUiAssets: null,
|
customUiAssets: null,
|
||||||
passwordPolicy: {},
|
passwordPolicy: {},
|
||||||
mfa: {
|
mfa: {
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
factors: [],
|
factors: [],
|
||||||
},
|
},
|
||||||
singleSignOnEnabled: true,
|
singleSignOnEnabled: true,
|
||||||
|
|
|
@ -10,7 +10,7 @@ describe('validate mfa', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
validateMfa({
|
validateMfa({
|
||||||
factors: [],
|
factors: [],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
});
|
});
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
@ -19,7 +19,7 @@ describe('validate mfa', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
validateMfa({
|
validateMfa({
|
||||||
factors: [MfaFactor.TOTP],
|
factors: [MfaFactor.TOTP],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
});
|
});
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
@ -28,7 +28,7 @@ describe('validate mfa', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
validateMfa({
|
validateMfa({
|
||||||
factors: [MfaFactor.TOTP, MfaFactor.BackupCode],
|
factors: [MfaFactor.TOTP, MfaFactor.BackupCode],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
});
|
});
|
||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
|
@ -38,7 +38,7 @@ describe('validate mfa', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
validateMfa({
|
validateMfa({
|
||||||
factors: [MfaFactor.BackupCode],
|
factors: [MfaFactor.BackupCode],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
});
|
});
|
||||||
}).toMatchError(new RequestError('sign_in_experiences.backup_code_cannot_be_enabled_alone'));
|
}).toMatchError(new RequestError('sign_in_experiences.backup_code_cannot_be_enabled_alone'));
|
||||||
});
|
});
|
||||||
|
@ -47,7 +47,7 @@ describe('validate mfa', () => {
|
||||||
expect(() => {
|
expect(() => {
|
||||||
validateMfa({
|
validateMfa({
|
||||||
factors: [MfaFactor.TOTP, MfaFactor.TOTP],
|
factors: [MfaFactor.TOTP, MfaFactor.TOTP],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
});
|
});
|
||||||
}).toMatchError(new RequestError('sign_in_experiences.duplicated_mfa_factors'));
|
}).toMatchError(new RequestError('sign_in_experiences.duplicated_mfa_factors'));
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
bindTotpGuard,
|
bindTotpGuard,
|
||||||
type BindWebAuthn,
|
type BindWebAuthn,
|
||||||
bindWebAuthnGuard,
|
bindWebAuthnGuard,
|
||||||
|
InteractionEvent,
|
||||||
type JsonObject,
|
type JsonObject,
|
||||||
MfaFactor,
|
MfaFactor,
|
||||||
MfaPolicy,
|
MfaPolicy,
|
||||||
|
@ -152,7 +153,7 @@ export class Mfa {
|
||||||
const { policy } = await this.signInExperienceValidator.getMfaSettings();
|
const { policy } = await this.signInExperienceValidator.getMfaSettings();
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
policy === MfaPolicy.UserControlled,
|
policy !== MfaPolicy.Mandatory,
|
||||||
new RequestError({
|
new RequestError({
|
||||||
code: 'session.mfa.mfa_policy_not_user_controlled',
|
code: 'session.mfa.mfa_policy_not_user_controlled',
|
||||||
status: 422,
|
status: 422,
|
||||||
|
@ -278,9 +279,21 @@ export class Mfa {
|
||||||
|
|
||||||
const { mfaVerifications, logtoConfig } = await this.interactionContext.getIdentifiedUser();
|
const { mfaVerifications, logtoConfig } = await this.interactionContext.getIdentifiedUser();
|
||||||
|
|
||||||
// If the policy is user controlled and the user has skipped MFA, then there is nothing to check
|
// If the policy is no prompt, then there is nothing to check
|
||||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
if (policy === MfaPolicy.NoPrompt) {
|
||||||
if ((policy === MfaPolicy.UserControlled && this.#mfaSkipped) || isMfaSkipped(logtoConfig)) {
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the policy is not mandatory and the user has skipped MFA, then there is nothing to check
|
||||||
|
if ((policy !== MfaPolicy.Mandatory && this.#mfaSkipped) ?? isMfaSkipped(logtoConfig)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the policy is prompt only at sign-in, and the event is register, skip check
|
||||||
|
if (
|
||||||
|
this.interactionContext.getInteractionEvent() === InteractionEvent.Register &&
|
||||||
|
policy === MfaPolicy.PromptOnlyAtSignIn
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -171,7 +171,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
|
||||||
} = ctx;
|
} = ctx;
|
||||||
|
|
||||||
assertThat(
|
assertThat(
|
||||||
policy === MfaPolicy.UserControlled,
|
policy !== MfaPolicy.Mandatory,
|
||||||
new RequestError({
|
new RequestError({
|
||||||
code: 'session.mfa.mfa_policy_not_user_controlled',
|
code: 'session.mfa.mfa_policy_not_user_controlled',
|
||||||
status: 422,
|
status: 422,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import { PasswordPolicyChecker } from '@logto/core-kit';
|
import { PasswordPolicyChecker } from '@logto/core-kit';
|
||||||
import type { SignInExperience } from '@logto/schemas';
|
import { MfaPolicy, type SignInExperience } from '@logto/schemas';
|
||||||
import type { MiddlewareType } from 'koa';
|
import type { MiddlewareType } from 'koa';
|
||||||
import { type IRouterParamContext } from 'koa-router';
|
import { type IRouterParamContext } from 'koa-router';
|
||||||
|
|
||||||
|
@ -24,7 +24,17 @@ export default function koaInteractionSie<StateT, ContextT extends IRouterParamC
|
||||||
return async (ctx, next) => {
|
return async (ctx, next) => {
|
||||||
const signInExperience = await findDefaultSignInExperience();
|
const signInExperience = await findDefaultSignInExperience();
|
||||||
|
|
||||||
ctx.signInExperience = signInExperience;
|
ctx.signInExperience = {
|
||||||
|
...signInExperience,
|
||||||
|
mfa: {
|
||||||
|
...signInExperience.mfa,
|
||||||
|
policy:
|
||||||
|
// Fallback deprecated UserControlled policy to PromptAtSignInAndSignUp
|
||||||
|
signInExperience.mfa.policy === MfaPolicy.UserControlled
|
||||||
|
? MfaPolicy.PromptAtSignInAndSignUp
|
||||||
|
: signInExperience.mfa.policy,
|
||||||
|
},
|
||||||
|
};
|
||||||
ctx.passwordPolicyChecker = new PasswordPolicyChecker(
|
ctx.passwordPolicyChecker = new PasswordPolicyChecker(
|
||||||
signInExperience.passwordPolicy,
|
signInExperience.passwordPolicy,
|
||||||
crypto.subtle
|
crypto.subtle
|
||||||
|
|
|
@ -54,7 +54,7 @@ const baseCtx = {
|
||||||
...mockSignInExperience,
|
...mockSignInExperience,
|
||||||
mfa: {
|
mfa: {
|
||||||
factors: [MfaFactor.TOTP],
|
factors: [MfaFactor.TOTP],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
passwordPolicyChecker: new PasswordPolicyChecker(
|
passwordPolicyChecker: new PasswordPolicyChecker(
|
||||||
|
|
|
@ -55,7 +55,7 @@ const baseCtx = {
|
||||||
...mockSignInExperience,
|
...mockSignInExperience,
|
||||||
mfa: {
|
mfa: {
|
||||||
factors: [MfaFactor.TOTP],
|
factors: [MfaFactor.TOTP],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
passwordPolicyChecker: new PasswordPolicyChecker(
|
passwordPolicyChecker: new PasswordPolicyChecker(
|
||||||
|
@ -75,6 +75,17 @@ const mfaRequiredCtx = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const promptOnlyAtSignInCtx = {
|
||||||
|
...baseCtx,
|
||||||
|
signInExperience: {
|
||||||
|
...mockSignInExperience,
|
||||||
|
mfa: {
|
||||||
|
factors: [MfaFactor.TOTP],
|
||||||
|
policy: MfaPolicy.PromptOnlyAtSignIn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const mfaRequiredTotpOnlyCtx = {
|
const mfaRequiredTotpOnlyCtx = {
|
||||||
...baseCtx,
|
...baseCtx,
|
||||||
signInExperience: {
|
signInExperience: {
|
||||||
|
@ -92,7 +103,7 @@ const allFactorsEnabledCtx = {
|
||||||
...mockSignInExperience,
|
...mockSignInExperience,
|
||||||
mfa: {
|
mfa: {
|
||||||
factors: [MfaFactor.TOTP, MfaFactor.WebAuthn, MfaFactor.BackupCode],
|
factors: [MfaFactor.TOTP, MfaFactor.WebAuthn, MfaFactor.BackupCode],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -142,7 +153,7 @@ describe('validateMandatoryBindMfa', () => {
|
||||||
).resolves.not.toThrow();
|
).resolves.not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bindMfa missing and not required should throw (for skip)', async () => {
|
it('bindMfa missing and policy is PromptAtSignInAndSignUp should throw (for skip)', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
validateMandatoryBindMfa(tenantContext, baseCtx, interaction)
|
validateMandatoryBindMfa(tenantContext, baseCtx, interaction)
|
||||||
).rejects.toMatchError(
|
).rejects.toMatchError(
|
||||||
|
@ -156,7 +167,13 @@ describe('validateMandatoryBindMfa', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('bindMfa missing and not required, marked as skipped should pass', async () => {
|
it('bindMfas missing and policy is PromptOnlyAtSignIn should pass', async () => {
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, promptOnlyAtSignInCtx, interaction)
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bindMfa missing and policy is PromptAtSignInAndSignUp should throw (for skip)', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
validateMandatoryBindMfa(tenantContext, baseCtx, {
|
validateMandatoryBindMfa(tenantContext, baseCtx, {
|
||||||
...interaction,
|
...interaction,
|
||||||
|
@ -182,7 +199,7 @@ describe('validateMandatoryBindMfa', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('user mfaVerifications and bindMfa missing, and not required should throw (for skip)', async () => {
|
it('user mfaVerifications and bindMfa missing, and policy is PromptAtSignInAndSignUp should throw (for skip)', async () => {
|
||||||
findUserById.mockResolvedValueOnce(mockUser);
|
findUserById.mockResolvedValueOnce(mockUser);
|
||||||
await expect(
|
await expect(
|
||||||
validateMandatoryBindMfa(tenantContext, baseCtx, signInInteraction)
|
validateMandatoryBindMfa(tenantContext, baseCtx, signInInteraction)
|
||||||
|
@ -197,6 +214,21 @@ describe('validateMandatoryBindMfa', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('user mfaVerifications and bindMfa missing, and policy is PromptOnlyAtSignIn should throw (for skip)', async () => {
|
||||||
|
findUserById.mockResolvedValueOnce(mockUser);
|
||||||
|
await expect(
|
||||||
|
validateMandatoryBindMfa(tenantContext, promptOnlyAtSignInCtx, signInInteraction)
|
||||||
|
).rejects.toMatchError(
|
||||||
|
new RequestError(
|
||||||
|
{
|
||||||
|
code: 'user.missing_mfa',
|
||||||
|
status: 422,
|
||||||
|
},
|
||||||
|
{ availableFactors: [MfaFactor.TOTP], skippable: true }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('user mfaVerifications and bindMfa missing, mark skipped, and not required should pass', async () => {
|
it('user mfaVerifications and bindMfa missing, mark skipped, and not required should pass', async () => {
|
||||||
findUserById.mockResolvedValueOnce({
|
findUserById.mockResolvedValueOnce({
|
||||||
...mockUser,
|
...mockUser,
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
/* eslint-disable complexity */
|
||||||
import {
|
import {
|
||||||
InteractionEvent,
|
InteractionEvent,
|
||||||
MfaFactor,
|
MfaFactor,
|
||||||
|
@ -147,13 +148,19 @@ export const validateMandatoryBindMfa = async (
|
||||||
const { event, bindMfas } = interaction;
|
const { event, bindMfas } = interaction;
|
||||||
const availableFactors = factors.filter((factor) => factor !== MfaFactor.BackupCode);
|
const availableFactors = factors.filter((factor) => factor !== MfaFactor.BackupCode);
|
||||||
|
|
||||||
// No available MFA, skip check
|
// No available MFA, or no prompt policy, skip check
|
||||||
if (availableFactors.length === 0) {
|
if (availableFactors.length === 0 || policy === MfaPolicy.NoPrompt) {
|
||||||
return interaction;
|
return interaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the policy is not mandatory and the user has skipped MFA, skip check
|
||||||
const { mfaSkipped } = interaction;
|
const { mfaSkipped } = interaction;
|
||||||
if (policy === MfaPolicy.UserControlled && mfaSkipped) {
|
if (policy !== MfaPolicy.Mandatory && mfaSkipped) {
|
||||||
|
return interaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the policy is prompt only at sign-in, and the event is register, skip check
|
||||||
|
if (interaction.event === InteractionEvent.Register && policy === MfaPolicy.PromptOnlyAtSignIn) {
|
||||||
return interaction;
|
return interaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -268,3 +275,4 @@ export const validateBindMfaBackupCode = async (
|
||||||
{ codes }
|
{ codes }
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
/* eslint-enable complexity */
|
||||||
|
|
|
@ -109,7 +109,7 @@ export const mockSignInExperience: SignInExperience = {
|
||||||
customUiAssets: null,
|
customUiAssets: null,
|
||||||
passwordPolicy: {},
|
passwordPolicy: {},
|
||||||
mfa: {
|
mfa: {
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
factors: [],
|
factors: [],
|
||||||
},
|
},
|
||||||
singleSignOnEnabled: true,
|
singleSignOnEnabled: true,
|
||||||
|
@ -146,7 +146,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
|
||||||
customUiAssets: null,
|
customUiAssets: null,
|
||||||
passwordPolicy: {},
|
passwordPolicy: {},
|
||||||
mfa: {
|
mfa: {
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
factors: [],
|
factors: [],
|
||||||
},
|
},
|
||||||
isDevelopmentTenant: false,
|
isDevelopmentTenant: false,
|
||||||
|
|
|
@ -109,7 +109,7 @@ export const mockSignInExperience: SignInExperience = {
|
||||||
customUiAssets: null,
|
customUiAssets: null,
|
||||||
passwordPolicy: {},
|
passwordPolicy: {},
|
||||||
mfa: {
|
mfa: {
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
factors: [],
|
factors: [],
|
||||||
},
|
},
|
||||||
singleSignOnEnabled: true,
|
singleSignOnEnabled: true,
|
||||||
|
@ -146,7 +146,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
|
||||||
customUiAssets: null,
|
customUiAssets: null,
|
||||||
passwordPolicy: {},
|
passwordPolicy: {},
|
||||||
mfa: {
|
mfa: {
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
factors: [],
|
factors: [],
|
||||||
},
|
},
|
||||||
isDevelopmentTenant: false,
|
isDevelopmentTenant: false,
|
||||||
|
|
|
@ -81,7 +81,7 @@ export const enableAllPasswordSignInMethods = async (
|
||||||
signIn: {
|
signIn: {
|
||||||
methods: defaultPasswordSignInMethods,
|
methods: defaultPasswordSignInMethods,
|
||||||
},
|
},
|
||||||
mfa: { factors: [], policy: MfaPolicy.UserControlled },
|
mfa: { factors: [], policy: MfaPolicy.PromptAtSignInAndSignUp },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetPasswordPolicy = async () =>
|
export const resetPasswordPolicy = async () =>
|
||||||
|
@ -102,14 +102,30 @@ export const enableAllVerificationCodeSignInMethods = async (
|
||||||
signIn: {
|
signIn: {
|
||||||
methods: defaultVerificationCodeSignInMethods,
|
methods: defaultVerificationCodeSignInMethods,
|
||||||
},
|
},
|
||||||
mfa: { factors: [], policy: MfaPolicy.UserControlled },
|
mfa: { factors: [], policy: MfaPolicy.PromptAtSignInAndSignUp },
|
||||||
});
|
});
|
||||||
|
|
||||||
export const enableUserControlledMfaWithTotp = async () =>
|
export const enableUserControlledMfaWithTotp = async () =>
|
||||||
updateSignInExperience({
|
updateSignInExperience({
|
||||||
mfa: {
|
mfa: {
|
||||||
factors: [MfaFactor.TOTP],
|
factors: [MfaFactor.TOTP],
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enableUserControlledMfaWithTotpOnlyAtSignIn = async () =>
|
||||||
|
updateSignInExperience({
|
||||||
|
mfa: {
|
||||||
|
factors: [MfaFactor.TOTP],
|
||||||
|
policy: MfaPolicy.PromptOnlyAtSignIn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const enableUserControlledMfaWithNoPrompt = async () =>
|
||||||
|
updateSignInExperience({
|
||||||
|
mfa: {
|
||||||
|
factors: [MfaFactor.TOTP],
|
||||||
|
policy: MfaPolicy.NoPrompt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -146,7 +162,7 @@ export const enableMandatoryMfaWithWebAuthnAndBackupCode = async () =>
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resetMfaSettings = async () =>
|
export const resetMfaSettings = async () =>
|
||||||
updateSignInExperience({ mfa: { policy: MfaPolicy.UserControlled, factors: [] } });
|
updateSignInExperience({ mfa: { policy: MfaPolicy.PromptAtSignInAndSignUp, factors: [] } });
|
||||||
|
|
||||||
/** Enable only username and password sign-in and sign-up. */
|
/** Enable only username and password sign-in and sign-up. */
|
||||||
export const setUsernamePasswordOnly = async () => {
|
export const setUsernamePasswordOnly = async () => {
|
||||||
|
|
|
@ -16,7 +16,9 @@ import {
|
||||||
enableAllPasswordSignInMethods,
|
enableAllPasswordSignInMethods,
|
||||||
enableMandatoryMfaWithTotp,
|
enableMandatoryMfaWithTotp,
|
||||||
enableMandatoryMfaWithTotpAndBackupCode,
|
enableMandatoryMfaWithTotpAndBackupCode,
|
||||||
|
enableUserControlledMfaWithNoPrompt,
|
||||||
enableUserControlledMfaWithTotp,
|
enableUserControlledMfaWithTotp,
|
||||||
|
enableUserControlledMfaWithTotpOnlyAtSignIn,
|
||||||
} from '#src/helpers/sign-in-experience.js';
|
} from '#src/helpers/sign-in-experience.js';
|
||||||
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
|
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
|
||||||
|
|
||||||
|
@ -122,7 +124,7 @@ describe('Bind MFA APIs happy path', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('user controlled TOTP', () => {
|
describe('TOTP with policy PromptAtSignInAndSignUp', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await enableUserControlledMfaWithTotp();
|
await enableUserControlledMfaWithTotp();
|
||||||
});
|
});
|
||||||
|
@ -183,6 +185,86 @@ describe('Bind MFA APIs happy path', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TOTP with policy PromptOnlyAtSignIn', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await enableUserControlledMfaWithTotpOnlyAtSignIn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should able to register without MFA', async () => {
|
||||||
|
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||||
|
const client = await initExperienceClient(InteractionEvent.Register);
|
||||||
|
|
||||||
|
const { verificationId } = await client.createNewPasswordIdentityVerification({
|
||||||
|
identifier: {
|
||||||
|
type: SignInIdentifier.Username,
|
||||||
|
value: username,
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.identifyUser({ verificationId });
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
const userId = await processSession(client, redirectTo);
|
||||||
|
await logoutClient(client);
|
||||||
|
await deleteUser(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should able to skip MFA binding on sign-in', async () => {
|
||||||
|
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||||
|
await userApi.create({ username, password });
|
||||||
|
|
||||||
|
const client = await initExperienceClient();
|
||||||
|
await identifyUserWithUsernamePassword(client, username, password);
|
||||||
|
|
||||||
|
await expectRejects(client.submitInteraction(), {
|
||||||
|
code: 'user.missing_mfa',
|
||||||
|
status: 422,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.skipMfaBinding();
|
||||||
|
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
await processSession(client, redirectTo);
|
||||||
|
await logoutClient(client);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TOTP with policy NoPrompt', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await enableUserControlledMfaWithNoPrompt();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should able to register without MFA', async () => {
|
||||||
|
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||||
|
const client = await initExperienceClient(InteractionEvent.Register);
|
||||||
|
|
||||||
|
const { verificationId } = await client.createNewPasswordIdentityVerification({
|
||||||
|
identifier: {
|
||||||
|
type: SignInIdentifier.Username,
|
||||||
|
value: username,
|
||||||
|
},
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.identifyUser({ verificationId });
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
const userId = await processSession(client, redirectTo);
|
||||||
|
await logoutClient(client);
|
||||||
|
await deleteUser(userId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should able to sign-in without MFA', async () => {
|
||||||
|
const { username, password } = generateNewUserProfile({ username: true, password: true });
|
||||||
|
await userApi.create({ username, password });
|
||||||
|
|
||||||
|
const client = await initExperienceClient();
|
||||||
|
await identifyUserWithUsernamePassword(client, username, password);
|
||||||
|
const { redirectTo } = await client.submitInteraction();
|
||||||
|
await processSession(client, redirectTo);
|
||||||
|
await logoutClient(client);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mandatory TOTP with backup codes', () => {
|
describe('mandatory TOTP with backup codes', () => {
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await enableMandatoryMfaWithTotpAndBackupCode();
|
await enableMandatoryMfaWithTotpAndBackupCode();
|
||||||
|
|
|
@ -32,7 +32,7 @@ describe('admin console sign-in experience', () => {
|
||||||
termsOfUseUrl: 'mock://fake-url/terms',
|
termsOfUseUrl: 'mock://fake-url/terms',
|
||||||
privacyPolicyUrl: 'mock://fake-url/privacy',
|
privacyPolicyUrl: 'mock://fake-url/privacy',
|
||||||
mfa: {
|
mfa: {
|
||||||
policy: MfaPolicy.UserControlled,
|
policy: MfaPolicy.PromptAtSignInAndSignUp,
|
||||||
factors: [],
|
factors: [],
|
||||||
},
|
},
|
||||||
singleSignOnEnabled: true,
|
singleSignOnEnabled: true,
|
||||||
|
|
|
@ -28,6 +28,15 @@ const mfa = {
|
||||||
mandatory: 'Users are always required to use MFA at sign-in',
|
mandatory: 'Users are always required to use MFA at sign-in',
|
||||||
mandatory_tip:
|
mandatory_tip:
|
||||||
'Users must set up MFA the first time at sign-in or sign-up, and use it for all future sign-ins.',
|
'Users must set up MFA the first time at sign-in or sign-up, and use it for all future sign-ins.',
|
||||||
|
require_mfa: 'Require MFA',
|
||||||
|
require_mfa_label:
|
||||||
|
'Enable this to make 2-step verification mandatory for accessing your applications. If disabled, users can decide whether to enable MFA for themselves.',
|
||||||
|
set_up_prompt: 'MFA set-up prompt',
|
||||||
|
no_prompt: 'Do not ask users to set up MFA',
|
||||||
|
prompt_at_sign_in_and_sign_up:
|
||||||
|
'Ask users to set up MFA during registration (skippable, one-time prompt)',
|
||||||
|
prompt_only_at_sign_in:
|
||||||
|
'Ask users to set up MFA on their sign-in after registration (skippable, one-time prompt)',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Object.freeze(mfa);
|
export default Object.freeze(mfa);
|
||||||
|
|
|
@ -105,8 +105,16 @@ export const mfaFactorsGuard = z.nativeEnum(MfaFactor).array();
|
||||||
export type MfaFactors = z.infer<typeof mfaFactorsGuard>;
|
export type MfaFactors = z.infer<typeof mfaFactorsGuard>;
|
||||||
|
|
||||||
export enum MfaPolicy {
|
export enum MfaPolicy {
|
||||||
|
/** @deprecated, use `PromptAtSignInAndSignUp` instead */
|
||||||
UserControlled = 'UserControlled',
|
UserControlled = 'UserControlled',
|
||||||
|
/** MFA is required for all users */
|
||||||
Mandatory = 'Mandatory',
|
Mandatory = 'Mandatory',
|
||||||
|
/** Ask users to set up MFA on their sign-in after registration (skippable, one-time prompt) */
|
||||||
|
PromptOnlyAtSignIn = 'PromptOnlyAtSignIn',
|
||||||
|
/** Ask users to set up MFA during registration (skippable, one-time prompt) */
|
||||||
|
PromptAtSignInAndSignUp = 'PromptAtSignInAndSignUp',
|
||||||
|
/** Do not ask users to set up MFA */
|
||||||
|
NoPrompt = 'NoPrompt',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mfaGuard = z.object({
|
export const mfaGuard = z.object({
|
||||||
|
|
Loading…
Reference in a new issue