0
Fork 0
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:
wangsijie 2024-12-12 14:40:50 +08:00
parent 2178589507
commit a38031caf8
No known key found for this signature in database
GPG key ID: F95DE0D0DDB952CF
20 changed files with 289 additions and 67 deletions

View 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.

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,21 @@ function MfaForm({ data, onMfaUpdated }: Props) {
return factors.length === 0;
}, [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(
trySubmitSafe(async (formData) => {
const mfaConfig = convertMfaFormToConfig(formData);
@ -143,28 +157,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({