CVE-2022-35924: Incorrect Authorization (CWE-863)
value: NextAuth.js is a complete open source authentication solution for Next.js applications. `next-auth` users who are using the `EmailProvider` either in versions before `4.10.3` or `3.29.10` are affected. If an attacker could forge a request that sent a comma-separated list of emails (eg.: `attacker@attacker.com,victim@victim.com`) to the sign-in endpoint, NextAuth.js would send emails to both the attacker and the victim's e-mail addresses. The attacker could then login as a newly created user with the email being `attacker@attacker.com,victim@victim.com`. This means that basic authorization like `email.endsWith("@victim.com")` in the `signIn` callback would fail to communicate a threat to the developer and would let the attacker bypass authorization, even with an `@attacker.com` address. This vulnerability has been patched in `v4.10.3` and `v3.29.10` by normalizing the email value that is sent to the sign-in endpoint before accessing it anywhere else. We also added a `normalizeIdentifier` callback on the `EmailProvider` configuration, where you can further tweak your requirements for what your system considers a valid e-mail address. (E.g.: strict RFC2821 compliance). Users are advised to upgrade. There are no known workarounds for this vulnerability. If for some reason you cannot upgrade, you can normalize the incoming request using Advanced Initialization.
CWE description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
CVSS v3 base score: 9.1
Published: 2022-08-02T18:15Z
Repository: https://github.com/nextauthjs/next-auth
Language: TypeScript
Files changed: docs/docs/providers/email.md, packages/next-auth/config/jest.config.js, packages/next-auth/package.json, packages/next-auth/src/core/lib/email/signin.ts, packages/next-auth/src/core/routes/signin.ts, packages/next-auth/src/providers/email.ts, packages/next-auth/tests/email.test.ts, packages/next-auth/tests/getServerSession.test.ts, packages/next-auth/tests/lib.ts, packages/next-auth/tsconfig.json
Commit date: 2022-08-01 13:43:19 +0200
Commit message: fix(providers): add `normalizeIdentifier` to EmailProvider

* fix(providers): add `normalizeIdentifier` to EmailProvider

* docs: document `normalizeIdentifier`

* fix: allow throwing error from normalizer

* test: add e-mail tests

* chore: log provider id

* test: merge client+config jest configs and add coverage report

* test: show coverage for untested files

* fix: only allow first domain in email. Add tests

* chore: add `coverage` to tsconfig exclude list

* cleanup

* revert

Co-authored-by: Thang Vu <thvu@hey.com>

