diff --git a/apps/docs/content/guides/integrate/login-ui/login-app.mdx b/apps/docs/content/guides/integrate/login-ui/login-app.mdx index 8632201a6..dc60e150f 100644 --- a/apps/docs/content/guides/integrate/login-ui/login-app.mdx +++ b/apps/docs/content/guides/integrate/login-ui/login-app.mdx @@ -144,4 +144,7 @@ Also ensure your login domain is registered on your instance by adding it as a [ If you want to enforce users to have their email verified, you can set the optional `EMAIL_VERIFICATION` variable to `true` in your environment and your users will be enforced to verify their email address before they can log in. +By default, verification codes are not automatically submitted on page load. This protects against enterprise email link scanners that pre-fetch URLs and could inadvertently consume one-time codes before users click them. +If you want to enable automatic code submission (e.g. for a smoother UX when link scanners are not a concern), set `NEXT_PUBLIC_AUTO_SUBMIT_CODE` to `true`. + ![](../../../../public/img/deploy-to-vercel.png) diff --git a/apps/login/integration/integration/invite.cy.ts b/apps/login/integration/integration/invite.cy.ts index bf4d46554..a68bb5469 100644 --- a/apps/login/integration/integration/invite.cy.ts +++ b/apps/login/integration/integration/invite.cy.ts @@ -93,6 +93,7 @@ describe("verify invite", () => { stub("zitadel.user.v2.UserService", "VerifyInviteCode"); cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.get("[data-testid=submit-button]").click(); cy.url().should("include", Cypress.config().baseUrl + "/authenticator/set"); }); @@ -105,6 +106,7 @@ describe("verify invite", () => { // TODO: Avoid uncaught exception in application cy.once("uncaught:exception", () => false); cy.visit("/verify?userId=221394658884845598&code=abc&invite=true"); + cy.get("[data-testid=submit-button]").click(); cy.contains("Could not verify invite", { timeout: 10_000 }); }); }); diff --git a/apps/login/integration/integration/verify.cy.ts b/apps/login/integration/integration/verify.cy.ts index c50101a95..ed3182819 100644 --- a/apps/login/integration/integration/verify.cy.ts +++ b/apps/login/integration/integration/verify.cy.ts @@ -90,6 +90,7 @@ describe("verify email", () => { // TODO: Avoid uncaught exception in application cy.once("uncaught:exception", () => false); cy.visit("/verify?userId=221394658884845598&code=abc"); + cy.get("[data-testid=submit-button]").click(); cy.contains("Could not verify email"); }); }); diff --git a/apps/login/next-env-vars.d.ts b/apps/login/next-env-vars.d.ts index 833239f13..a9370721f 100644 --- a/apps/login/next-env-vars.d.ts +++ b/apps/login/next-env-vars.d.ts @@ -44,5 +44,13 @@ declare namespace NodeJS { * Optional: The application name shown in the login and invite emails */ NEXT_PUBLIC_APPLICATION_NAME?: string; + + /** + * Optional: Enable automatic code submission on page load. + * Set to "true" to auto-submit verification codes (e.g. email verification, Email OTP). + * Default behavior (undefined) requires users to click a Submit button, + * which is safer for environments with enterprise email link scanners. + */ + NEXT_PUBLIC_AUTO_SUBMIT_CODE?: string; } } diff --git a/apps/login/src/app/(login)/verify/page.tsx b/apps/login/src/app/(login)/verify/page.tsx index afa1216a1..687769c2f 100644 --- a/apps/login/src/app/(login)/verify/page.tsx +++ b/apps/login/src/app/(login)/verify/page.tsx @@ -41,6 +41,7 @@ export default async function Page(props: { searchParams: Promise }) { const doSend = send === "true"; const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + const autoSubmitCode = process.env.NEXT_PUBLIC_AUTO_SUBMIT_CODE === "true"; async function sendEmail(userId: string) { const hostWithProtocol = await getPublicHostWithProtocol(_headers); @@ -220,6 +221,7 @@ export default async function Page(props: { searchParams: Promise }) { code={code} isInvite={invite === "true"} requestId={requestId} + submit={autoSubmitCode} /> )} diff --git a/apps/login/src/components/verify-form.test.tsx b/apps/login/src/components/verify-form.test.tsx new file mode 100644 index 000000000..f284cd9a2 --- /dev/null +++ b/apps/login/src/components/verify-form.test.tsx @@ -0,0 +1,57 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { VerifyForm } from "./verify-form"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("@/lib/server/verify", () => ({ + sendVerification: vi.fn(), + resendVerification: vi.fn(), +})); + +describe("VerifyForm", () => { + let mockSendVerification: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + const { sendVerification } = await import("@/lib/server/verify"); + mockSendVerification = vi.mocked(sendVerification); + mockSendVerification.mockResolvedValue({ redirect: "/success" }); + }); + + afterEach(cleanup); + + describe("Auto-submit Behavior", () => { + test("should call sendVerification automatically when submit=true", async () => { + render(); + + await waitFor(() => { + expect(mockSendVerification).toHaveBeenCalledWith( + expect.objectContaining({ + code: "123456", + userId: "user-1", + }), + ); + }); + }); + + test("should prefill code but not auto-submit when submit=false", () => { + render(); + + const input = screen.getByTestId("code-text-input"); + expect(input).toHaveValue("123456"); + + const submitButton = screen.getByTestId("submit-button"); + expect(submitButton).toBeInTheDocument(); + + expect(mockSendVerification).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/login/src/components/verify-form.tsx b/apps/login/src/components/verify-form.tsx index 0ee2e7d41..5d303a8c4 100644 --- a/apps/login/src/components/verify-form.tsx +++ b/apps/login/src/components/verify-form.tsx @@ -26,9 +26,10 @@ type Props = { code?: string; isInvite: boolean; requestId?: string; + submit: boolean; }; -export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite }: Props) { +export function VerifyForm({ userId, loginName, organization, requestId, code, isInvite, submit }: Props) { const router = useRouter(); const { register, handleSubmit, formState } = useForm({ @@ -104,11 +105,11 @@ export function VerifyForm({ userId, loginName, organization, requestId, code, i ); useEffect(() => { - if (code && code !== processedCode.current) { + if (submit && code && code !== processedCode.current) { processedCode.current = code; fcn({ code }); } - }, [code, fcn]); + }, [submit, code, fcn]); return ( <>