0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core,console): new mfa prompt policy (#6880)

This commit is contained in:
wangsijie 2024-12-17 19:51:09 +04:00 committed by GitHub
parent bbbfd01d7b
commit f1b1d9e95a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 297 additions and 67 deletions

View file

@ -0,0 +1,24 @@
---
"@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.
First, choose if you want to enable **Require MFA**:
- **Enable**: Users will be prompted to set up MFA during the sign-in process which cannot be skipped. If the user fails to set up MFA or deletes their MFA settings, they will be locked out of their account until they set up MFA again.
- **Disable**: Users can skip the MFA setup process during sign-up flow.
If you choose to **Disable**, you can choose the MFA setup prompt:
- Do not ask users to set up MFA.
- Ask users to set up MFA during registration (skippable, one-time prompt). **The same prompt as previous policy (UserControlled)**
- Ask users to set up MFA on their sign-in after registration (skippable, one-time prompt)

View file

@ -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',
};

View file

@ -13,7 +13,7 @@ import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import DynamicT from '@/ds-components/DynamicT';
import FormField from '@/ds-components/FormField';
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 useApi from '@/hooks/use-api';
import useDocumentationUrl from '@/hooks/use-documentation-url';
@ -24,7 +24,6 @@ import { type MfaConfigForm, type MfaConfig } from '../types';
import FactorLabel from './FactorLabel';
import UpsellNotice from './UpsellNotice';
import { policyOptionTitleMap } from './constants';
import styles from './index.module.scss';
import { convertMfaFormToConfig, convertMfaConfigToForm, validateBackupCodeFactor } from './utils';
@ -69,6 +68,24 @@ function MfaForm({ data, onMfaUpdated }: Props) {
return factors.length === 0;
}, [formValues, isMfaDisabled]);
const mfaPolicyOptions = useMemo(
() => [
{
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'),
},
],
[t]
);
const onSubmit = handleSubmit(
trySubmitSafe(async (formData) => {
const mfaConfig = convertMfaFormToConfig(formData);
@ -143,28 +160,37 @@ function MfaForm({ data, onMfaUpdated }: Props) {
/>
)}
</FormCard>
<FormCard title="mfa.policy">
<FormField title="mfa.two_step_sign_in_policy" headlineSpacing="large">
<Controller
control={control}
name="policy"
render={({ field: { onChange, value, name } }) => (
<RadioGroup name={name} value={value} onChange={onChange}>
{Object.values(MfaPolicy).map((policy) => {
const title = policyOptionTitleMap[policy];
return (
<Radio
key={policy}
isDisabled={isPolicySettingsDisabled}
title={title}
value={policy}
/>
);
})}
</RadioGroup>
)}
<FormCard
title="mfa.policy"
description="mfa.policy_description"
learnMoreLink={{
href: getDocumentationUrl('/docs/recipes/multi-factor-auth/configure-mfa'),
targetBlank: 'noopener',
}}
>
<FormField title="mfa.require_mfa" headlineSpacing="large">
<Switch
disabled={isPolicySettingsDisabled}
label={t('mfa.require_mfa_label')}
{...register('isMandatory')}
/>
</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>
</DetailsForm>
<UnsavedChangesAlertModal hasUnsavedChanges={isDirty} />

View file

@ -1,17 +1,24 @@
import { MfaFactor } from '@logto/schemas';
import { MfaFactor, MfaPolicy } from '@logto/schemas';
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 => ({
policy,
isMandatory: policy === MfaPolicy.Mandatory,
setUpPrompt: isSignInPrompt(policy) ? policy : MfaPolicy.PromptAtSignInAndSignUp,
totpEnabled: factors.includes(MfaFactor.TOTP),
webAuthnEnabled: factors.includes(MfaFactor.WebAuthn),
backupCodeEnabled: factors.includes(MfaFactor.BackupCode),
});
export const convertMfaFormToConfig = (mfaConfigForm: MfaConfigForm): MfaConfig => {
const { policy, totpEnabled, webAuthnEnabled, backupCodeEnabled } = mfaConfigForm;
const { isMandatory, setUpPrompt, totpEnabled, webAuthnEnabled, backupCodeEnabled } =
mfaConfigForm;
const factors = [
conditional(totpEnabled && MfaFactor.TOTP),
@ -21,7 +28,7 @@ export const convertMfaFormToConfig = (mfaConfigForm: MfaConfigForm): MfaConfig
].filter((factor): factor is MfaFactor => Boolean(factor));
return {
policy,
policy: isMandatory ? MfaPolicy.Mandatory : setUpPrompt,
factors,
};
};

View file

@ -1,10 +1,12 @@
import { type MfaPolicy, type SignInExperience } from '@logto/schemas';
export type MfaConfig = SignInExperience['mfa'];
export type SignInPrompt = Exclude<MfaPolicy, MfaPolicy.UserControlled | MfaPolicy.Mandatory>;
export type MfaConfigForm = {
policy: MfaPolicy;
totpEnabled: boolean;
webAuthnEnabled: boolean;
backupCodeEnabled: boolean;
isMandatory: boolean;
setUpPrompt: SignInPrompt;
};

View file

@ -95,7 +95,7 @@ export const mockSignInExperience: SignInExperience = {
customUiAssets: null,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
factors: [],
},
singleSignOnEnabled: true,

View file

@ -10,7 +10,7 @@ describe('validate mfa', () => {
expect(() => {
validateMfa({
factors: [],
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
});
}).not.toThrow();
});
@ -19,7 +19,7 @@ describe('validate mfa', () => {
expect(() => {
validateMfa({
factors: [MfaFactor.TOTP],
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
});
}).not.toThrow();
});
@ -28,7 +28,7 @@ describe('validate mfa', () => {
expect(() => {
validateMfa({
factors: [MfaFactor.TOTP, MfaFactor.BackupCode],
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
});
}).not.toThrow();
});
@ -38,7 +38,7 @@ describe('validate mfa', () => {
expect(() => {
validateMfa({
factors: [MfaFactor.BackupCode],
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
});
}).toMatchError(new RequestError('sign_in_experiences.backup_code_cannot_be_enabled_alone'));
});
@ -47,7 +47,7 @@ describe('validate mfa', () => {
expect(() => {
validateMfa({
factors: [MfaFactor.TOTP, MfaFactor.TOTP],
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
});
}).toMatchError(new RequestError('sign_in_experiences.duplicated_mfa_factors'));
});

View file

@ -7,6 +7,7 @@ import {
bindTotpGuard,
type BindWebAuthn,
bindWebAuthnGuard,
InteractionEvent,
type JsonObject,
MfaFactor,
MfaPolicy,
@ -152,7 +153,7 @@ export class Mfa {
const { policy } = await this.signInExperienceValidator.getMfaSettings();
assertThat(
policy === MfaPolicy.UserControlled,
policy !== MfaPolicy.Mandatory,
new RequestError({
code: 'session.mfa.mfa_policy_not_user_controlled',
status: 422,
@ -278,9 +279,21 @@ export class Mfa {
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
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if ((policy === MfaPolicy.UserControlled && this.#mfaSkipped) || isMfaSkipped(logtoConfig)) {
// If the policy is no prompt, then there is nothing to check
if (policy === MfaPolicy.NoPrompt) {
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;
}

View file

@ -171,7 +171,7 @@ export default function mfaRoutes<T extends IRouterParamContext>(
} = ctx;
assertThat(
policy === MfaPolicy.UserControlled,
policy !== MfaPolicy.Mandatory,
new RequestError({
code: 'session.mfa.mfa_policy_not_user_controlled',
status: 422,

View file

@ -1,7 +1,7 @@
import crypto from 'node:crypto';
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 IRouterParamContext } from 'koa-router';
@ -24,7 +24,17 @@ export default function koaInteractionSie<StateT, ContextT extends IRouterParamC
return async (ctx, next) => {
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(
signInExperience.passwordPolicy,
crypto.subtle

View file

@ -54,7 +54,7 @@ const baseCtx = {
...mockSignInExperience,
mfa: {
factors: [MfaFactor.TOTP],
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
},
},
passwordPolicyChecker: new PasswordPolicyChecker(

View file

@ -55,7 +55,7 @@ const baseCtx = {
...mockSignInExperience,
mfa: {
factors: [MfaFactor.TOTP],
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
},
},
passwordPolicyChecker: new PasswordPolicyChecker(
@ -75,6 +75,17 @@ const mfaRequiredCtx = {
},
};
const promptOnlyAtSignInCtx = {
...baseCtx,
signInExperience: {
...mockSignInExperience,
mfa: {
factors: [MfaFactor.TOTP],
policy: MfaPolicy.PromptOnlyAtSignIn,
},
},
};
const mfaRequiredTotpOnlyCtx = {
...baseCtx,
signInExperience: {
@ -92,7 +103,7 @@ const allFactorsEnabledCtx = {
...mockSignInExperience,
mfa: {
factors: [MfaFactor.TOTP, MfaFactor.WebAuthn, MfaFactor.BackupCode],
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
},
},
};
@ -142,7 +153,7 @@ describe('validateMandatoryBindMfa', () => {
).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(
validateMandatoryBindMfa(tenantContext, baseCtx, interaction)
).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(
validateMandatoryBindMfa(tenantContext, baseCtx, {
...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);
await expect(
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 () => {
findUserById.mockResolvedValueOnce({
...mockUser,

View file

@ -1,3 +1,4 @@
/* eslint-disable complexity */
import {
InteractionEvent,
MfaFactor,
@ -147,13 +148,19 @@ export const validateMandatoryBindMfa = async (
const { event, bindMfas } = interaction;
const availableFactors = factors.filter((factor) => factor !== MfaFactor.BackupCode);
// No available MFA, skip check
if (availableFactors.length === 0) {
// No available MFA, or no prompt policy, skip check
if (availableFactors.length === 0 || policy === MfaPolicy.NoPrompt) {
return interaction;
}
// If the policy is not mandatory and the user has skipped MFA, skip check
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;
}
@ -268,3 +275,4 @@ export const validateBindMfaBackupCode = async (
{ codes }
);
};
/* eslint-enable complexity */

View file

@ -109,7 +109,7 @@ export const mockSignInExperience: SignInExperience = {
customUiAssets: null,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
factors: [],
},
singleSignOnEnabled: true,
@ -146,7 +146,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
customUiAssets: null,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
factors: [],
},
isDevelopmentTenant: false,

View file

@ -109,7 +109,7 @@ export const mockSignInExperience: SignInExperience = {
customUiAssets: null,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
factors: [],
},
singleSignOnEnabled: true,
@ -146,7 +146,7 @@ export const mockSignInExperienceSettings: SignInExperienceResponse = {
customUiAssets: null,
passwordPolicy: {},
mfa: {
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
factors: [],
},
isDevelopmentTenant: false,

View file

@ -81,7 +81,7 @@ export const enableAllPasswordSignInMethods = async (
signIn: {
methods: defaultPasswordSignInMethods,
},
mfa: { factors: [], policy: MfaPolicy.UserControlled },
mfa: { factors: [], policy: MfaPolicy.PromptAtSignInAndSignUp },
});
export const resetPasswordPolicy = async () =>
@ -102,14 +102,30 @@ export const enableAllVerificationCodeSignInMethods = async (
signIn: {
methods: defaultVerificationCodeSignInMethods,
},
mfa: { factors: [], policy: MfaPolicy.UserControlled },
mfa: { factors: [], policy: MfaPolicy.PromptAtSignInAndSignUp },
});
export const enableUserControlledMfaWithTotp = async () =>
updateSignInExperience({
mfa: {
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 () =>
updateSignInExperience({ mfa: { policy: MfaPolicy.UserControlled, factors: [] } });
updateSignInExperience({ mfa: { policy: MfaPolicy.PromptAtSignInAndSignUp, factors: [] } });
/** Enable only username and password sign-in and sign-up. */
export const setUsernamePasswordOnly = async () => {

View file

@ -16,7 +16,9 @@ import {
enableAllPasswordSignInMethods,
enableMandatoryMfaWithTotp,
enableMandatoryMfaWithTotpAndBackupCode,
enableUserControlledMfaWithNoPrompt,
enableUserControlledMfaWithTotp,
enableUserControlledMfaWithTotpOnlyAtSignIn,
} from '#src/helpers/sign-in-experience.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 () => {
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', () => {
beforeAll(async () => {
await enableMandatoryMfaWithTotpAndBackupCode();

View file

@ -32,7 +32,7 @@ describe('admin console sign-in experience', () => {
termsOfUseUrl: 'mock://fake-url/terms',
privacyPolicyUrl: 'mock://fake-url/privacy',
mfa: {
policy: MfaPolicy.UserControlled,
policy: MfaPolicy.PromptAtSignInAndSignUp,
factors: [],
},
singleSignOnEnabled: true,

View file

@ -28,6 +28,15 @@ const mfa = {
mandatory: 'Users are always required to use MFA at sign-in',
mandatory_tip:
'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);

View file

@ -105,8 +105,16 @@ export const mfaFactorsGuard = z.nativeEnum(MfaFactor).array();
export type MfaFactors = z.infer<typeof mfaFactorsGuard>;
export enum MfaPolicy {
/** @deprecated, use `PromptAtSignInAndSignUp` instead */
UserControlled = 'UserControlled',
/** MFA is required for all users */
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({