Diff: diff --git a/docs/docs/providers/email.md b/docs/docs/providers/email.md
index 6ddefe65..aff2cfdf 100644
--- a/docs/docs/providers/email.md
+++ b/docs/docs/providers/email.md
@@ -221,5 +221,33 @@ providers: [
       return "ABC123"
     }
   })
 ],
 ```
+
+## Normalizing the email address
+
+By default, NextAuth.js will normalize the email address. It treats values as case-insensitive (which is technically not compliant to the [RFC 2821 spec](https://datatracker.ietf.org/doc/html/rfc2821), but in practice this causes more problems than it solves, eg. when looking up users by e-mail from databases.) and also removes any secondary email address that was passed in as a comma-separated list. You can apply your own normalization via the `normalizeIdentifier` method on the `EmailProvider`. The following example shows the default behavior:
+```ts
+  EmailProvider({
+    // ...
+    normalizeIdentifier(identifier: string): string {
+      // Get the first two elements only,
+      // separated by `@` from user input.
+      let [local, domain] = identifier.toLowerCase().trim().split("@")
+      // The part before "@" can contain a ","
+      // but we remove it on the domain part
+      domain = domain.split(",")[0]
+      return `${local}@${domain}`
+
+      // You can also throw an error, which will redirect the user
+      // to the error page with error=EmailSignin in the URL
+      // if (identifier.split("@").length > 2) {
+      //   throw new Error("Only one email allowed")
+      // }
+    },
+  })
+```
+
+:::warning
+Always make sure this returns a single e-mail address, even if multiple ones were passed in.
+:::
\ No newline at end of file
diff --git a/packages/next-auth/config/jest.client.config.js b/packages/next-auth/config/jest.client.config.js
deleted file mode 100644
index 067fec90..00000000
--- a/packages/next-auth/config/jest.client.config.js
+++ /dev/null
@@ -1,16 +0,0 @@
-/** @type {import('@jest/types').Config.InitialOptions} */
-module.exports = {
-  transform: {
-    "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
-  },
-  rootDir: "../src",
-  setupFilesAfterEnv: ["../config/jest-setup.js"],
-  testMatch: ["**/*.test.js"],
-  // collectCoverageFrom: ["!client/__tests__/**"],
-  // coverageDirectory: "../coverage",
-  testEnvironment: "jsdom",
-  watchPlugins: [
-    "jest-watch-typeahead/filename",
-    "jest-watch-typeahead/testname",
-  ],
-}
diff --git a/packages/next-auth/config/jest.config.js b/packages/next-auth/config/jest.config.js
new file mode 100644
index 00000000..7d6eb84d
--- /dev/null
+++ b/packages/next-auth/config/jest.config.js
@@ -0,0 +1,34 @@
+/** @type {import('jest').Config} */
+module.exports = {
+  projects: [
+    {
+      displayName: "core",
+      testMatch: ["**/*.test.ts"],
+      rootDir: ".",
+      setupFilesAfterEnv: ["./config/jest-setup.js"],
+      transform: {
+        "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
+      },
+      coveragePathIgnorePatterns: ["tests"],
+    },
+    {
+      displayName: "client",
+      testMatch: ["**/*.test.js"],
+      setupFilesAfterEnv: ["./config/jest-setup.js"],
+      rootDir: ".",
+      transform: {
+        "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
+      },
+      testEnvironment: "jsdom",
+      coveragePathIgnorePatterns: ["__tests__"],
+    },
+  ],
+  watchPlugins: [
+    "jest-watch-typeahead/filename",
+    "jest-watch-typeahead/testname",
+  ],
+  collectCoverage: true,
+  coverageDirectory: "../coverage",
+  coverageReporters: ["html", "text-summary"],
+  collectCoverageFrom: ["src/**/*.(js|jsx|ts|tsx)"],
+}
diff --git a/packages/next-auth/config/jest.core.config.js b/packages/next-auth/config/jest.core.config.js
deleted file mode 100644
index 82e39e02..00000000
--- a/packages/next-auth/config/jest.core.config.js
+++ /dev/null
@@ -1,13 +0,0 @@
-/** @type {import('@jest/types').Config.InitialOptions} */
-module.exports = {
-  transform: {
-    "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
-  },
-  rootDir: "..",
-  testMatch: ["**/*.test.ts"],
-  setupFilesAfterEnv: ["./config/jest-setup.js"],
-  watchPlugins: [
-    "jest-watch-typeahead/filename",
-    "jest-watch-typeahead/testname",
-  ],
-}
diff --git a/packages/next-auth/package.json b/packages/next-auth/package.json
index f6f8eecb..f74de9ec 100644
--- a/packages/next-auth/package.json
+++ b/packages/next-auth/package.json
@@ -40,13 +40,11 @@
     "build": "pnpm clean && pnpm build:js && pnpm build:css",
     "clean": "rm -rf client css utils providers core jwt react next index.d.ts index.js adapters.d.ts middleware.d.ts middleware.js",
     "build:js": "pnpm clean && pnpm generate-providers && tsc && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"",
     "build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js",
     "watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir .",
-    "test:client": "jest --config ./config/jest.client.config.js",
-    "test:core": "jest --config ./config/jest.core.config.js",
-    "test": "pnpm test:core && pnpm test:client",
+    "test": "jest --config ./config/jest.config.js",
     "prepublishOnly": "pnpm build",
     "generate-providers": "node ./config/generate-providers.js",
     "setup": "pnpm generate-providers",
     "lint": "eslint src config"
   },
diff --git a/packages/next-auth/src/core/lib/email/signin.ts b/packages/next-auth/src/core/lib/email/signin.ts
index 3be14f40..d74516d4 100644
--- a/packages/next-auth/src/core/lib/email/signin.ts
+++ b/packages/next-auth/src/core/lib/email/signin.ts
@@ -19,31 +19,32 @@ export default async function email(
   const ONE_DAY_IN_SECONDS = 86400
   const expires = new Date(
     Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000
   )
 
-  // Save in database
-  // @ts-expect-error
-  await adapter.createVerificationToken({
-    identifier,
-    token: hashToken(token, options),
-    expires,
-  })
-
   // Generate a link with email, unhashed token and callback url
   const params = new URLSearchParams({ callbackUrl, token, email: identifier })
   const _url = `${url}/callback/${provider.id}?${params}`
 
-  // Send to user
-  await provider.sendVerificationRequest({
-    identifier,
-    token,
-    expires,
-    url: _url,
-    provider,
-    theme,
-  })
+  await Promise.all([
+    // Send to user
+    provider.sendVerificationRequest({
+      identifier,
+      token,
+      expires,
+      url: _url,
+      provider,
+      theme,
+    }),
+    // Save in database
+    // @ts-expect-error // verified in `assertConfig`
+    adapter.createVerificationToken({
+      identifier,
+      token: hashToken(token, options),
+      expires,
+    }),
+  ])
 
   return `${url}/verify-request?${new URLSearchParams({
     provider: provider.id,
     type: provider.type,
   })}`
diff --git a/packages/next-auth/src/core/routes/signin.ts b/packages/next-auth/src/core/routes/signin.ts
index 739ea109..3db306a0 100644
--- a/packages/next-auth/src/core/routes/signin.ts
+++ b/packages/next-auth/src/core/routes/signin.ts
@@ -31,20 +31,30 @@ export default async function signin(params: {
         providerId: provider.id,
       })
       return { redirect: `${url}/error?error=OAuthSignin` }
     }
   } else if (provider.type === "email") {
-    /**
-     * @note Technically the part of the email address local mailbox element
-     * (everything before the @ symbol) should be treated as 'case sensitive'
-     * according to RFC 2821, but in practice this causes more problems than
-     * it solves. We treat email addresses as all lower case. If anyone
-     * complains about this we can make strict RFC 2821 compliance an option.
-     */
-    const email = body?.email?.toLowerCase()
-
+    let email: string = body?.email
     if (!email) return { redirect: `${url}/error?error=EmailSignin` }
+    const normalizer: (identifier: string) => string =
+      provider.normalizeIdentifier ??
+      ((identifier) => {
+        // Get the first two elements only,
+        // separated by `@` from user input.
+        let [local, domain] = identifier.toLowerCase().trim().split("@")
+        // The part before "@" can contain a ","
+        // but we remove it on the domain part
+        domain = domain.split(",")[0]
+        return `${local}@${domain}`
+      })
+
+    try {
+      email = normalizer(body?.email)
+    } catch (error) {
+      logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
+      return { redirect: `${url}/error?error=EmailSignin` }
+    }
 
     // Verified in `assertConfig`
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     const { getUserByEmail } = adapter!
     // If is an existing user return a user object (otherwise use placeholder)
@@ -83,14 +93,11 @@ export default async function signin(params: {
 
     try {
       const redirect = await emailSignin(email, options)
       return { redirect }
     } catch (error) {
-      logger.error("SIGNIN_EMAIL_ERROR", {
-        error: error as Error,
-        providerId: provider.id,
-      })
+      logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
       return { redirect: `${url}/error?error=EmailSignin` }
     }
   }
   return { redirect: `${url}/signin` }
 }
diff --git a/packages/next-auth/src/providers/email.ts b/packages/next-auth/src/providers/email.ts
index b611e99c..8e5d3463 100644
--- a/packages/next-auth/src/providers/email.ts
+++ b/packages/next-auth/src/providers/email.ts
@@ -44,10 +44,25 @@ export interface EmailConfig extends CommonProviderOptions {
    * [Documentation](https://next-auth.js.org/providers/email#customising-the-verification-token)
    */
   generateVerificationToken?: () => Awaitable<string>
   /** If defined, it is used to hash the verification token when saving to the database . */
   secret?: string
+  /**
+   * Normalizes the user input before sending the verification request.
+   *
+   * ⚠️ Always make sure this method returns a single email address.
+   *
+   * @note Technically, the part of the email address local mailbox element
+   * (everything before the `@` symbol) should be treated as 'case sensitive'
+   * according to RFC 2821, but in practice this causes more problems than
+   * it solves, e.g.: when looking up users by e-mail from databases.
+   * By default, we treat email addresses as all lower case,
+   * but you can override this function to change this behavior.
+   *
+   * [Documentation](https://next-auth.js.org/providers/email#normalizing-the-e-mail-address) | [RFC 2821](https://tools.ietf.org/html/rfc2821) | [Email syntax](https://en.wikipedia.org/wiki/Email_address#Syntax)
+   */
+  normalizeIdentifier?: (identifier: string) => string
   options: EmailUserConfig
 }
 
 export type EmailUserConfig = Partial<Omit<EmailConfig, "options">>
 
@@ -77,11 +92,11 @@ export default function Email(options: EmailUserConfig): EmailConfig {
         text: text({ url, host }),
         html: html({ url, host, theme }),
       })
       const failed = result.rejected.concat(result.pending).filter(Boolean)
       if (failed.length) {
-        throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
+        throw new Error(`Email (${failed.join(", ")}) could not be sent`)
       }
     },
     options,
   }
 }
diff --git a/packages/next-auth/tests/email.test.ts b/packages/next-auth/tests/email.test.ts
new file mode 100644
index 00000000..681438ac
--- /dev/null
+++ b/packages/next-auth/tests/email.test.ts
@@ -0,0 +1,167 @@
+import { createCSRF, handler, mockAdapter } from "./lib"
+import EmailProvider from "../src/providers/email"
+
+it("Send e-mail to the only address correctly", async () => {
+  const { secret, csrf } = await createCSRF()
+  const sendVerificationRequest = jest.fn()
+  const signIn = jest.fn(() => true)
+
+  const email = "email@example.com"
+  const { res } = await handler(
+    {
+      adapter: mockAdapter(),
+      providers: [EmailProvider({ sendVerificationRequest })],
+      callbacks: { signIn },
+      secret,
+    },
+    {
+      path: "signin/email",
+      requestInit: {
+        method: "POST",
+        headers: { cookie: csrf.cookie },
+        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
+      },
+    }
+  )
+
+  expect(res.redirect).toBe(
+    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
+  )
+
+  expect(signIn).toBeCalledTimes(1)
+  expect(signIn).toHaveBeenCalledWith(
+    expect.objectContaining({
+      user: expect.objectContaining({ email }),
+    })
+  )
+
+  expect(sendVerificationRequest).toHaveBeenCalledWith(
+    expect.objectContaining({ identifier: email })
+  )
+})
+
+it("Send e-mail to first address only", async () => {
+  const { secret, csrf } = await createCSRF()
+  const sendVerificationRequest = jest.fn()
+  const signIn = jest.fn(() => true)
+
+  const firstEmail = "email@email.com"
+  const email = `${firstEmail},email@email2.com`
+  const { res } = await handler(
+    {
+      adapter: mockAdapter(),
+      providers: [EmailProvider({ sendVerificationRequest })],
+      callbacks: { signIn },
+      secret,
+    },
+    {
+      path: "signin/email",
+      requestInit: {
+        method: "POST",
+        headers: { cookie: csrf.cookie },
+        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
+      },
+    }
+  )
+
+  expect(res.redirect).toBe(
+    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
+  )
+
+  expect(signIn).toBeCalledTimes(1)
+  expect(signIn).toHaveBeenCalledWith(
+    expect.objectContaining({
+      user: expect.objectContaining({ email: firstEmail }),
+    })
+  )
+
+  expect(sendVerificationRequest).toHaveBeenCalledWith(
+    expect.objectContaining({ identifier: firstEmail })
+  )
+})
+
+it("Send e-mail to address with first domain", async () => {
+  const { secret, csrf } = await createCSRF()
+  const sendVerificationRequest = jest.fn()
+  const signIn = jest.fn(() => true)
+
+  const firstEmail = "email@email.com"
+  const email = `${firstEmail},email2.com`
+  const { res } = await handler(
+    {
+      adapter: mockAdapter(),
+      providers: [EmailProvider({ sendVerificationRequest })],
+      callbacks: { signIn },
+      secret,
+    },
+    {
+      path: "signin/email",
+      requestInit: {
+        method: "POST",
+        headers: { cookie: csrf.cookie },
+        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
+      },
+    }
+  )
+
+  expect(res.redirect).toBe(
+    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
+  )
+
+  expect(signIn).toBeCalledTimes(1)
+  expect(signIn).toHaveBeenCalledWith(
+    expect.objectContaining({
+      user: expect.objectContaining({ email: firstEmail }),
+    })
+  )
+
+  expect(sendVerificationRequest).toHaveBeenCalledWith(
+    expect.objectContaining({ identifier: firstEmail })
+  )
+})
+
+it("Redirect to error page if multiple addresses aren't allowed", async () => {
+  const { secret, csrf } = await createCSRF()
+  const sendVerificationRequest = jest.fn()
+  const signIn = jest.fn()
+  const error = new Error("Only one email allowed")
+  const { res, log } = await handler(
+    {
+      adapter: mockAdapter(),
+      callbacks: { signIn },
+      providers: [
+        EmailProvider({
+          sendVerificationRequest,
+          normalizeIdentifier(identifier) {
+            if (identifier.split("@").length > 2) throw error
+            return identifier
+          },
+        }),
+      ],
+      secret,
+    },
+    {
+      path: "signin/email",
+      requestInit: {
+        method: "POST",
+        headers: { cookie: csrf.cookie },
+        body: JSON.stringify({
+          email: "email@email.com,email@email2.com",
+          csrfToken: csrf.value,
+        }),
+      },
+    }
+  )
+
+  expect(signIn).toBeCalledTimes(0)
+  expect(sendVerificationRequest).toBeCalledTimes(0)
+
+  expect(log.error.mock.calls[0]).toEqual([
+    "SIGNIN_EMAIL_ERROR",
+    { error, providerId: "email" },
+  ])
+
+  expect(res.redirect).toBe(
+    "http://localhost:3000/api/auth/error?error=EmailSignin"
+  )
+})
diff --git a/packages/next-auth/tests/getServerSession.test.ts b/packages/next-auth/tests/getServerSession.test.ts
index b2922e83..2eae732f 100644
--- a/packages/next-auth/tests/getServerSession.test.ts
+++ b/packages/next-auth/tests/getServerSession.test.ts
@@ -1,6 +1,5 @@
-import type { NextApiRequest } from "next"
 import { MissingSecret } from "../src/core/errors"
 import { unstable_getServerSession } from "../src/next"
 import { mockLogger } from "./lib"
 
 let originalWarn = console.warn
diff --git a/packages/next-auth/tests/lib.ts b/packages/next-auth/tests/lib.ts
index ef0b415e..47462de5 100644
--- a/packages/next-auth/tests/lib.ts
+++ b/packages/next-auth/tests/lib.ts
@@ -1,8 +1,9 @@
 import { createHash } from "crypto"
-import type { LoggerInstance, NextAuthOptions } from "../src"
 import { NextAuthHandler } from "../src/core"
+import type { LoggerInstance, NextAuthOptions } from "../src"
+import type { Adapter } from "../src/adapters"
 
 export const mockLogger: () => LoggerInstance = () => ({
   error: jest.fn(() => {}),
   warn: jest.fn(() => {}),
   debug: jest.fn(() => {}),
@@ -54,5 +55,12 @@ export function createCSRF() {
   return {
     secret,
     csrf: { value, token, cookie: `next-auth.csrf-token=${value}|${token}` },
   }
 }
+
+export function mockAdapter(): Adapter {
+  return {
+    createVerificationToken: jest.fn(() => {}),
+    getUserByEmail: jest.fn(() => {}),
+  } as Adapter
+}
diff --git a/packages/next-auth/tsconfig.json b/packages/next-auth/tsconfig.json
index e9662c80..c01ac48d 100644
--- a/packages/next-auth/tsconfig.json
+++ b/packages/next-auth/tsconfig.json
@@ -17,7 +17,14 @@
     "outDir": ".",
     "paths": {
       "next": ["node_modules/next"]
     }
   },
-  "exclude": ["./*.js", "./*.d.ts", "config", "**/__tests__", "tests"]
+  "exclude": [
+    "./*.js",
+    "./*.d.ts",
+    "config",
+    "**/__tests__",
+    "tests",
+    "coverage"
+  ]
 }

Vulnerable code: /** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  transform: {
    "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
  },
  rootDir: "../src",
  setupFilesAfterEnv: ["../config/jest-setup.js"],
  testMatch: ["**/*.test.js"],
  // collectCoverageFrom: ["!client/__tests__/**"],
  // coverageDirectory: "../coverage",
  testEnvironment: "jsdom",
  watchPlugins: [
    "jest-watch-typeahead/filename",
    "jest-watch-typeahead/testname",
  ],
}
/** @type {import('@jest/types').Config.InitialOptions} */
module.exports = {
  transform: {
    "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
  },
  rootDir: "..",
  testMatch: ["**/*.test.ts"],
  setupFilesAfterEnv: ["./config/jest-setup.js"],
  watchPlugins: [
    "jest-watch-typeahead/filename",
    "jest-watch-typeahead/testname",
  ],
}
    "test:client": "jest --config ./config/jest.client.config.js",
    "test:core": "jest --config ./config/jest.core.config.js",
    "test": "pnpm test:core && pnpm test:client",
  // Save in database
  // @ts-expect-error
  await adapter.createVerificationToken({
    identifier,
    token: hashToken(token, options),
    expires,
  })

  // Send to user
  await provider.sendVerificationRequest({
    identifier,
    token,
    expires,
    url: _url,
    provider,
    theme,
  })
    /**
     * @note Technically the part of the email address local mailbox element
     * (everything before the @ symbol) should be treated as 'case sensitive'
     * according to RFC 2821, but in practice this causes more problems than
     * it solves. We treat email addresses as all lower case. If anyone
     * complains about this we can make strict RFC 2821 compliance an option.
     */
    const email = body?.email?.toLowerCase()

      logger.error("SIGNIN_EMAIL_ERROR", {
        error: error as Error,
        providerId: provider.id,
      })
        throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
import type { NextApiRequest } from "next"
import type { LoggerInstance, NextAuthOptions } from "../src"
  "exclude": ["./*.js", "./*.d.ts", "config", "**/__tests__", "tests"]
Fixed code: 
## Normalizing the email address

By default, NextAuth.js will normalize the email address. It treats values as case-insensitive (which is technically not compliant to the [RFC 2821 spec](https://datatracker.ietf.org/doc/html/rfc2821), but in practice this causes more problems than it solves, eg. when looking up users by e-mail from databases.) and also removes any secondary email address that was passed in as a comma-separated list. You can apply your own normalization via the `normalizeIdentifier` method on the `EmailProvider`. The following example shows the default behavior:
```ts
  EmailProvider({
    // ...
    normalizeIdentifier(identifier: string): string {
      // Get the first two elements only,
      // separated by `@` from user input.
      let [local, domain] = identifier.toLowerCase().trim().split("@")
      // The part before "@" can contain a ","
      // but we remove it on the domain part
      domain = domain.split(",")[0]
      return `${local}@${domain}`

      // You can also throw an error, which will redirect the user
      // to the error page with error=EmailSignin in the URL
      // if (identifier.split("@").length > 2) {
      //   throw new Error("Only one email allowed")
      // }
    },
  })
