mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-18 17:05:03 -05:00
fix: prevent double triggering of verification emails (#11995)
# Which Problems Are Solved This fixes a critical bug where initial verification emails (or invite codes) were occasionally sent twice, invalidating the first code and confusing users. Previously, the initial verification email was triggered via a `send=true` URL parameter executing inside a frontend useEffect exactly when the `/verify` page mounted. This was fragile and prone to race conditions caused by component remounts or partial hydration. # How the Problems Are Solved - Removed `send=true` from URL state and ripped out the doSend effect in `VerifyForm.tsx`. - Shifted execution strictly to the Next.js server. The email is now automatically dispatched via await `initialSendVerification(...)` during the POST requests (acting over `sendLoginname`, `register`, `password`, `passkeys`, and `idp`). - The login flow is now idempotent and robust against unintended frontend re-renders. - Refactored `checkEmailVerification()` to be async and updated the associated unit-test coverage (all tests passing).
This commit is contained in:
@@ -21,7 +21,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const { userId, loginName, code, organization, requestId, invite, send } = searchParams;
|
||||
const { userId, loginName, code, organization, requestId, invite } = searchParams;
|
||||
|
||||
const _headers = await headers();
|
||||
const { serviceConfig } = getServiceConfig(_headers);
|
||||
@@ -34,8 +34,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
let id: string | undefined;
|
||||
let loginSettings: LoginSettings | undefined;
|
||||
|
||||
const doSend = send === "true";
|
||||
|
||||
const autoSubmitCode = process.env.NEXT_PUBLIC_AUTO_SUBMIT_CODE === "true";
|
||||
|
||||
if ("loginName" in searchParams) {
|
||||
@@ -162,7 +160,6 @@ export default async function Page(props: { searchParams: Promise<any> }) {
|
||||
isInvite={invite === "true"}
|
||||
requestId={requestId}
|
||||
submit={autoSubmitCode}
|
||||
doSend={doSend}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { VerifyForm } from "./verify-form";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/verify",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
import { Alert, AlertType } from "@/components/alert";
|
||||
import { handleServerActionResponse } from "@/lib/client-utils";
|
||||
import { UNKNOWN_USER_ID } from "@/lib/constants";
|
||||
import { initialSendVerification, resendVerification, sendVerification } from "@/lib/server/verify";
|
||||
import { resendVerification, sendVerification } from "@/lib/server/verify";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { AutoSubmitForm } from "./auto-submit-form";
|
||||
@@ -27,11 +27,13 @@ type Props = {
|
||||
isInvite: boolean;
|
||||
requestId?: string;
|
||||
submit: boolean;
|
||||
doSend?: boolean;
|
||||
};
|
||||
|
||||
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit, doSend }: Props) {
|
||||
export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit }: Props) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const codeSent = searchParams.get("codeSent") === "true";
|
||||
|
||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||
mode: "onChange",
|
||||
@@ -47,27 +49,8 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const initialSendDone = useRef(false);
|
||||
const [initialSendError, setInitialSendError] = useState<string>("");
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (doSend && userId && userId !== UNKNOWN_USER_ID && !initialSendDone.current) {
|
||||
initialSendDone.current = true;
|
||||
setError("");
|
||||
initialSendVerification({ userId, isInvite, requestId })
|
||||
.then(() => {
|
||||
setCodeSent(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setInitialSendError(isInvite ? t("errors.couldNotResendInvite") : t("errors.couldNotResendEmail"));
|
||||
});
|
||||
}
|
||||
}, [doSend, userId, isInvite, requestId, t]);
|
||||
|
||||
async function resendCode() {
|
||||
setError("");
|
||||
setInitialSendError("");
|
||||
setLoading(true);
|
||||
|
||||
// do not send code for dummy userid that is set to prevent user enumeration
|
||||
@@ -95,6 +78,11 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
return;
|
||||
}
|
||||
|
||||
// Signal success via URL search param so the "code sent" alert is shown
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set("codeSent", "true");
|
||||
router.replace(`${pathname}?${params.toString()}`);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -103,7 +91,6 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
const fcn = useCallback(
|
||||
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
|
||||
setError("");
|
||||
setInitialSendError("");
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
@@ -136,7 +123,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
return (
|
||||
<>
|
||||
{samlData && <AutoSubmitForm url={samlData.url} fields={samlData.fields} />}
|
||||
{codeSent && !initialSendError && (
|
||||
{codeSent && (
|
||||
<div className="w-full py-4">
|
||||
<Alert type={AlertType.INFO}>
|
||||
<Translated i18nKey="verify.codeSent" namespace="verify" />
|
||||
@@ -174,9 +161,9 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(error || initialSendError) && (
|
||||
{error && (
|
||||
<div className="py-4" data-testid="error">
|
||||
<Alert>{error || initialSendError}</Alert>
|
||||
<Alert>{error}</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { timestampDate } from "@zitadel/client";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { getUserByID, ServiceConfig } from "./zitadel";
|
||||
|
||||
export async function checkMFAFactors(
|
||||
serviceConfig: ServiceConfig,
|
||||
session: Session,
|
||||
loginSettings: LoginSettings | undefined,
|
||||
authMethods: AuthenticationMethodType[],
|
||||
organization?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
const availableMultiFactors = authMethods?.filter(
|
||||
(m: AuthenticationMethodType) =>
|
||||
m === AuthenticationMethodType.TOTP ||
|
||||
m === AuthenticationMethodType.OTP_SMS ||
|
||||
m === AuthenticationMethodType.OTP_EMAIL ||
|
||||
m === AuthenticationMethodType.U2F,
|
||||
);
|
||||
|
||||
const hasAuthenticatedWithPasskey = session.factors?.webAuthN?.verifiedAt && session.factors?.webAuthN?.userVerified;
|
||||
|
||||
// escape further checks if user has authenticated with passkey
|
||||
if (hasAuthenticatedWithPasskey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if user has not authenticated with passkey and has only one additional mfa factor, redirect to that
|
||||
if (availableMultiFactors?.length == 1) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
});
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
|
||||
const factor = availableMultiFactors[0];
|
||||
// if passkey is other method, but user selected password as alternative, perform a login
|
||||
if (factor === AuthenticationMethodType.TOTP) {
|
||||
return { redirect: `/otp/time-based?` + params };
|
||||
} else if (factor === AuthenticationMethodType.OTP_SMS) {
|
||||
return { redirect: `/otp/sms?` + params };
|
||||
} else if (factor === AuthenticationMethodType.OTP_EMAIL) {
|
||||
return { redirect: `/otp/email?` + params };
|
||||
} else if (factor === AuthenticationMethodType.U2F) {
|
||||
return { redirect: `/u2f?` + params };
|
||||
}
|
||||
} else if (availableMultiFactors?.length > 1) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
});
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
|
||||
return { redirect: `/mfa?` + params };
|
||||
} else if (shouldEnforceMFA(session, loginSettings) && !availableMultiFactors.length) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
force: "true", // this defines if the mfa is forced in the settings
|
||||
checkAfter: "true", // this defines if the check is directly made after the setup
|
||||
});
|
||||
|
||||
if (session.id) {
|
||||
params.append("sessionId", session.id);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
|
||||
// TODO: provide a way to setup passkeys on mfa page?
|
||||
return { redirect: `/mfa/set?` + params };
|
||||
/* eslint-disable no-dupe-else-if -- TODO: this branch is unreachable, conditions overlap with the previous branch */
|
||||
} else if (
|
||||
loginSettings?.mfaInitSkipLifetime &&
|
||||
(loginSettings.mfaInitSkipLifetime.nanos > 0 || loginSettings.mfaInitSkipLifetime.seconds > 0) &&
|
||||
!availableMultiFactors.length &&
|
||||
session?.factors?.user?.id &&
|
||||
shouldEnforceMFA(session, loginSettings)
|
||||
) {
|
||||
/* eslint-enable no-dupe-else-if */
|
||||
const userResponse = await getUserByID({ serviceConfig, userId: session.factors?.user?.id });
|
||||
|
||||
const humanUser = userResponse?.user?.type.case === "human" ? userResponse?.user.type.value : undefined;
|
||||
|
||||
if (humanUser?.mfaInitSkipped) {
|
||||
const mfaInitSkippedTimestamp = timestampDate(humanUser.mfaInitSkipped);
|
||||
|
||||
const mfaInitSkipLifetimeMillis =
|
||||
Number(loginSettings.mfaInitSkipLifetime.seconds) * 1000 + loginSettings.mfaInitSkipLifetime.nanos / 1000000;
|
||||
const currentTime = Date.now();
|
||||
const mfaInitSkippedTime = mfaInitSkippedTimestamp.getTime();
|
||||
const timeDifference = currentTime - mfaInitSkippedTime;
|
||||
|
||||
if (!(timeDifference > mfaInitSkipLifetimeMillis)) {
|
||||
// if the time difference is smaller than the lifetime, skip the mfa setup
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// the user has never skipped the mfa init but we have a setting so we redirect
|
||||
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
force: "false", // this defines if the mfa is not forced in the settings and can be skipped
|
||||
checkAfter: "true", // this defines if the check is directly made after the setup
|
||||
});
|
||||
|
||||
if (session.id) {
|
||||
params.append("sessionId", session.id);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
|
||||
// TODO: provide a way to setup passkeys on mfa page?
|
||||
return { redirect: `/mfa/set?` + params };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if MFA should be enforced based on the authentication method used and login settings
|
||||
* @param session - The current session
|
||||
* @param loginSettings - The login settings containing MFA enforcement rules
|
||||
* @returns true if MFA should be enforced, false otherwise
|
||||
*/
|
||||
export function shouldEnforceMFA(session: Session, loginSettings: LoginSettings | undefined): boolean {
|
||||
if (!loginSettings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user authenticated with passkey (passkeys are inherently multi-factor)
|
||||
const authenticatedWithPasskey = session.factors?.webAuthN?.verifiedAt && session.factors?.webAuthN?.userVerified;
|
||||
|
||||
// If user authenticated with passkey, MFA is not required regardless of settings
|
||||
if (authenticatedWithPasskey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If forceMfa is enabled, MFA is required for ALL authentication methods (except passkeys)
|
||||
if (loginSettings.forceMfa) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If forceMfaLocalOnly is enabled, MFA is only required for local/password authentication
|
||||
if (loginSettings.forceMfaLocalOnly) {
|
||||
// Check if user authenticated with password (local authentication)
|
||||
const authenticatedWithPassword = !!session.factors?.password?.verifiedAt;
|
||||
|
||||
// Check if user authenticated with IDP (external authentication)
|
||||
const authenticatedWithIDP = !!session.factors?.intent?.verifiedAt;
|
||||
|
||||
// If user authenticated with IDP, MFA is not required for forceMfaLocalOnly
|
||||
if (authenticatedWithIDP) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user authenticated with password, MFA is required for forceMfaLocalOnly
|
||||
if (authenticatedWithPassword) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -168,7 +168,7 @@ export async function createNewSessionFromIdpIntent(command: CreateNewSessionCom
|
||||
const humanUser = userResponse.user.type.case === "human" ? userResponse.user.type.value : undefined;
|
||||
|
||||
// check to see if user was verified
|
||||
const emailVerificationCheck = checkEmailVerification(session, humanUser, command.organization, command.requestId);
|
||||
const emailVerificationCheck = await checkEmailVerification(session, humanUser, command.organization, command.requestId);
|
||||
|
||||
if (emailVerificationCheck?.redirect) {
|
||||
return emailVerificationCheck;
|
||||
|
||||
@@ -43,6 +43,10 @@ vi.mock("./host", () => ({
|
||||
getPublicHost: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./verify", () => ({
|
||||
trySendVerification: vi.fn(() => Promise.resolve(false)),
|
||||
}));
|
||||
|
||||
// this returns the key itself that can be checked not the translated value
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: vi.fn(() => (key: string) => key),
|
||||
@@ -198,7 +202,7 @@ describe("sendLoginname", () => {
|
||||
mockCreateSessionAndUpdateCookie.mockResolvedValue({ session: mockSession, sessionCookie: {} });
|
||||
});
|
||||
|
||||
test("should redirect to verify with send=false when user has no authentication methods and email is unverified", async () => {
|
||||
test("should redirect to verify without codeSent when user has no authentication methods and email is unverified", async () => {
|
||||
mockListAuthenticationMethodTypes.mockResolvedValue({ authMethodTypes: [] });
|
||||
|
||||
const result = await sendLoginname({
|
||||
@@ -210,12 +214,11 @@ describe("sendLoginname", () => {
|
||||
expect(result).toHaveProperty("redirect");
|
||||
expect((result as any).redirect).toMatch(/^\/verify\?/);
|
||||
expect((result as any).redirect).toContain("loginName=user%40example.com");
|
||||
expect((result as any).redirect).toContain("send=false");
|
||||
expect((result as any).redirect).toContain("invite=true");
|
||||
expect((result as any).redirect).toContain("requestId=req123");
|
||||
});
|
||||
|
||||
test("should redirect to verify with send=true when user has no authentication methods and email is already verified", async () => {
|
||||
test("should redirect to verify when user has no authentication methods and email is already verified", async () => {
|
||||
const verifiedEmailUser = {
|
||||
...mockUser,
|
||||
type: { case: "human", value: { email: { email: "user@example.com", isVerified: true } } },
|
||||
@@ -232,7 +235,6 @@ describe("sendLoginname", () => {
|
||||
expect(result).toHaveProperty("redirect");
|
||||
expect((result as any).redirect).toMatch(/^\/verify\?/);
|
||||
expect((result as any).redirect).toContain("loginName=user%40example.com");
|
||||
expect((result as any).redirect).toContain("send=true");
|
||||
expect((result as any).redirect).toContain("invite=true");
|
||||
expect((result as any).redirect).toContain("requestId=req123");
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from "../zitadel";
|
||||
import { createSessionAndUpdateCookie } from "./cookie";
|
||||
import { getPublicHost } from "./host";
|
||||
import { trySendVerification } from "./verify";
|
||||
|
||||
const logger = createLogger("loginname");
|
||||
|
||||
@@ -341,15 +342,26 @@ export async function sendLoginname(command: SendLoginnameCommand) {
|
||||
|
||||
// If the user's email is not verified, they likely already have a code from the
|
||||
// initial verification email. Auto-sending a new one here invalidates their existing code
|
||||
// and causes confusion. Only auto-send (`send=true`) if the email is already verified.
|
||||
// and causes confusion. Only auto-send if the email is already verified.
|
||||
const shouldSend = humanUser?.email?.isVerified === true;
|
||||
|
||||
const codeSent = shouldSend
|
||||
? await trySendVerification({
|
||||
userId: session?.factors?.user?.id ?? user.userId,
|
||||
isInvite: true,
|
||||
requestId: command.requestId,
|
||||
})
|
||||
: false;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
loginName: (session?.factors?.user?.loginName ?? user.preferredLoginName) as string,
|
||||
send: shouldSend ? "true" : "false",
|
||||
invite: "true", // always send invite code if user has no primary auth method
|
||||
});
|
||||
|
||||
if (codeSent) {
|
||||
params.append("codeSent", "true");
|
||||
}
|
||||
|
||||
if (command.requestId) {
|
||||
params.append("requestId", command.requestId);
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ export async function sendPasskey(command: SendPasskeyCommand) {
|
||||
|
||||
const humanUser = userResponse.user.type.case === "human" ? userResponse.user.type.value : undefined;
|
||||
|
||||
const emailVerificationCheck = checkEmailVerification(session as any, humanUser, organization, requestId);
|
||||
const emailVerificationCheck = await checkEmailVerification(session as any, humanUser, organization, requestId);
|
||||
|
||||
if (emailVerificationCheck?.redirect) {
|
||||
return emailVerificationCheck;
|
||||
|
||||
@@ -374,7 +374,7 @@ export async function sendPassword(
|
||||
}
|
||||
|
||||
// check to see if user was verified
|
||||
const emailVerificationCheck = checkEmailVerification(session, humanUser, command.organization, command.requestId);
|
||||
const emailVerificationCheck = await checkEmailVerification(session, humanUser, command.organization, command.requestId);
|
||||
|
||||
if (emailVerificationCheck?.redirect) {
|
||||
return emailVerificationCheck;
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function registerUser(
|
||||
|
||||
const humanUser = userResponse.user.type.case === "human" ? userResponse.user.type.value : undefined;
|
||||
|
||||
const emailVerificationCheck = checkEmailVerification(
|
||||
const emailVerificationCheck = await checkEmailVerification(
|
||||
session,
|
||||
humanUser,
|
||||
session.factors.user.organizationId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { initialSendVerification, sendVerification } from "./verify";
|
||||
import { sendVerification, trySendVerification } from "./verify";
|
||||
|
||||
import {
|
||||
createInviteCode,
|
||||
@@ -219,7 +219,7 @@ describe("sendVerification", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialSendVerification", () => {
|
||||
describe("trySendVerification", () => {
|
||||
let mockSendEmailCode: any;
|
||||
let mockCreateInviteCode: any;
|
||||
let originalBasePath: string | undefined;
|
||||
@@ -243,12 +243,13 @@ describe("initialSendVerification", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should call sendEmailCode with correct URL template for non-invite", async () => {
|
||||
await initialSendVerification({
|
||||
test("should call sendEmailCode with correct URL template for non-invite and return true", async () => {
|
||||
const result = await trySendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSendEmailCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
@@ -257,12 +258,13 @@ describe("initialSendVerification", () => {
|
||||
expect(mockCreateInviteCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should call createInviteCode with correct URL template for invite", async () => {
|
||||
await initialSendVerification({
|
||||
test("should call createInviteCode with correct URL template for invite and return true", async () => {
|
||||
const result = await trySendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockCreateInviteCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
@@ -273,12 +275,13 @@ describe("initialSendVerification", () => {
|
||||
});
|
||||
|
||||
test("should include URL-encoded requestId in URL template", async () => {
|
||||
await initialSendVerification({
|
||||
const result = await trySendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: false,
|
||||
requestId: "req-123",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSendEmailCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
@@ -288,12 +291,13 @@ describe("initialSendVerification", () => {
|
||||
});
|
||||
|
||||
test("should URL-encode special characters in requestId", async () => {
|
||||
await initialSendVerification({
|
||||
const result = await trySendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: false,
|
||||
requestId: "req&id=injected",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockSendEmailCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
@@ -303,12 +307,13 @@ describe("initialSendVerification", () => {
|
||||
});
|
||||
|
||||
test("should include invite=true and requestId for invite with requestId", async () => {
|
||||
await initialSendVerification({
|
||||
const result = await trySendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: true,
|
||||
requestId: "req-456",
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockCreateInviteCode).toHaveBeenCalledWith({
|
||||
serviceConfig: {},
|
||||
userId: "user-1",
|
||||
@@ -316,4 +321,26 @@ describe("initialSendVerification", () => {
|
||||
"https://example.com/ui/v2/login/verify?code={{.Code}}&userId={{.UserID}}&organization={{.OrgID}}&invite=true&requestId=req-456",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false when sendEmailCode fails", async () => {
|
||||
mockSendEmailCode.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const result = await trySendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test("should return false when createInviteCode fails", async () => {
|
||||
mockCreateInviteCode.mockRejectedValue(new Error("Already invited"));
|
||||
|
||||
const result = await trySendVerification({
|
||||
userId: "user-1",
|
||||
isInvite: true,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,9 +23,9 @@ import { cookies, headers } from "next/headers";
|
||||
import { completeFlowOrGetUrl } from "../client";
|
||||
import { getSessionCookieByLoginName } from "../cookies";
|
||||
import { getOrSetFingerprintId } from "../fingerprint";
|
||||
import { checkMFAFactors } from "../mfa-helper";
|
||||
import { getServiceConfig } from "../service-url";
|
||||
import { loadMostRecentSession } from "../session";
|
||||
import { checkMFAFactors } from "../verify-helper";
|
||||
import { createSessionAndUpdateCookie } from "./cookie";
|
||||
import { getPublicHostWithProtocol } from "./host";
|
||||
|
||||
@@ -311,31 +311,43 @@ export async function sendInviteEmailCode(command: SendEmailCommand) {
|
||||
return createInviteCode({ serviceConfig, userId: command.userId, urlTemplate: command.urlTemplate });
|
||||
}
|
||||
|
||||
type InitialSendVerificationCommand = {
|
||||
type TrySendVerificationCommand = {
|
||||
userId: string;
|
||||
isInvite: boolean;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export async function initialSendVerification(command: InitialSendVerificationCommand) {
|
||||
const _headers = await headers();
|
||||
const { serviceConfig } = getServiceConfig(_headers);
|
||||
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
|
||||
/**
|
||||
* Attempts to send an initial verification email/invite code.
|
||||
* Returns `true` if sent successfully, `false` on error (swallowed and logged).
|
||||
*/
|
||||
export async function trySendVerification(command: TrySendVerificationCommand): Promise<boolean> {
|
||||
try {
|
||||
const _headers = await headers();
|
||||
const { serviceConfig } = getServiceConfig(_headers);
|
||||
const hostWithProtocol = await getPublicHostWithProtocol(_headers);
|
||||
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
const urlTemplate = buildVerificationUrlTemplate(hostWithProtocol, basePath, command.isInvite, command.requestId);
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? "";
|
||||
const urlTemplate = buildVerificationUrlTemplate(hostWithProtocol, basePath, command.isInvite, command.requestId);
|
||||
|
||||
if (command.isInvite) {
|
||||
return createInviteCode({
|
||||
serviceConfig,
|
||||
userId: command.userId,
|
||||
urlTemplate,
|
||||
});
|
||||
} else {
|
||||
return zitadelSendEmailCode({
|
||||
serviceConfig,
|
||||
userId: command.userId,
|
||||
urlTemplate,
|
||||
});
|
||||
if (command.isInvite) {
|
||||
await createInviteCode({
|
||||
serviceConfig,
|
||||
userId: command.userId,
|
||||
urlTemplate,
|
||||
});
|
||||
} else {
|
||||
await zitadelSendEmailCode({
|
||||
serviceConfig,
|
||||
userId: command.userId,
|
||||
urlTemplate,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("Verification email sent successfully", { userId: command.userId, isInvite: command.isInvite });
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error("Failed to send verification email", { userId: command.userId, isInvite: command.isInvite, error: err });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
shouldEnforceMFA,
|
||||
} from "./verify-helper";
|
||||
|
||||
vi.mock("./server/verify", () => ({
|
||||
trySendVerification: vi.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
// Mock function to create timestamps - following the same pattern as session.test.ts
|
||||
function createMockTimestamp(offsetMs = 3600000): any {
|
||||
return {
|
||||
@@ -483,88 +487,78 @@ describe("checkEmailVerified", () => {
|
||||
},
|
||||
};
|
||||
|
||||
it("should redirect if email is not verified", () => {
|
||||
it("should redirect if email is not verified", async () => {
|
||||
const humanUser: any = {
|
||||
email: {
|
||||
isVerified: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerified(mockSession, humanUser);
|
||||
const result = await checkEmailVerified(mockSession, humanUser);
|
||||
|
||||
expect(result).toEqual({
|
||||
redirect: expect.stringContaining("/verify"),
|
||||
});
|
||||
expect(result?.redirect).toContain("codeSent=true");
|
||||
});
|
||||
|
||||
it("should not redirect if email is verified", () => {
|
||||
it("should not redirect if email is verified", async () => {
|
||||
const humanUser: any = {
|
||||
email: {
|
||||
isVerified: true,
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerified(mockSession, humanUser);
|
||||
const result = await checkEmailVerified(mockSession, humanUser);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should include userId in verify redirect", () => {
|
||||
it("should include userId in verify redirect", async () => {
|
||||
const humanUser: any = {
|
||||
email: {
|
||||
isVerified: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerified(mockSession, humanUser);
|
||||
const result = await checkEmailVerified(mockSession, humanUser);
|
||||
|
||||
expect(result?.redirect).toContain("userId=user-123");
|
||||
});
|
||||
|
||||
it("should include send=true parameter", () => {
|
||||
it("should include organization in redirect", async () => {
|
||||
const humanUser: any = {
|
||||
email: {
|
||||
isVerified: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerified(mockSession, humanUser);
|
||||
|
||||
expect(result?.redirect).toContain("send=true");
|
||||
});
|
||||
|
||||
it("should include organization in redirect", () => {
|
||||
const humanUser: any = {
|
||||
email: {
|
||||
isVerified: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerified(mockSession, humanUser, "custom-org");
|
||||
const result = await checkEmailVerified(mockSession, humanUser, "custom-org");
|
||||
|
||||
expect(result?.redirect).toContain("organization=custom-org");
|
||||
});
|
||||
|
||||
it("should include requestId in redirect", () => {
|
||||
it("should include requestId in redirect", async () => {
|
||||
const humanUser: any = {
|
||||
email: {
|
||||
isVerified: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerified(mockSession, humanUser, undefined, "request-123");
|
||||
const result = await checkEmailVerified(mockSession, humanUser, undefined, "request-123");
|
||||
|
||||
expect(result?.redirect).toContain("requestId=request-123");
|
||||
});
|
||||
|
||||
it("should handle no email on user", () => {
|
||||
it("should handle no email on user", async () => {
|
||||
const humanUser: any = {};
|
||||
|
||||
const result = checkEmailVerified(mockSession, humanUser);
|
||||
const result = await checkEmailVerified(mockSession, humanUser);
|
||||
|
||||
expect(result).toEqual({
|
||||
redirect: expect.stringContaining("/verify"),
|
||||
});
|
||||
expect(result?.redirect).toContain("codeSent=true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -584,7 +578,7 @@ describe("checkEmailVerification", () => {
|
||||
process.env.EMAIL_VERIFICATION = originalEnv;
|
||||
});
|
||||
|
||||
it("should redirect if email not verified and EMAIL_VERIFICATION is true", () => {
|
||||
it("should redirect if email not verified and EMAIL_VERIFICATION is true", async () => {
|
||||
process.env.EMAIL_VERIFICATION = "true";
|
||||
|
||||
const humanUser: any = {
|
||||
@@ -593,14 +587,15 @@ describe("checkEmailVerification", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerification(mockSession, humanUser);
|
||||
const result = await checkEmailVerification(mockSession, humanUser);
|
||||
|
||||
expect(result).toEqual({
|
||||
redirect: expect.stringContaining("/verify"),
|
||||
});
|
||||
expect(result?.redirect).toContain("codeSent=true");
|
||||
});
|
||||
|
||||
it("should not redirect if EMAIL_VERIFICATION is not true", () => {
|
||||
it("should not redirect if EMAIL_VERIFICATION is not true", async () => {
|
||||
process.env.EMAIL_VERIFICATION = "false";
|
||||
|
||||
const humanUser: any = {
|
||||
@@ -609,12 +604,12 @@ describe("checkEmailVerification", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerification(mockSession, humanUser);
|
||||
const result = await checkEmailVerification(mockSession, humanUser);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should not redirect if email is verified", () => {
|
||||
it("should not redirect if email is verified", async () => {
|
||||
process.env.EMAIL_VERIFICATION = "true";
|
||||
|
||||
const humanUser: any = {
|
||||
@@ -623,12 +618,12 @@ describe("checkEmailVerification", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerification(mockSession, humanUser);
|
||||
const result = await checkEmailVerification(mockSession, humanUser);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should include send=true parameter", () => {
|
||||
it("should include organization in redirect", async () => {
|
||||
process.env.EMAIL_VERIFICATION = "true";
|
||||
|
||||
const humanUser: any = {
|
||||
@@ -637,21 +632,7 @@ describe("checkEmailVerification", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerification(mockSession, humanUser);
|
||||
|
||||
expect(result?.redirect).toContain("send=true");
|
||||
});
|
||||
|
||||
it("should include organization in redirect", () => {
|
||||
process.env.EMAIL_VERIFICATION = "true";
|
||||
|
||||
const humanUser: any = {
|
||||
email: {
|
||||
isVerified: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = checkEmailVerification(mockSession, humanUser, "custom-org");
|
||||
const result = await checkEmailVerification(mockSession, humanUser, "custom-org");
|
||||
|
||||
expect(result?.redirect).toContain("organization=custom-org");
|
||||
});
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { timestampDate } from "@zitadel/client";
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { LoginSettings } from "@zitadel/proto/zitadel/settings/v2/login_settings_pb";
|
||||
import { PasswordExpirySettings } from "@zitadel/proto/zitadel/settings/v2/password_settings_pb";
|
||||
import { HumanUser } from "@zitadel/proto/zitadel/user/v2/user_pb";
|
||||
import { AuthenticationMethodType } from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import crypto from "crypto";
|
||||
import moment from "moment";
|
||||
import { cookies } from "next/headers";
|
||||
import { getFingerprintIdCookie } from "./fingerprint";
|
||||
import { getUserByID, ServiceConfig } from "./zitadel";
|
||||
import { trySendVerification } from "./server/verify";
|
||||
|
||||
export function checkPasswordChangeRequired(
|
||||
expirySettings: PasswordExpirySettings | undefined,
|
||||
@@ -42,14 +40,28 @@ export function checkPasswordChangeRequired(
|
||||
}
|
||||
}
|
||||
|
||||
export function checkEmailVerified(session: Session, humanUser?: HumanUser, organization?: string, requestId?: string) {
|
||||
export async function checkEmailVerified(
|
||||
session: Session,
|
||||
humanUser?: HumanUser,
|
||||
organization?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
if (!humanUser?.email?.isVerified) {
|
||||
const codeSent = await trySendVerification({
|
||||
userId: session.factors?.user?.id as string,
|
||||
isInvite: false,
|
||||
requestId,
|
||||
});
|
||||
|
||||
const paramsVerify = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
userId: session.factors?.user?.id as string, // verify needs user id
|
||||
send: "true", // we request a new email code once the page is loaded
|
||||
});
|
||||
|
||||
if (codeSent) {
|
||||
paramsVerify.append("codeSent", "true");
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
paramsVerify.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
@@ -62,13 +74,27 @@ export function checkEmailVerified(session: Session, humanUser?: HumanUser, orga
|
||||
}
|
||||
}
|
||||
|
||||
export function checkEmailVerification(session: Session, humanUser?: HumanUser, organization?: string, requestId?: string) {
|
||||
export async function checkEmailVerification(
|
||||
session: Session,
|
||||
humanUser?: HumanUser,
|
||||
organization?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
if (!humanUser?.email?.isVerified && process.env.EMAIL_VERIFICATION === "true") {
|
||||
const codeSent = await trySendVerification({
|
||||
userId: session.factors?.user?.id as string,
|
||||
isInvite: false,
|
||||
requestId,
|
||||
});
|
||||
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
send: "true", // set this to true as we dont expect old email codes to be valid anymore
|
||||
});
|
||||
|
||||
if (codeSent) {
|
||||
params.append("codeSent", "true");
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
@@ -81,187 +107,8 @@ export function checkEmailVerification(session: Session, humanUser?: HumanUser,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkMFAFactors(
|
||||
serviceConfig: ServiceConfig,
|
||||
session: Session,
|
||||
loginSettings: LoginSettings | undefined,
|
||||
authMethods: AuthenticationMethodType[],
|
||||
organization?: string,
|
||||
requestId?: string,
|
||||
) {
|
||||
const availableMultiFactors = authMethods?.filter(
|
||||
(m: AuthenticationMethodType) =>
|
||||
m === AuthenticationMethodType.TOTP ||
|
||||
m === AuthenticationMethodType.OTP_SMS ||
|
||||
m === AuthenticationMethodType.OTP_EMAIL ||
|
||||
m === AuthenticationMethodType.U2F,
|
||||
);
|
||||
|
||||
const hasAuthenticatedWithPasskey = session.factors?.webAuthN?.verifiedAt && session.factors?.webAuthN?.userVerified;
|
||||
|
||||
// escape further checks if user has authenticated with passkey
|
||||
if (hasAuthenticatedWithPasskey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if user has not authenticated with passkey and has only one additional mfa factor, redirect to that
|
||||
if (availableMultiFactors?.length == 1) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
});
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
|
||||
const factor = availableMultiFactors[0];
|
||||
// if passkey is other method, but user selected password as alternative, perform a login
|
||||
if (factor === AuthenticationMethodType.TOTP) {
|
||||
return { redirect: `/otp/time-based?` + params };
|
||||
} else if (factor === AuthenticationMethodType.OTP_SMS) {
|
||||
return { redirect: `/otp/sms?` + params };
|
||||
} else if (factor === AuthenticationMethodType.OTP_EMAIL) {
|
||||
return { redirect: `/otp/email?` + params };
|
||||
} else if (factor === AuthenticationMethodType.U2F) {
|
||||
return { redirect: `/u2f?` + params };
|
||||
}
|
||||
} else if (availableMultiFactors?.length > 1) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
});
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
|
||||
return { redirect: `/mfa?` + params };
|
||||
} else if (shouldEnforceMFA(session, loginSettings) && !availableMultiFactors.length) {
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
force: "true", // this defines if the mfa is forced in the settings
|
||||
checkAfter: "true", // this defines if the check is directly made after the setup
|
||||
});
|
||||
|
||||
if (session.id) {
|
||||
params.append("sessionId", session.id);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
|
||||
// TODO: provide a way to setup passkeys on mfa page?
|
||||
return { redirect: `/mfa/set?` + params };
|
||||
/* eslint-disable no-dupe-else-if -- TODO: this branch is unreachable, conditions overlap with the previous branch */
|
||||
} else if (
|
||||
loginSettings?.mfaInitSkipLifetime &&
|
||||
(loginSettings.mfaInitSkipLifetime.nanos > 0 || loginSettings.mfaInitSkipLifetime.seconds > 0) &&
|
||||
!availableMultiFactors.length &&
|
||||
session?.factors?.user?.id &&
|
||||
shouldEnforceMFA(session, loginSettings)
|
||||
) {
|
||||
/* eslint-enable no-dupe-else-if */
|
||||
const userResponse = await getUserByID({ serviceConfig, userId: session.factors?.user?.id });
|
||||
|
||||
const humanUser = userResponse?.user?.type.case === "human" ? userResponse?.user.type.value : undefined;
|
||||
|
||||
if (humanUser?.mfaInitSkipped) {
|
||||
const mfaInitSkippedTimestamp = timestampDate(humanUser.mfaInitSkipped);
|
||||
|
||||
const mfaInitSkipLifetimeMillis =
|
||||
Number(loginSettings.mfaInitSkipLifetime.seconds) * 1000 + loginSettings.mfaInitSkipLifetime.nanos / 1000000;
|
||||
const currentTime = Date.now();
|
||||
const mfaInitSkippedTime = mfaInitSkippedTimestamp.getTime();
|
||||
const timeDifference = currentTime - mfaInitSkippedTime;
|
||||
|
||||
if (!(timeDifference > mfaInitSkipLifetimeMillis)) {
|
||||
// if the time difference is smaller than the lifetime, skip the mfa setup
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// the user has never skipped the mfa init but we have a setting so we redirect
|
||||
|
||||
const params = new URLSearchParams({
|
||||
loginName: session.factors?.user?.loginName as string,
|
||||
force: "false", // this defines if the mfa is not forced in the settings and can be skipped
|
||||
checkAfter: "true", // this defines if the check is directly made after the setup
|
||||
});
|
||||
|
||||
if (session.id) {
|
||||
params.append("sessionId", session.id);
|
||||
}
|
||||
|
||||
if (requestId) {
|
||||
params.append("requestId", requestId);
|
||||
}
|
||||
|
||||
if (organization || session.factors?.user?.organizationId) {
|
||||
params.append("organization", organization ?? (session.factors?.user?.organizationId as string));
|
||||
}
|
||||
|
||||
// TODO: provide a way to setup passkeys on mfa page?
|
||||
return { redirect: `/mfa/set?` + params };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if MFA should be enforced based on the authentication method used and login settings
|
||||
* @param session - The current session
|
||||
* @param loginSettings - The login settings containing MFA enforcement rules
|
||||
* @returns true if MFA should be enforced, false otherwise
|
||||
*/
|
||||
export function shouldEnforceMFA(session: Session, loginSettings: LoginSettings | undefined): boolean {
|
||||
if (!loginSettings) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user authenticated with passkey (passkeys are inherently multi-factor)
|
||||
const authenticatedWithPasskey = session.factors?.webAuthN?.verifiedAt && session.factors?.webAuthN?.userVerified;
|
||||
|
||||
// If user authenticated with passkey, MFA is not required regardless of settings
|
||||
if (authenticatedWithPasskey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If forceMfa is enabled, MFA is required for ALL authentication methods (except passkeys)
|
||||
if (loginSettings.forceMfa) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If forceMfaLocalOnly is enabled, MFA is only required for local/password authentication
|
||||
if (loginSettings.forceMfaLocalOnly) {
|
||||
// Check if user authenticated with password (local authentication)
|
||||
const authenticatedWithPassword = !!session.factors?.password?.verifiedAt;
|
||||
|
||||
// Check if user authenticated with IDP (external authentication)
|
||||
const authenticatedWithIDP = !!session.factors?.intent?.verifiedAt;
|
||||
|
||||
// If user authenticated with IDP, MFA is not required for forceMfaLocalOnly
|
||||
if (authenticatedWithIDP) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If user authenticated with password, MFA is required for forceMfaLocalOnly
|
||||
if (authenticatedWithPassword) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
// Re-export MFA helpers for backward compatibility
|
||||
export { checkMFAFactors, shouldEnforceMFA } from "./mfa-helper";
|
||||
|
||||
export async function checkUserVerification(userId: string): Promise<boolean> {
|
||||
// check if a verification was done earlier
|
||||
|
||||
Reference in New Issue
Block a user