diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index 39c36e81b7..0a9eceab18 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -21,7 +21,7 @@ export async function generateMetadata(): Promise { export default async function Page(props: { searchParams: Promise }) { 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 }) { 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 }) { isInvite={invite === "true"} requestId={requestId} submit={autoSubmitCode} - doSend={doSend} /> )} diff --git a/apps/login/src/components/verify-form.test.tsx b/apps/login/src/components/verify-form.test.tsx index 05789242cf..93febeb914 100644 --- a/apps/login/src/components/verify-form.test.tsx +++ b/apps/login/src/components/verify-form.test.tsx @@ -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", () => ({ diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index ecffda7aa9..499abd0aa9 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -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({ mode: "onChange", @@ -47,27 +49,8 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i const [loading, setLoading] = useState(false); - const initialSendDone = useRef(false); - const [initialSendError, setInitialSendError] = useState(""); - 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 { setError(""); - setInitialSendError(""); setLoading(true); try { @@ -136,7 +123,7 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i return ( <> {samlData && } - {codeSent && !initialSendError && ( + {codeSent && (
@@ -174,9 +161,9 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i />
- {(error || initialSendError) && ( + {error && (
- {error || initialSendError} + {error}
)} diff --git a/apps/login/src/lib/mfa-helper.ts b/apps/login/src/lib/mfa-helper.ts new file mode 100644 index 0000000000..89124be202 --- /dev/null +++ b/apps/login/src/lib/mfa-helper.ts @@ -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; +} diff --git a/apps/login/src/lib/server/idp.ts b/apps/login/src/lib/server/idp.ts index a5e0bec122..84fa3a9d1a 100644 --- a/apps/login/src/lib/server/idp.ts +++ b/apps/login/src/lib/server/idp.ts @@ -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; diff --git a/apps/login/src/lib/server/loginname.test.ts b/apps/login/src/lib/server/loginname.test.ts index e5122e6ea6..cf6be82240 100644 --- a/apps/login/src/lib/server/loginname.test.ts +++ b/apps/login/src/lib/server/loginname.test.ts @@ -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"); }); diff --git a/apps/login/src/lib/server/loginname.ts b/apps/login/src/lib/server/loginname.ts index e46112b480..622c6333e2 100644 --- a/apps/login/src/lib/server/loginname.ts +++ b/apps/login/src/lib/server/loginname.ts @@ -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); } diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts index 9e01cb1b9e..70216ee039 100644 --- a/apps/login/src/lib/server/passkeys.ts +++ b/apps/login/src/lib/server/passkeys.ts @@ -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; diff --git a/apps/login/src/lib/server/password.ts b/apps/login/src/lib/server/password.ts index d790456df4..f05962fe9f 100644 --- a/apps/login/src/lib/server/password.ts +++ b/apps/login/src/lib/server/password.ts @@ -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; diff --git a/apps/login/src/lib/server/register.ts b/apps/login/src/lib/server/register.ts index 998719d09e..ce085c8f4e 100644 --- a/apps/login/src/lib/server/register.ts +++ b/apps/login/src/lib/server/register.ts @@ -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, diff --git a/apps/login/src/lib/server/verify.test.ts b/apps/login/src/lib/server/verify.test.ts index 32eadc5247..1cb7353e7a 100644 --- a/apps/login/src/lib/server/verify.test.ts +++ b/apps/login/src/lib/server/verify.test.ts @@ -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); + }); }); diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index 34df847879..ec39235a34 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -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 { + 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; } } diff --git a/apps/login/src/lib/verify-helper.test.ts b/apps/login/src/lib/verify-helper.test.ts index 9c0a000d51..a2939a3ae8 100644 --- a/apps/login/src/lib/verify-helper.test.ts +++ b/apps/login/src/lib/verify-helper.test.ts @@ -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"); }); diff --git a/apps/login/src/lib/verify-helper.ts b/apps/login/src/lib/verify-helper.ts index 5040e5130f..c8bc14c051 100644 --- a/apps/login/src/lib/verify-helper.ts +++ b/apps/login/src/lib/verify-helper.ts @@ -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 { // check if a verification was done earlier