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:
Max Peintner
2026-06-29 11:42:45 +03:00
committed by GitHub
parent 14874d6546
commit 3a18cef281
14 changed files with 359 additions and 305 deletions
+1 -4
View File
@@ -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", () => ({
+14 -27
View File
@@ -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>
)}
+187
View File
@@ -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;
}
+1 -1
View File
@@ -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;
+6 -4
View File
@@ -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");
});
+14 -2
View File
@@ -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);
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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,
+36 -9
View File
@@ -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);
});
});
+32 -20
View File
@@ -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;
}
}
+27 -46
View File
@@ -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");
});
+35 -188
View File
@@ -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