```

:::warning
Always make sure this returns a single e-mail address, even if multiple ones were passed in.
:::
/** @type {import('jest').Config} */
module.exports = {
  projects: [
    {
      displayName: "core",
      testMatch: ["**/*.test.ts"],
      rootDir: ".",
      setupFilesAfterEnv: ["./config/jest-setup.js"],
      transform: {
        "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
      },
      coveragePathIgnorePatterns: ["tests"],
    },
    {
      displayName: "client",
      testMatch: ["**/*.test.js"],
      setupFilesAfterEnv: ["./config/jest-setup.js"],
      rootDir: ".",
      transform: {
        "\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
      },
      testEnvironment: "jsdom",
      coveragePathIgnorePatterns: ["__tests__"],
    },
  ],
  watchPlugins: [
    "jest-watch-typeahead/filename",
    "jest-watch-typeahead/testname",
  ],
  collectCoverage: true,
  coverageDirectory: "../coverage",
  coverageReporters: ["html", "text-summary"],
  collectCoverageFrom: ["src/**/*.(js|jsx|ts|tsx)"],
}
    "test": "jest --config ./config/jest.config.js",
  await Promise.all([
    // Send to user
    provider.sendVerificationRequest({
      identifier,
      token,
      expires,
      url: _url,
      provider,
      theme,
    }),
    // Save in database
    // @ts-expect-error // verified in `assertConfig`
    adapter.createVerificationToken({
      identifier,
      token: hashToken(token, options),
      expires,
    }),
  ])
    let email: string = body?.email
    const normalizer: (identifier: string) => string =
      provider.normalizeIdentifier ??
      ((identifier) => {
        // Get the first two elements only,
        // separated by `@` from user input.
        let [local, domain] = identifier.toLowerCase().trim().split("@")
        // The part before "@" can contain a ","
        // but we remove it on the domain part
        domain = domain.split(",")[0]
        return `${local}@${domain}`
      })

    try {
      email = normalizer(body?.email)
    } catch (error) {
      logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
      return { redirect: `${url}/error?error=EmailSignin` }
    }
      logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
  /**
   * Normalizes the user input before sending the verification request.
   *
   * ⚠️ Always make sure this method returns a single email address.
   *
   * @note Technically, the part of the email address local mailbox element
   * (everything before the `@` symbol) should be treated as 'case sensitive'
   * according to RFC 2821, but in practice this causes more problems than
   * it solves, e.g.: when looking up users by e-mail from databases.
   * By default, we treat email addresses as all lower case,
   * but you can override this function to change this behavior.
   *
   * [Documentation](https://next-auth.js.org/providers/email#normalizing-the-e-mail-address) | [RFC 2821](https://tools.ietf.org/html/rfc2821) | [Email syntax](https://en.wikipedia.org/wiki/Email_address#Syntax)
   */
  normalizeIdentifier?: (identifier: string) => string
        throw new Error(`Email (${failed.join(", ")}) could not be sent`)
import { createCSRF, handler, mockAdapter } from "./lib"
import EmailProvider from "../src/providers/email"

it("Send e-mail to the only address correctly", async () => {
  const { secret, csrf } = await createCSRF()
  const sendVerificationRequest = jest.fn()
  const signIn = jest.fn(() => true)

  const email = "email@example.com"
  const { res } = await handler(
    {
      adapter: mockAdapter(),
      providers: [EmailProvider({ sendVerificationRequest })],
      callbacks: { signIn },
      secret,
    },
    {
      path: "signin/email",
      requestInit: {
        method: "POST",
        headers: { cookie: csrf.cookie },
        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
      },
    }
  )

  expect(res.redirect).toBe(
    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
  )

  expect(signIn).toBeCalledTimes(1)
  expect(signIn).toHaveBeenCalledWith(
    expect.objectContaining({
      user: expect.objectContaining({ email }),
    })
  )

  expect(sendVerificationRequest).toHaveBeenCalledWith(
    expect.objectContaining({ identifier: email })
  )
})

it("Send e-mail to first address only", async () => {
  const { secret, csrf } = await createCSRF()
  const sendVerificationRequest = jest.fn()
  const signIn = jest.fn(() => true)

  const firstEmail = "email@email.com"
  const email = `${firstEmail},email@email2.com`
  const { res } = await handler(
    {
      adapter: mockAdapter(),
      providers: [EmailProvider({ sendVerificationRequest })],
      callbacks: { signIn },
      secret,
    },
    {
      path: "signin/email",
      requestInit: {
        method: "POST",
        headers: { cookie: csrf.cookie },
        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
      },
    }
  )

  expect(res.redirect).toBe(
    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
  )

  expect(signIn).toBeCalledTimes(1)
  expect(signIn).toHaveBeenCalledWith(
    expect.objectContaining({
      user: expect.objectContaining({ email: firstEmail }),
    })
  )

  expect(sendVerificationRequest).toHaveBeenCalledWith(
    expect.objectContaining({ identifier: firstEmail })
  )
})

it("Send e-mail to address with first domain", async () => {
  const { secret, csrf } = await createCSRF()
  const sendVerificationRequest = jest.fn()
  const signIn = jest.fn(() => true)

  const firstEmail = "email@email.com"
  const email = `${firstEmail},email2.com`
  const { res } = await handler(
    {
      adapter: mockAdapter(),
      providers: [EmailProvider({ sendVerificationRequest })],
      callbacks: { signIn },
      secret,
    },
    {
      path: "signin/email",
      requestInit: {
        method: "POST",
        headers: { cookie: csrf.cookie },
        body: JSON.stringify({ email: email, csrfToken: csrf.value }),
      },
    }
  )

  expect(res.redirect).toBe(
    "http://localhost:3000/api/auth/verify-request?provider=email&type=email"
  )

  expect(signIn).toBeCalledTimes(1)
  expect(signIn).toHaveBeenCalledWith(
    expect.objectContaining({
      user: expect.objectContaining({ email: firstEmail }),
    })
  )

  expect(sendVerificationRequest).toHaveBeenCalledWith(
    expect.objectContaining({ identifier: firstEmail })
  )
})

it("Redirect to error page if multiple addresses aren't allowed", async () => {
  const { secret, csrf } = await createCSRF()
  const sendVerificationRequest = jest.fn()
  const signIn = jest.fn()
  const error = new Error("Only one email allowed")
  const { res, log } = await handler(
    {
      adapter: mockAdapter(),
      callbacks: { signIn },
      providers: [
        EmailProvider({
          sendVerificationRequest,
          normalizeIdentifier(identifier) {
            if (identifier.split("@").length > 2) throw error
            return identifier
          },
        }),
      ],
      secret,
    },
    {
      path: "signin/email",
      requestInit: {
        method: "POST",
        headers: { cookie: csrf.cookie },
        body: JSON.stringify({
          email: "email@email.com,email@email2.com",
          csrfToken: csrf.value,
        }),
      },
    }
  )

  expect(signIn).toBeCalledTimes(0)
  expect(sendVerificationRequest).toBeCalledTimes(0)

  expect(log.error.mock.calls[0]).toEqual([
    "SIGNIN_EMAIL_ERROR",
    { error, providerId: "email" },
  ])

  expect(res.redirect).toBe(
    "http://localhost:3000/api/auth/error?error=EmailSignin"
  )
})
import type { LoggerInstance, NextAuthOptions } from "../src"
import type { Adapter } from "../src/adapters"

export function mockAdapter(): Adapter {
  return {
    createVerificationToken: jest.fn(() => {}),
    getUserByEmail: jest.fn(() => {}),
  } as Adapter
}
  "exclude": [
    "./*.js",
    "./*.d.ts",
    "config",
    "**/__tests__",
    "tests",
    "coverage"
  ]