Skip to content

Commit afb1fcd

Browse files
balazsorban44ThangHuuVu
andcommittedAug 1, 2022
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>
1 parent a21db89 commit afb1fcd

File tree

12 files changed

+301
-66
lines changed

12 files changed

+301
-66
lines changed
 

‎docs/docs/providers/email.md

+28
Original file line numberDiff line numberDiff line change
@@ -223,3 +223,31 @@ providers: [
223223
})
224224
],
225225
```
226+
227+
## Normalizing the email address
228+
229+
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:
230+
```ts
231+
EmailProvider({
232+
// ...
233+
normalizeIdentifier(identifier: string): string {
234+
// Get the first two elements only,
235+
// separated by `@` from user input.
236+
let [local, domain] = identifier.toLowerCase().trim().split("@")
237+
// The part before "@" can contain a ","
238+
// but we remove it on the domain part
239+
domain = domain.split(",")[0]
240+
return `${local}@${domain}`
241+
242+
// You can also throw an error, which will redirect the user
243+
// to the error page with error=EmailSignin in the URL
244+
// if (identifier.split("@").length > 2) {
245+
// throw new Error("Only one email allowed")
246+
// }
247+
},
248+
})
249+
```
250+
251+
:::warning
252+
Always make sure this returns a single e-mail address, even if multiple ones were passed in.
253+
:::

‎packages/next-auth/config/jest.client.config.js

-16
This file was deleted.
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/** @type {import('jest').Config} */
2+
module.exports = {
3+
projects: [
4+
{
5+
displayName: "core",
6+
testMatch: ["**/*.test.ts"],
7+
rootDir: ".",
8+
setupFilesAfterEnv: ["./config/jest-setup.js"],
9+
transform: {
10+
"\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
11+
},
12+
coveragePathIgnorePatterns: ["tests"],
13+
},
14+
{
15+
displayName: "client",
16+
testMatch: ["**/*.test.js"],
17+
setupFilesAfterEnv: ["./config/jest-setup.js"],
18+
rootDir: ".",
19+
transform: {
20+
"\\.(js|jsx|ts|tsx)$": ["@swc/jest", require("./swc.config")],
21+
},
22+
testEnvironment: "jsdom",
23+
coveragePathIgnorePatterns: ["__tests__"],
24+
},
25+
],
26+
watchPlugins: [
27+
"jest-watch-typeahead/filename",
28+
"jest-watch-typeahead/testname",
29+
],
30+
collectCoverage: true,
31+
coverageDirectory: "../coverage",
32+
coverageReporters: ["html", "text-summary"],
33+
collectCoverageFrom: ["src/**/*.(js|jsx|ts|tsx)"],
34+
}

‎packages/next-auth/config/jest.core.config.js

-13
This file was deleted.

‎packages/next-auth/package.json

+1-3
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,7 @@
4242
"build:js": "pnpm clean && pnpm generate-providers && tsc && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"",
4343
"build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js",
4444
"watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir .",
45-
"test:client": "jest --config ./config/jest.client.config.js",
46-
"test:core": "jest --config ./config/jest.core.config.js",
47-
"test": "pnpm test:core && pnpm test:client",
45+
"test": "jest --config ./config/jest.config.js",
4846
"prepublishOnly": "pnpm build",
4947
"generate-providers": "node ./config/generate-providers.js",
5048
"setup": "pnpm generate-providers",

‎packages/next-auth/src/core/lib/email/signin.ts

+18-17
Original file line numberDiff line numberDiff line change
@@ -21,27 +21,28 @@ export default async function email(
2121
Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000
2222
)
2323

24-
// Save in database
25-
// @ts-expect-error
26-
await adapter.createVerificationToken({
27-
identifier,
28-
token: hashToken(token, options),
29-
expires,
30-
})
31-
3224
// Generate a link with email, unhashed token and callback url
3325
const params = new URLSearchParams({ callbackUrl, token, email: identifier })
3426
const _url = `${url}/callback/${provider.id}?${params}`
3527

36-
// Send to user
37-
await provider.sendVerificationRequest({
38-
identifier,
39-
token,
40-
expires,
41-
url: _url,
42-
provider,
43-
theme,
44-
})
28+
await Promise.all([
29+
// Send to user
30+
provider.sendVerificationRequest({
31+
identifier,
32+
token,
33+
expires,
34+
url: _url,
35+
provider,
36+
theme,
37+
}),
38+
// Save in database
39+
// @ts-expect-error // verified in `assertConfig`
40+
adapter.createVerificationToken({
41+
identifier,
42+
token: hashToken(token, options),
43+
expires,
44+
}),
45+
])
4546

4647
return `${url}/verify-request?${new URLSearchParams({
4748
provider: provider.id,

‎packages/next-auth/src/core/routes/signin.ts

+20-13
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,26 @@ export default async function signin(params: {
3333
return { redirect: `${url}/error?error=OAuthSignin` }
3434
}
3535
} else if (provider.type === "email") {
36-
/**
37-
* @note Technically the part of the email address local mailbox element
38-
* (everything before the @ symbol) should be treated as 'case sensitive'
39-
* according to RFC 2821, but in practice this causes more problems than
40-
* it solves. We treat email addresses as all lower case. If anyone
41-
* complains about this we can make strict RFC 2821 compliance an option.
42-
*/
43-
const email = body?.email?.toLowerCase()
44-
36+
let email: string = body?.email
4537
if (!email) return { redirect: `${url}/error?error=EmailSignin` }
38+
const normalizer: (identifier: string) => string =
39+
provider.normalizeIdentifier ??
40+
((identifier) => {
41+
// Get the first two elements only,
42+
// separated by `@` from user input.
43+
let [local, domain] = identifier.toLowerCase().trim().split("@")
44+
// The part before "@" can contain a ","
45+
// but we remove it on the domain part
46+
domain = domain.split(",")[0]
47+
return `${local}@${domain}`
48+
})
49+
50+
try {
51+
email = normalizer(body?.email)
52+
} catch (error) {
53+
logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
54+
return { redirect: `${url}/error?error=EmailSignin` }
55+
}
4656

4757
// Verified in `assertConfig`
4858
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -85,10 +95,7 @@ export default async function signin(params: {
8595
const redirect = await emailSignin(email, options)
8696
return { redirect }
8797
} catch (error) {
88-
logger.error("SIGNIN_EMAIL_ERROR", {
89-
error: error as Error,
90-
providerId: provider.id,
91-
})
98+
logger.error("SIGNIN_EMAIL_ERROR", { error, providerId: provider.id })
9299
return { redirect: `${url}/error?error=EmailSignin` }
93100
}
94101
}

‎packages/next-auth/src/providers/email.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ export interface EmailConfig extends CommonProviderOptions {
4646
generateVerificationToken?: () => Awaitable<string>
4747
/** If defined, it is used to hash the verification token when saving to the database . */
4848
secret?: string
49+
/**
50+
* Normalizes the user input before sending the verification request.
51+
*
52+
* ⚠️ Always make sure this method returns a single email address.
53+
*
54+
* @note Technically, the part of the email address local mailbox element
55+
* (everything before the `@` symbol) should be treated as 'case sensitive'
56+
* according to RFC 2821, but in practice this causes more problems than
57+
* it solves, e.g.: when looking up users by e-mail from databases.
58+
* By default, we treat email addresses as all lower case,
59+
* but you can override this function to change this behavior.
60+
*
61+
* [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)
62+
*/
63+
normalizeIdentifier?: (identifier: string) => string
4964
options: EmailUserConfig
5065
}
5166

@@ -79,7 +94,7 @@ export default function Email(options: EmailUserConfig): EmailConfig {
7994
})
8095
const failed = result.rejected.concat(result.pending).filter(Boolean)
8196
if (failed.length) {
82-
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`)
97+
throw new Error(`Email (${failed.join(", ")}) could not be sent`)
8398
}
8499
},
85100
options,
+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { createCSRF, handler, mockAdapter } from "./lib"
2+
import EmailProvider from "../src/providers/email"
3+
4+
it("Send e-mail to the only address correctly", async () => {
5+
const { secret, csrf } = await createCSRF()
6+
const sendVerificationRequest = jest.fn()
7+
const signIn = jest.fn(() => true)
8+
9+
const email = "email@example.com"
10+
const { res } = await handler(
11+
{
12+
adapter: mockAdapter(),
13+
providers: [EmailProvider({ sendVerificationRequest })],
14+
callbacks: { signIn },
15+
secret,
16+
},
17+
{
18+
path: "signin/email",
19+
requestInit: {
20+
method: "POST",
21+
headers: { cookie: csrf.cookie },
22+
body: JSON.stringify({ email: email, csrfToken: csrf.value }),
23+
},
24+
}
25+
)
26+
27+
expect(res.redirect).toBe(
28+
"http://localhost:3000/api/auth/verify-request?provider=email&type=email"
29+
)
30+
31+
expect(signIn).toBeCalledTimes(1)
32+
expect(signIn).toHaveBeenCalledWith(
33+
expect.objectContaining({
34+
user: expect.objectContaining({ email }),
35+
})
36+
)
37+
38+
expect(sendVerificationRequest).toHaveBeenCalledWith(
39+
expect.objectContaining({ identifier: email })
40+
)
41+
})
42+
43+
it("Send e-mail to first address only", async () => {
44+
const { secret, csrf } = await createCSRF()
45+
const sendVerificationRequest = jest.fn()
46+
const signIn = jest.fn(() => true)
47+
48+
const firstEmail = "email@email.com"
49+
const email = `${firstEmail},email@email2.com`
50+
const { res } = await handler(
51+
{
52+
adapter: mockAdapter(),
53+
providers: [EmailProvider({ sendVerificationRequest })],
54+
callbacks: { signIn },
55+
secret,
56+
},
57+
{
58+
path: "signin/email",
59+
requestInit: {
60+
method: "POST",
61+
headers: { cookie: csrf.cookie },
62+
body: JSON.stringify({ email: email, csrfToken: csrf.value }),
63+
},
64+
}
65+
)
66+
67+
expect(res.redirect).toBe(
68+
"http://localhost:3000/api/auth/verify-request?provider=email&type=email"
69+
)
70+
71+
expect(signIn).toBeCalledTimes(1)
72+
expect(signIn).toHaveBeenCalledWith(
73+
expect.objectContaining({
74+
user: expect.objectContaining({ email: firstEmail }),
75+
})
76+
)
77+
78+
expect(sendVerificationRequest).toHaveBeenCalledWith(
79+
expect.objectContaining({ identifier: firstEmail })
80+
)
81+
})
82+
83+
it("Send e-mail to address with first domain", async () => {
84+
const { secret, csrf } = await createCSRF()
85+
const sendVerificationRequest = jest.fn()
86+
const signIn = jest.fn(() => true)
87+
88+
const firstEmail = "email@email.com"
89+
const email = `${firstEmail},email2.com`
90+
const { res } = await handler(
91+
{
92+
adapter: mockAdapter(),
93+
providers: [EmailProvider({ sendVerificationRequest })],
94+
callbacks: { signIn },
95+
secret,
96+
},
97+
{
98+
path: "signin/email",
99+
requestInit: {
100+
method: "POST",
101+
headers: { cookie: csrf.cookie },
102+
body: JSON.stringify({ email: email, csrfToken: csrf.value }),
103+
},
104+
}
105+
)
106+
107+
expect(res.redirect).toBe(
108+
"http://localhost:3000/api/auth/verify-request?provider=email&type=email"
109+
)
110+
111+
expect(signIn).toBeCalledTimes(1)
112+
expect(signIn).toHaveBeenCalledWith(
113+
expect.objectContaining({
114+
user: expect.objectContaining({ email: firstEmail }),
115+
})
116+
)
117+
118+
expect(sendVerificationRequest).toHaveBeenCalledWith(
119+
expect.objectContaining({ identifier: firstEmail })
120+
)
121+
})
122+
123+
it("Redirect to error page if multiple addresses aren't allowed", async () => {
124+
const { secret, csrf } = await createCSRF()
125+
const sendVerificationRequest = jest.fn()
126+
const signIn = jest.fn()
127+
const error = new Error("Only one email allowed")
128+
const { res, log } = await handler(
129+
{
130+
adapter: mockAdapter(),
131+
callbacks: { signIn },
132+
providers: [
133+
EmailProvider({
134+
sendVerificationRequest,
135+
normalizeIdentifier(identifier) {
136+
if (identifier.split("@").length > 2) throw error
137+
return identifier
138+
},
139+
}),
140+
],
141+
secret,
142+
},
143+
{
144+
path: "signin/email",
145+
requestInit: {
146+
method: "POST",
147+
headers: { cookie: csrf.cookie },
148+
body: JSON.stringify({
149+
email: "email@email.com,email@email2.com",
150+
csrfToken: csrf.value,
151+
}),
152+
},
153+
}
154+
)
155+
156+
expect(signIn).toBeCalledTimes(0)
157+
expect(sendVerificationRequest).toBeCalledTimes(0)
158+
159+
expect(log.error.mock.calls[0]).toEqual([
160+
"SIGNIN_EMAIL_ERROR",
161+
{ error, providerId: "email" },
162+
])
163+
164+
expect(res.redirect).toBe(
165+
"http://localhost:3000/api/auth/error?error=EmailSignin"
166+
)
167+
})

‎packages/next-auth/tests/getServerSession.test.ts

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { NextApiRequest } from "next"
21
import { MissingSecret } from "../src/core/errors"
32
import { unstable_getServerSession } from "../src/next"
43
import { mockLogger } from "./lib"

‎packages/next-auth/tests/lib.ts

+9-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from "crypto"
2-
import type { LoggerInstance, NextAuthOptions } from "../src"
32
import { NextAuthHandler } from "../src/core"
3+
import type { LoggerInstance, NextAuthOptions } from "../src"
4+
import type { Adapter } from "../src/adapters"
45

56
export const mockLogger: () => LoggerInstance = () => ({
67
error: jest.fn(() => {}),
@@ -56,3 +57,10 @@ export function createCSRF() {
5657
csrf: { value, token, cookie: `next-auth.csrf-token=${value}|${token}` },
5758
}
5859
}
60+
61+
export function mockAdapter(): Adapter {
62+
return {
63+
createVerificationToken: jest.fn(() => {}),
64+
getUserByEmail: jest.fn(() => {}),
65+
} as Adapter
66+
}

‎packages/next-auth/tsconfig.json

+8-1
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,12 @@
1919
"next": ["node_modules/next"]
2020
}
2121
},
22-
"exclude": ["./*.js", "./*.d.ts", "config", "**/__tests__", "tests"]
22+
"exclude": [
23+
"./*.js",
24+
"./*.d.ts",
25+
"config",
26+
"**/__tests__",
27+
"tests",
28+
"coverage"
29+
]
2330
}

1 commit comments

Comments
 (1)
Please sign in to comment.