Skip to content

Commit 7e91d7d

Browse files
authoredDec 3, 2022
refactor(core): use standard Request and Response (#4769)
* WIP use `Request` and `Response` for core * bump Next.js * rename ts types * refactor * simplify * upgrade Next.js * implement body reader * use `Request`/`Response` in `next-auth/next` * make linter happy * revert * fix tests * remove workaround for middleware return type * return session in protected api route example * don't export internal handler * fall back host to localhost * refactor `getBody` * refactor `next-auth/next` * chore: add `@edge-runtime/jest-environment` * fix tests, using Node 18 as runtime * fix test * remove patch * fix neo4j build * remove new-line * reduce file changes in the PR * fix tests * fix tests * refactor * refactor * add host tests * refactor tests * fix body reading * fix tests * use 302 * fix test * fix again * fix tests * handle when body is `Buffer` * move comment
1 parent c142413 commit 7e91d7d

17 files changed

+588
-305
lines changed
 

‎packages/next-auth/src/core/index.ts

+25-51
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logger, { setLogger } from "../utils/logger"
2-
import { detectHost } from "../utils/detect-host"
2+
import { toInternalRequest, toResponse } from "../utils/web"
33
import * as routes from "./routes"
44
import renderPage from "./pages"
55
import { init } from "./init"
@@ -9,7 +9,6 @@ import { SessionStore } from "./lib/cookie"
99
import type { NextAuthAction, NextAuthOptions } from "./types"
1010
import type { Cookie } from "./lib/cookie"
1111
import type { ErrorType } from "./pages/error"
12-
import { parse as parseCookie } from "cookie"
1312

1413
export interface RequestInternal {
1514
/** @default "http://localhost:3000" */
@@ -29,6 +28,7 @@ export interface NextAuthHeader {
2928
value: string
3029
}
3130

31+
// TODO: Rename to `ResponseInternal`
3232
export interface OutgoingResponse<
3333
Body extends string | Record<string, any> | any[] = any
3434
> {
@@ -39,56 +39,15 @@ export interface OutgoingResponse<
3939
cookies?: Cookie[]
4040
}
4141

42-
export interface NextAuthHandlerParams {
43-
req: Request | RequestInternal
44-
options: NextAuthOptions
45-
}
46-
47-
async function getBody(req: Request): Promise<Record<string, any> | undefined> {
48-
try {
49-
return await req.json()
50-
} catch {}
51-
}
52-
53-
// TODO:
54-
async function toInternalRequest(
55-
req: RequestInternal | Request,
56-
trustHost: boolean = false
57-
): Promise<RequestInternal> {
58-
if (req instanceof Request) {
59-
const url = new URL(req.url)
60-
// TODO: handle custom paths?
61-
const nextauth = url.pathname.split("/").slice(3)
62-
const headers = Object.fromEntries(req.headers)
63-
const query: Record<string, any> = Object.fromEntries(url.searchParams)
64-
query.nextauth = nextauth
65-
66-
return {
67-
action: nextauth[0] as NextAuthAction,
68-
method: req.method,
69-
headers,
70-
body: await getBody(req),
71-
cookies: parseCookie(req.headers.get("cookie") ?? ""),
72-
providerId: nextauth[1],
73-
error: url.searchParams.get("error") ?? nextauth[1],
74-
host: detectHost(
75-
trustHost,
76-
headers["x-forwarded-host"] ?? headers.host,
77-
"http://localhost:3000"
78-
),
79-
query,
80-
}
81-
}
82-
return req
83-
}
84-
85-
export async function NextAuthHandler<
42+
async function AuthHandlerInternal<
8643
Body extends string | Record<string, any> | any[]
87-
>(params: NextAuthHandlerParams): Promise<OutgoingResponse<Body>> {
88-
const { options: userOptions, req: incomingRequest } = params
89-
90-
const req = await toInternalRequest(incomingRequest, userOptions.trustHost)
91-
44+
>(params: {
45+
req: RequestInternal
46+
options: NextAuthOptions
47+
/** REVIEW: Is this the best way to skip parsing the body in Node.js? */
48+
parsedBody?: any
49+
}): Promise<OutgoingResponse<Body>> {
50+
const { options: userOptions, req } = params
9251
setLogger(userOptions.logger, userOptions.debug)
9352

9453
const assertionResult = assertConfig({ options: userOptions, req })
@@ -159,6 +118,7 @@ export async function NextAuthHandler<
159118
case "session": {
160119
const session = await routes.session({ options, sessionStore })
161120
if (session.cookies) cookies.push(...session.cookies)
121+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
162122
return { ...session, cookies } as any
163123
}
164124
case "csrf":
@@ -299,3 +259,17 @@ export async function NextAuthHandler<
299259
body: `Error: This action with HTTP ${method} is not supported by NextAuth.js` as any,
300260
}
301261
}
262+
263+
/**
264+
* The core functionality of `next-auth`.
265+
* It receives a standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request)
266+
* and returns a standard [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response).
267+
*/
268+
export async function AuthHandler(
269+
request: Request,
270+
options: NextAuthOptions
271+
): Promise<Response> {
272+
const req = await toInternalRequest(request)
273+
const internalResponse = await AuthHandlerInternal({ req, options })
274+
return toResponse(internalResponse)
275+
}

‎packages/next-auth/src/index.ts

-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
export * from "./core/types"
22

3-
export type { RequestInternal, OutgoingResponse } from "./core"
4-
53
export * from "./next"
64
export { default } from "./next"

‎packages/next-auth/src/next/index.ts

+56-81
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { NextAuthHandler } from "../core"
2-
import { detectHost } from "../utils/detect-host"
3-
import { setCookie } from "./utils"
1+
import { AuthHandler } from "../core"
2+
import { getURL, getBody } from "../utils/node"
43

54
import type {
65
GetServerSidePropsContext,
@@ -10,60 +9,49 @@ import type {
109
import type { NextAuthOptions, Session } from ".."
1110
import type {
1211
CallbacksOptions,
13-
NextAuthAction,
1412
NextAuthRequest,
1513
NextAuthResponse,
1614
} from "../core/types"
1715

18-
async function NextAuthNextHandler(
16+
async function NextAuthHandler(
1917
req: NextApiRequest,
2018
res: NextApiResponse,
2119
options: NextAuthOptions
2220
) {
23-
const { nextauth, ...query } = req.query
24-
25-
options.secret ??= options.jwt?.secret ?? process.env.NEXTAUTH_SECRET
26-
options.trustHost ??= !!(process.env.AUTH_TRUST_HOST ?? process.env.VERCEL)
27-
28-
const handler = await NextAuthHandler({
29-
req: {
30-
host: detectHost(
31-
options.trustHost,
32-
req.headers["x-forwarded-host"],
33-
process.env.NEXTAUTH_URL ??
34-
(process.env.NODE_ENV !== "production" && "http://localhost:3000")
35-
),
36-
body: req.body,
37-
query,
38-
cookies: req.cookies,
39-
headers: req.headers,
40-
method: req.method,
41-
action: nextauth?.[0] as NextAuthAction,
42-
providerId: nextauth?.[1],
43-
error: (req.query.error as string | undefined) ?? nextauth?.[1],
44-
},
45-
options,
21+
const url = getURL(
22+
req.url,
23+
options.trustHost,
24+
req.headers["x-forwarded-host"] ?? req.headers.host
25+
)
26+
27+
if (url instanceof Error) return res.status(400).end()
28+
29+
const request = new Request(url, {
30+
headers: new Headers(req.headers as any),
31+
method: req.method,
32+
...getBody(req),
4633
})
4734

48-
res.status(handler.status ?? 200)
35+
options.secret ??= options.jwt?.secret ?? process.env.NEXTAUTH_SECRET
36+
const response = await AuthHandler(request, options)
37+
const { status, headers } = response
38+
res.status(status)
4939

50-
handler.cookies?.forEach((cookie) => setCookie(res, cookie))
40+
for (const [key, val] of headers.entries()) {
41+
const value = key === "set-cookie" ? val.split(",") : val
Has a conversation. Original line has a conversation.
42+
res.setHeader(key, value)
43+
}
5144

52-
handler.headers?.forEach((h) => res.setHeader(h.key, h.value))
45+
// If the request expects a return URL, send it as JSON
46+
// instead of doing an actual redirect.
47+
const redirect = headers.get("Location")
5348

54-
if (handler.redirect) {
55-
// If the request expects a return URL, send it as JSON
56-
// instead of doing an actual redirect.
57-
if (req.body?.json !== "true") {
58-
// Could chain. .end() when lowest target is Node 14
59-
// https://github.com/nodejs/node/issues/33148
60-
res.status(302).setHeader("Location", handler.redirect)
61-
return res.end()
62-
}
63-
return res.json({ url: handler.redirect })
49+
if (req.body?.json === "true" && redirect) {
50+
res.removeHeader("Location")
51+
return res.json({ url: redirect })
6452
}
6553

66-
return res.send(handler.body)
54+
return res.send(await response.text())
6755
}
6856

6957
function NextAuth(options: NextAuthOptions): any
@@ -81,10 +69,10 @@ function NextAuth(
8169
) {
8270
if (args.length === 1) {
8371
return async (req: NextAuthRequest, res: NextAuthResponse) =>
84-
await NextAuthNextHandler(req, res, args[0])
72+
await NextAuthHandler(req, res, args[0])
8573
}
8674

87-
return NextAuthNextHandler(args[0], args[1], args[2])
75+
return NextAuthHandler(args[0], args[1], args[2])
8876
}
8977

9078
export default NextAuth
@@ -93,7 +81,7 @@ let experimentalWarningShown = false
9381
let experimentalRSCWarningShown = false
9482

9583
type GetServerSessionOptions = Partial<Omit<NextAuthOptions, "callbacks">> & {
96-
callbacks?: Omit<NextAuthOptions['callbacks'], "session"> & {
84+
callbacks?: Omit<NextAuthOptions["callbacks"], "session"> & {
9785
session?: (...args: Parameters<CallbacksOptions["session"]>) => any
9886
}
9987
}
@@ -156,47 +144,34 @@ export async function unstable_getServerSession<
156144
options = Object.assign(args[2], { providers: [] })
157145
}
158146

159-
options.secret ??= process.env.NEXTAUTH_SECRET
160-
options.trustHost ??= !!(process.env.AUTH_TRUST_HOST ?? process.env.VERCEL)
161-
162-
const session = await NextAuthHandler<Session | {} | string>({
163-
options,
164-
req: {
165-
host: detectHost(
166-
options.trustHost,
167-
req.headers["x-forwarded-host"],
168-
process.env.NEXTAUTH_URL ??
169-
(process.env.NODE_ENV !== "production" && "http://localhost:3000")
170-
),
171-
action: "session",
172-
method: "GET",
173-
cookies: req.cookies,
174-
headers: req.headers,
175-
},
176-
})
147+
const urlOrError = getURL(
148+
"/api/auth/session",
149+
options.trustHost,
150+
req.headers["x-forwarded-host"] ?? req.headers.host
151+
)
152+
153+
if (urlOrError instanceof Error) throw urlOrError
177154

178-
const { body, cookies, status = 200 } = session
155+
options.secret ??= process.env.NEXTAUTH_SECRET
156+
const response = await AuthHandler(
157+
new Request(urlOrError, { headers: req.headers }),
158+
options
159+
)
179160

180-
cookies?.forEach((cookie) => setCookie(res, cookie))
161+
const { status = 200, headers } = response
181162

182-
if (body && typeof body !== "string" && Object.keys(body).length) {
183-
if (status === 200) {
184-
// @ts-expect-error
185-
if (isRSC) delete body.expires
186-
return body as R
187-
}
188-
throw new Error((body as any).message)
163+
for (const [key, val] of headers.entries()) {
164+
const value = key === "set-cookie" ? val.split(",") : val
165+
res.setHeader(key, value)
189166
}
190167

191-
return null
192-
}
168+
const data = await response.json()
193169

194-
declare global {
195-
// eslint-disable-next-line @typescript-eslint/no-namespace
196-
namespace NodeJS {
197-
interface ProcessEnv {
198-
NEXTAUTH_URL?: string
199-
VERCEL?: "1"
200-
}
170+
if (!data || !Object.keys(data).length) return null
171+
172+
if (status === 200) {
173+
if (isRSC) delete data.expires
174+
return data as R
201175
}
176+
throw new Error(data.message)
202177
}

‎packages/next-auth/src/next/middleware.ts

+14-16
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { NextResponse, NextRequest } from "next/server"
66

77
import { getToken } from "../jwt"
88
import parseUrl from "../utils/parse-url"
9-
import { detectHost } from "../utils/detect-host"
9+
import { getURL } from "../utils/node"
1010

1111
type AuthorizedCallback = (params: {
1212
token: JWT | null
@@ -103,14 +103,10 @@ export interface NextAuthMiddlewareOptions {
103103
trustHost?: NextAuthOptions["trustHost"]
104104
}
105105

106-
// TODO: `NextMiddleware` should allow returning `void`
107-
// Simplify when https://github.com/vercel/next.js/pull/38625 is merged.
108-
type NextMiddlewareResult = ReturnType<NextMiddleware> | void // eslint-disable-line @typescript-eslint/no-invalid-void-type
109-
110106
async function handleMiddleware(
111107
req: NextRequest,
112108
options: NextAuthMiddlewareOptions | undefined = {},
113-
onSuccess?: (token: JWT | null) => Promise<NextMiddlewareResult>
109+
onSuccess?: (token: JWT | null) => ReturnType<NextMiddleware>
114110
) {
115111
const { pathname, search, origin, basePath } = req.nextUrl
116112

@@ -121,13 +117,15 @@ async function handleMiddleware(
121117
options.trustHost ?? process.env.VERCEL ?? process.env.AUTH_TRUST_HOST
122118
)
123119

124-
const host = detectHost(
120+
let authPath
121+
const url = getURL(
122+
null,
125123
options.trustHost,
126-
req.headers.get("x-forwarded-host"),
127-
process.env.NEXTAUTH_URL ??
128-
(process.env.NODE_ENV !== "production" && "http://localhost:3000")
124+
req.headers.get("x-forwarded-host") ?? req.headers.get("host")
129125
)
130-
const authPath = parseUrl(host).path
126+
if (url instanceof URL) authPath = parseUrl(url).path
127+
else authPath = "/api/auth"
128+
131129
const publicPaths = ["/_next", "/favicon.ico"]
132130

133131
// Avoid infinite redirects/invalid response
@@ -140,8 +138,8 @@ async function handleMiddleware(
140138
return
141139
}
142140

143-
const secret = options?.secret ?? process.env.NEXTAUTH_SECRET
144-
if (!secret) {
141+
options.secret ??= process.env.NEXTAUTH_SECRET
142+
if (!options.secret) {
145143
console.error(
146144
`[next-auth][error][NO_SECRET]`,
147145
`\nhttps://next-auth.js.org/errors#no_secret`
@@ -155,9 +153,9 @@ async function handleMiddleware(
155153

156154
const token = await getToken({
157155
req,
158-
decode: options?.jwt?.decode,
156+
decode: options.jwt?.decode,
159157
cookieName: options?.cookies?.sessionToken?.name,
160-
secret,
158+
secret: options.secret,
161159
})
162160

163161
const isAuthorized =
@@ -182,7 +180,7 @@ export interface NextRequestWithAuth extends NextRequest {
182180
export type NextMiddlewareWithAuth = (
183181
request: NextRequestWithAuth,
184182
event: NextFetchEvent
185-
) => NextMiddlewareResult | Promise<NextMiddlewareResult>
183+
) => ReturnType<NextMiddleware>
186184

187185
export type WithAuthArgs =
188186
| [NextRequestWithAuth]

‎packages/next-auth/src/next/utils.ts

-15
This file was deleted.

‎packages/next-auth/src/utils/detect-host.ts

-12
This file was deleted.

‎packages/next-auth/src/utils/node.ts

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { IncomingMessage } from "http"
2+
import type { GetServerSidePropsContext, NextApiRequest } from "next"
3+
4+
export function setCookie(res, value: string) {
5+
// Preserve any existing cookies that have already been set in the same session
6+
let setCookieHeader = res.getHeader("Set-Cookie") ?? []
7+
// If not an array (i.e. a string with a single cookie) convert it into an array
8+
if (!Array.isArray(setCookieHeader)) {
9+
setCookieHeader = [setCookieHeader]
10+
}
11+
setCookieHeader.push(value)
12+
res.setHeader("Set-Cookie", setCookieHeader)
13+
}
14+
15+
export function getBody(
16+
req: IncomingMessage | NextApiRequest | GetServerSidePropsContext["req"]
17+
) {
18+
if (!("body" in req) || !req.body || req.method !== "POST") {
19+
return
20+
}
21+
22+
if (req.body instanceof ReadableStream) {
23+
return { body: req.body }
24+
}
25+
return { body: JSON.stringify(req.body) }
26+
}
27+
28+
/** Extract the host from the environment */
29+
export function getURL(
30+
url: string | undefined | null,
31+
trusted: boolean | undefined = !!(
32+
process.env.AUTH_TRUST_HOST ?? process.env.VERCEL
33+
),
34+
forwardedValue: string | string[] | undefined | null
35+
): URL | Error {
36+
try {
37+
let host =
38+
process.env.NEXTAUTH_URL ??
39+
(process.env.NODE_ENV !== "production" && "http://localhost:3000")
40+
41+
if (trusted && forwardedValue) {
42+
host = Array.isArray(forwardedValue) ? forwardedValue[0] : forwardedValue
43+
}
44+
45+
if (!host) throw new TypeError("Invalid host")
46+
47+
return new URL(url ?? "", new URL(host))
48+
} catch (error) {
49+
return error as Error
50+
}
51+
}
52+
53+
declare global {
54+
// eslint-disable-next-line @typescript-eslint/no-namespace
55+
namespace NodeJS {
56+
interface ProcessEnv {
57+
AUTH_TRUST_HOST?: string
58+
NEXTAUTH_URL?: string
59+
NEXTAUTH_SECRET?: string
60+
VERCEL?: "1"
61+
}
62+
}
63+
}

‎packages/next-auth/src/utils/parse-url.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ export interface InternalUrl {
1212
}
1313

1414
/** Returns an `URL` like object to make requests/redirects from server-side */
15-
export default function parseUrl(url?: string): InternalUrl {
15+
export default function parseUrl(url?: string | URL): InternalUrl {
1616
const defaultUrl = new URL("http://localhost:3000/api/auth")
1717

18-
if (url && !url.startsWith("http")) {
18+
if (url && !url.toString().startsWith("http")) {
1919
url = `https://${url}`
2020
}
2121

‎packages/next-auth/src/utils/web.ts

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { serialize, parse as parseCookie } from "cookie"
2+
import type { OutgoingResponse, RequestInternal } from "../core"
3+
import type { NextAuthAction } from "../core/types"
4+
5+
const decoder = new TextDecoder()
6+
7+
async function streamToString(stream): Promise<string> {
8+
const chunks: Uint8Array[] = []
9+
return await new Promise((resolve, reject) => {
10+
stream.on("data", (chunk) => chunks.push(Buffer.from(chunk)))
11+
stream.on("error", (err) => reject(err))
12+
stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")))
13+
})
14+
}
15+
16+
async function readJSONBody(
17+
body: ReadableStream | Buffer
18+
): Promise<Record<string, any> | undefined> {
19+
try {
20+
if ("getReader" in body) {
21+
const reader = body.getReader()
22+
const bytes: number[] = []
23+
while (true) {
24+
const { value, done } = await reader.read()
25+
if (done) break
26+
bytes.push(...value)
27+
}
28+
const b = new Uint8Array(bytes)
29+
return JSON.parse(decoder.decode(b))
30+
}
31+
32+
// node-fetch
33+
34+
if (typeof Buffer !== "undefined" && Buffer.isBuffer(body)) {
35+
return JSON.parse(body.toString("utf8"))
36+
}
37+
38+
return JSON.parse(await streamToString(body))
39+
} catch (e) {
40+
console.error(e)
41+
}
42+
}
43+
44+
export async function toInternalRequest(
45+
req: Request
46+
): Promise<RequestInternal> {
47+
const url = new URL(req.url)
48+
const nextauth = url.pathname.split("/").slice(3)
49+
const headers = Object.fromEntries(req.headers)
50+
const query: Record<string, any> = Object.fromEntries(url.searchParams)
51+
52+
const cookieHeader = req.headers.get("cookie") ?? ""
53+
const cookies =
54+
parseCookie(
55+
Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader
56+
) ?? {}
57+
58+
return {
59+
action: nextauth[0] as NextAuthAction,
60+
method: req.method,
61+
headers,
62+
body: req.body ? await readJSONBody(req.body) : undefined,
63+
cookies: cookies,
64+
providerId: nextauth[1],
65+
error: url.searchParams.get("error") ?? undefined,
66+
host: new URL(req.url).origin,
67+
query,
68+
}
69+
}
70+
71+
export function toResponse(res: OutgoingResponse): Response {
72+
const headers = new Headers(
73+
res.headers?.reduce((acc, { key, value }) => {
74+
acc[key] = value
75+
return acc
76+
}, {})
77+
)
78+
79+
res.cookies?.forEach((cookie) => {
80+
const { name, value, options } = cookie
81+
const cookieHeader = serialize(name, value, options)
82+
if (headers.has("Set-Cookie")) {
83+
headers.append("Set-Cookie", cookieHeader)
84+
} else {
85+
headers.set("Set-Cookie", cookieHeader)
86+
}
87+
})
88+
89+
const body =
90+
headers.get("content-type") === "application/json"
91+
? JSON.stringify(res.body)
92+
: res.body
93+
94+
const response = new Response(body, {
95+
headers,
96+
status: res.redirect ? 302 : res.status ?? 200,
97+
})
98+
99+
if (res.redirect) {
100+
response.headers.set("Location", res.redirect)
101+
}
102+
103+
return response
104+
}

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {
44
MissingAdapterMethods,
55
MissingSecret,
66
} from "../src/core/errors"
7-
import { handler } from "./lib"
7+
import { handler } from "./utils"
88
import EmailProvider from "../src/providers/email"
99

1010
it("Show error page if secret is not defined", async () => {

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

+6-6
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { createCSRF, handler, mockAdapter } from "./lib"
1+
import { createCSRF, handler, mockAdapter } from "./utils"
22
import EmailProvider from "../src/providers/email"
33

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

@@ -18,7 +19,7 @@ it("Send e-mail to the only address correctly", async () => {
1819
path: "signin/email",
1920
requestInit: {
2021
method: "POST",
21-
headers: { cookie: csrf.cookie },
22+
headers: { cookie: csrf.cookie, "content-type": "application/json" },
2223
body: JSON.stringify({ email: email, csrfToken: csrf.value }),
2324
},
2425
}
@@ -58,7 +59,7 @@ it("Send e-mail to first address only", async () => {
5859
path: "signin/email",
5960
requestInit: {
6061
method: "POST",
61-
headers: { cookie: csrf.cookie },
62+
headers: { cookie: csrf.cookie, "content-type": "application/json" },
6263
body: JSON.stringify({ email: email, csrfToken: csrf.value }),
6364
},
6465
}
@@ -98,7 +99,7 @@ it("Send e-mail to address with first domain", async () => {
9899
path: "signin/email",
99100
requestInit: {
100101
method: "POST",
101-
headers: { cookie: csrf.cookie },
102+
headers: { cookie: csrf.cookie, "content-type": "application/json" },
102103
body: JSON.stringify({ email: email, csrfToken: csrf.value }),
103104
},
104105
}
@@ -144,7 +145,7 @@ it("Redirect to error page if multiple addresses aren't allowed", async () => {
144145
path: "signin/email",
145146
requestInit: {
146147
method: "POST",
147-
headers: { cookie: csrf.cookie },
148+
headers: { cookie: csrf.cookie, "content-type": "application/json" },
148149
body: JSON.stringify({
149150
email: "email@email.com,email@email2.com",
150151
csrfToken: csrf.value,
@@ -156,7 +157,6 @@ it("Redirect to error page if multiple addresses aren't allowed", async () => {
156157
expect(signIn).toBeCalledTimes(0)
157158
expect(sendVerificationRequest).toBeCalledTimes(0)
158159

159-
// @ts-expect-error
160160
expect(log.error.mock.calls[0]).toEqual([
161161
"SIGNIN_EMAIL_ERROR",
162162
{ error, providerId: "email" },

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

+15-17
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as core from "../src/core"
22
import { MissingSecret } from "../src/core/errors"
33
import { unstable_getServerSession } from "../src/next"
4-
import { mockLogger } from "./lib"
4+
import { mockLogger } from "./utils"
55

66
const originalWarn = console.warn
77
let logger = mockLogger()
@@ -83,9 +83,9 @@ describe("Return correct data", () => {
8383
})
8484

8585
it("Should return null if there is no session", async () => {
86-
const spy = jest.spyOn(core, "NextAuthHandler")
87-
// @ts-expect-error
88-
spy.mockReturnValue({ body: {} })
86+
const spy = jest.spyOn(core, "AuthHandler")
87+
// @ts-expect-error [Response.json](https://developer.mozilla.org/en-US/docs/Web/API/Response/json)
88+
spy.mockReturnValue(Promise.resolve(Response.json(null)))
8989

9090
const session = await unstable_getServerSession(req, res, {
9191
providers: [],
@@ -97,28 +97,26 @@ describe("Return correct data", () => {
9797
})
9898

9999
it("Should return the session if one is found", async () => {
100-
const mockedResponse = {
101-
body: {
102-
user: {
103-
name: "John Doe",
104-
email: "test@example.com",
105-
image: "",
106-
id: "1234",
107-
},
108-
expires: "",
100+
const mockedBody = {
101+
user: {
102+
name: "John Doe",
103+
email: "test@example.com",
104+
image: "",
105+
id: "1234",
109106
},
107+
expires: "",
110108
}
111109

112-
const spy = jest.spyOn(core, "NextAuthHandler")
113-
// @ts-expect-error
114-
spy.mockReturnValue(mockedResponse)
110+
const spy = jest.spyOn(core, "AuthHandler")
111+
// @ts-expect-error [Response.json](https://developer.mozilla.org/en-US/docs/Web/API/Response/json)
112+
spy.mockReturnValue(Promise.resolve(Response.json(mockedBody)))
115113

116114
const session = await unstable_getServerSession(req, res, {
117115
providers: [],
118116
logger,
119117
secret: "secret",
120118
})
121119

122-
expect(session).toEqual(mockedResponse.body)
120+
expect(session).toEqual(mockedBody)
123121
})
124122
})

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

-68
This file was deleted.

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

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { MissingAPIRoute } from "../src/core/errors"
2+
import { nodeHandler } from "./utils"
3+
4+
it("Missing req.url throws MISSING_NEXTAUTH_API_ROUTE_ERROR", async () => {
5+
const { res, logger } = await nodeHandler()
6+
7+
expect(res.status).toBeCalledWith(500)
8+
expect(logger.error).toBeCalledTimes(1)
9+
expect(logger.error).toBeCalledWith(
10+
"MISSING_NEXTAUTH_API_ROUTE_ERROR",
11+
expect.any(MissingAPIRoute)
12+
)
13+
expect(res.setHeader).toBeCalledWith("content-type", "application/json")
14+
const body = res.send.mock.calls[0][0]
15+
expect(JSON.parse(body)).toEqual({
16+
message:
17+
"There is a problem with the server configuration. Check the server logs for more information.",
18+
})
19+
})
20+
21+
it("Missing host throws 400 in production", async () => {
22+
// @ts-expect-error
23+
process.env.NODE_ENV = "production"
24+
const { res } = await nodeHandler()
25+
expect(res.status).toBeCalledWith(400)
26+
// @ts-expect-error
27+
process.env.NODE_ENV = "test"
28+
})
29+
30+
it("Defined host throws 400 in production if not trusted", async () => {
31+
// @ts-expect-error
32+
process.env.NODE_ENV = "production"
33+
const { res } = await nodeHandler({
34+
req: { headers: { host: "http://localhost" } },
35+
})
36+
expect(res.status).toBeCalledWith(400)
37+
// @ts-expect-error
38+
process.env.NODE_ENV = "test"
39+
})
40+
41+
it("Defined host throws 400 in production if trusted but invalid URL", async () => {
42+
// @ts-expect-error
43+
process.env.NODE_ENV = "production"
44+
const { res } = await nodeHandler({
45+
req: { headers: { host: "localhost" } },
46+
options: { trustHost: true },
47+
})
48+
expect(res.status).toBeCalledWith(400)
49+
// @ts-expect-error
50+
process.env.NODE_ENV = "test"
51+
})
52+
53+
it("Defined host does not throw in production if trusted and valid URL", async () => {
54+
// @ts-expect-error
55+
process.env.NODE_ENV = "production"
56+
const { res } = await nodeHandler({
57+
req: {
58+
url: "/api/auth/session",
59+
headers: { host: "http://localhost" },
60+
},
61+
options: { trustHost: true },
62+
})
63+
expect(res.status).toBeCalledWith(200)
64+
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
65+
// @ts-expect-error
66+
process.env.NODE_ENV = "test"
67+
})
68+
69+
it("Use process.env.NEXTAUTH_URL for host if present", async () => {
70+
process.env.NEXTAUTH_URL = "http://localhost"
71+
const { res } = await nodeHandler({
72+
req: { url: "/api/auth/session" },
73+
})
74+
expect(res.status).toBeCalledWith(200)
75+
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
76+
})
77+
78+
it("Redirects if necessary", async () => {
79+
process.env.NEXTAUTH_URL = "http://localhost"
80+
const { res } = await nodeHandler({
81+
req: {
82+
method: "post",
83+
url: "/api/auth/signin/github",
84+
body: { json: "true" },
85+
},
86+
})
87+
expect(res.status).toBeCalledWith(302)
88+
expect(res.removeHeader).toBeCalledWith("Location")
89+
expect(res.json).toBeCalledWith({
90+
url: "http://localhost/api/auth/signin?csrf=true",
91+
})
92+
})

‎packages/next-auth/tests/pkce-handler.test.ts

+47-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { mockLogger } from "./lib"
2-
import type { InternalOptions, LoggerInstance, InternalProvider, CallbacksOptions, Account, Awaitable, Profile, Session, User, CookiesOptions } from "../src"
1+
import { mockLogger } from "./utils"
2+
import type {
3+
InternalOptions,
4+
LoggerInstance,
5+
InternalProvider,
6+
CallbacksOptions,
7+
Account,
8+
Awaitable,
9+
Profile,
10+
Session,
11+
User,
12+
CookiesOptions,
13+
} from "../src"
314
import { createPKCE } from "../src/core/lib/oauth/pkce-handler"
415
import { InternalUrl } from "../src/utils/parse-url"
516
import { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "../src/jwt"
@@ -21,7 +32,7 @@ beforeEach(() => {
2132
host: "localhost:3000",
2233
path: "/api/auth",
2334
base: "http://localhost:3000/api/auth",
24-
toString: () => "http://localhost:3000/api/auth"
35+
toString: () => "http://localhost:3000/api/auth",
2536
}
2637

2738
provider = {
@@ -30,7 +41,7 @@ beforeEach(() => {
3041
name: "testName",
3142
signinUrl: "/",
3243
callbackUrl: "/",
33-
checks: ["pkce", "state"]
44+
checks: ["pkce", "state"],
3445
}
3546

3647
jwt = {
@@ -41,22 +52,41 @@ beforeEach(() => {
4152
},
4253
decode: function (params: JWTDecodeParams): Awaitable<JWT | null> {
4354
throw new Error("Function not implemented.")
44-
}
55+
},
4556
}
4657

4758
callbacks = {
48-
signIn: function (params: { user: User; account: Account; profile: Profile & Record<string, unknown>; email: { verificationRequest?: boolean | undefined }; credentials?: Record<string, CredentialInput> | undefined }): Awaitable<string | boolean> {
59+
signIn: function (params: {
60+
user: User
61+
account: Account
62+
profile: Profile & Record<string, unknown>
63+
email: { verificationRequest?: boolean | undefined }
64+
credentials?: Record<string, CredentialInput> | undefined
65+
}): Awaitable<string | boolean> {
4966
throw new Error("Function not implemented.")
5067
},
51-
redirect: function (params: { url: string; baseUrl: string }): Awaitable<string> {
68+
redirect: function (params: {
69+
url: string
70+
baseUrl: string
71+
}): Awaitable<string> {
5272
throw new Error("Function not implemented.")
5373
},
54-
session: function (params: { session: Session; user: User; token: JWT }): Awaitable<Session> {
74+
session: function (params: {
75+
session: Session
76+
user: User
77+
token: JWT
78+
}): Awaitable<Session> {
5579
throw new Error("Function not implemented.")
5680
},
57-
jwt: function (params: { token: JWT; user?: User | undefined; account?: Account | undefined; profile?: Profile | undefined; isNewUser?: boolean | undefined }): Awaitable<JWT> {
81+
jwt: function (params: {
82+
token: JWT
83+
user?: User | undefined
84+
account?: Account | undefined
85+
profile?: Profile | undefined
86+
isNewUser?: boolean | undefined
87+
}): Awaitable<JWT> {
5888
throw new Error("Function not implemented.")
59-
}
89+
},
6090
}
6191

6292
cookies = {
@@ -65,7 +95,7 @@ beforeEach(() => {
6595
csrfToken: { name: "", options: undefined },
6696
pkceCodeVerifier: { name: "", options: {} },
6797
state: { name: "", options: undefined },
68-
nonce: { name: "", options: undefined }
98+
nonce: { name: "", options: undefined },
6999
}
70100

71101
options = {
@@ -81,9 +111,9 @@ beforeEach(() => {
81111
events: {},
82112
callbacks,
83113
cookies,
84-
callbackUrl: '',
114+
callbackUrl: "",
85115
providers: [],
86-
theme: {}
116+
theme: {},
87117
}
88118
})
89119

@@ -109,7 +139,7 @@ describe("createPKCE", () => {
109139
const expires = new Date()
110140
expires.setTime(expires.getTime() + defaultMaxAge * 1000)
111141

112-
validateCookieExpiration({pkce, expires})
142+
validateCookieExpiration({ pkce, expires })
113143
expect(pkce?.cookie.options.maxAge).toBeUndefined()
114144
})
115145

@@ -122,18 +152,18 @@ describe("createPKCE", () => {
122152
const expires = new Date()
123153
expires.setTime(expires.getTime() + maxAge * 1000)
124154

125-
validateCookieExpiration({pkce, expires})
155+
validateCookieExpiration({ pkce, expires })
126156
expect(pkce?.cookie.options.maxAge).toEqual(maxAge)
127157
})
128158
})
129159

130160
// comparing the parts instead of getTime() because the milliseconds
131161
// will not match since the two Date objects are created milliseconds apart
132-
const validateCookieExpiration = ({pkce, expires}) => {
162+
const validateCookieExpiration = ({ pkce, expires }) => {
133163
const cookieExpires = pkce?.cookie.options.expires
134164
expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear())
135165
expect(cookieExpires.getMonth()).toEqual(expires.getMonth())
136166
expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear())
137167
expect(cookieExpires.getHours()).toEqual(expires.getHours())
138168
expect(cookieExpires.getMinutes()).toEqual(expires.getMinutes())
139-
}
169+
}

‎packages/next-auth/tests/state-handler.test.ts

+47-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1-
import { mockLogger } from "./lib"
2-
import type { InternalOptions, LoggerInstance, InternalProvider, CallbacksOptions, Account, Awaitable, Profile, Session, User, CookiesOptions } from "../src"
1+
import { mockLogger } from "./utils"
2+
import type {
3+
InternalOptions,
4+
LoggerInstance,
5+
InternalProvider,
6+
CallbacksOptions,
7+
Account,
8+
Awaitable,
9+
Profile,
10+
Session,
11+
User,
12+
CookiesOptions,
13+
} from "../src"
314
import { createState } from "../src/core/lib/oauth/state-handler"
415
import { InternalUrl } from "../src/utils/parse-url"
516
import { JWT, JWTOptions, encode, decode } from "../src/jwt"
@@ -21,7 +32,7 @@ beforeEach(() => {
2132
host: "localhost:3000",
2233
path: "/api/auth",
2334
base: "http://localhost:3000/api/auth",
24-
toString: () => "http://localhost:3000/api/auth"
35+
toString: () => "http://localhost:3000/api/auth",
2536
}
2637

2738
provider = {
@@ -30,29 +41,48 @@ beforeEach(() => {
3041
name: "testName",
3142
signinUrl: "/",
3243
callbackUrl: "/",
33-
checks: ["pkce", "state"]
44+
checks: ["pkce", "state"],
3445
}
3546

3647
jwt = {
3748
secret: "secret",
3849
maxAge: 0,
3950
encode,
40-
decode
51+
decode,
4152
}
4253

4354
callbacks = {
44-
signIn: function (params: { user: User; account: Account; profile: Profile & Record<string, unknown>; email: { verificationRequest?: boolean | undefined }; credentials?: Record<string, CredentialInput> | undefined }): Awaitable<string | boolean> {
55+
signIn: function (params: {
56+
user: User
57+
account: Account
58+
profile: Profile & Record<string, unknown>
59+
email: { verificationRequest?: boolean | undefined }
60+
credentials?: Record<string, CredentialInput> | undefined
61+
}): Awaitable<string | boolean> {
4562
throw new Error("Function not implemented.")
4663
},
47-
redirect: function (params: { url: string; baseUrl: string }): Awaitable<string> {
64+
redirect: function (params: {
65+
url: string
66+
baseUrl: string
67+
}): Awaitable<string> {
4868
throw new Error("Function not implemented.")
4969
},
50-
session: function (params: { session: Session; user: User; token: JWT }): Awaitable<Session> {
70+
session: function (params: {
71+
session: Session
72+
user: User
73+
token: JWT
74+
}): Awaitable<Session> {
5175
throw new Error("Function not implemented.")
5276
},
53-
jwt: function (params: { token: JWT; user?: User | undefined; account?: Account | undefined; profile?: Profile | undefined; isNewUser?: boolean | undefined }): Awaitable<JWT> {
77+
jwt: function (params: {
78+
token: JWT
79+
user?: User | undefined
80+
account?: Account | undefined
81+
profile?: Profile | undefined
82+
isNewUser?: boolean | undefined
83+
}): Awaitable<JWT> {
5484
throw new Error("Function not implemented.")
55-
}
85+
},
5686
}
5787

5888
cookies = {
@@ -61,7 +91,7 @@ beforeEach(() => {
6191
csrfToken: { name: "", options: undefined },
6292
pkceCodeVerifier: { name: "", options: undefined },
6393
state: { name: "", options: {} },
64-
nonce: { name: "", options: undefined }
94+
nonce: { name: "", options: undefined },
6595
}
6696

6797
options = {
@@ -77,9 +107,9 @@ beforeEach(() => {
77107
events: {},
78108
callbacks,
79109
cookies,
80-
callbackUrl: '',
110+
callbackUrl: "",
81111
providers: [],
82-
theme: {}
112+
theme: {},
83113
}
84114
})
85115

@@ -104,7 +134,7 @@ describe("createState", () => {
104134
const expires = new Date()
105135
expires.setTime(expires.getTime() + defaultMaxAge * 1000)
106136

107-
validateCookieExpiration({state, expires})
137+
validateCookieExpiration({ state, expires })
108138
expect(state?.cookie.options.maxAge).toBeUndefined()
109139
})
110140

@@ -117,18 +147,18 @@ describe("createState", () => {
117147
const expires = new Date()
118148
expires.setTime(expires.getTime() + maxAge * 1000)
119149

120-
validateCookieExpiration({state, expires})
150+
validateCookieExpiration({ state, expires })
121151
expect(state?.cookie.options.maxAge).toEqual(maxAge)
122152
})
123153
})
124154

125155
// comparing the parts instead of getTime() because the milliseconds
126156
// will not match since the two Date objects are created milliseconds apart
127-
const validateCookieExpiration = ({state, expires}) => {
157+
const validateCookieExpiration = ({ state, expires }) => {
128158
const cookieExpires = state?.cookie.options.expires
129159
expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear())
130160
expect(cookieExpires.getMonth()).toEqual(expires.getMonth())
131161
expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear())
132162
expect(cookieExpires.getHours()).toEqual(expires.getHours())
133163
expect(cookieExpires.getMinutes()).toEqual(expires.getMinutes())
134-
}
164+
}

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

+116
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import { createHash } from "crypto"
2+
import { AuthHandler } from "../src/core"
3+
import type { LoggerInstance, NextAuthOptions } from "../src"
4+
import type { Adapter } from "../src/adapters"
5+
6+
import NextAuth from "../src/next"
7+
8+
import type { NextApiRequest, NextApiResponse } from "next"
9+
10+
export function mockLogger(): Record<keyof LoggerInstance, jest.Mock> {
11+
return {
12+
error: jest.fn(() => {}),
13+
warn: jest.fn(() => {}),
14+
debug: jest.fn(() => {}),
15+
}
16+
}
17+
18+
interface HandlerOptions {
19+
prod?: boolean
20+
path?: string
21+
params?: URLSearchParams | Record<string, string>
22+
requestInit?: RequestInit
23+
}
24+
25+
export async function handler(
26+
options: NextAuthOptions,
27+
{ prod, path, params, requestInit }: HandlerOptions
28+
) {
29+
// @ts-expect-error
30+
if (prod) process.env.NODE_ENV = "production"
31+
32+
const url = new URL(
33+
`http://localhost:3000/api/auth/${path ?? "signin"}?${new URLSearchParams(
34+
params ?? {}
35+
)}`
36+
)
37+
const req = new Request(url, { headers: { host: "" }, ...requestInit })
38+
const logger = mockLogger()
39+
const response = await AuthHandler(req, {
40+
secret: "secret",
41+
...options,
42+
logger,
43+
})
44+
// @ts-expect-error
45+
if (prod) process.env.NODE_ENV = "test"
46+
47+
return {
48+
res: {
49+
status: response.status,
50+
headers: response.headers,
51+
body: response.body,
52+
redirect: response.headers.get("location"),
53+
html:
54+
response.headers?.get("content-type") === "text/html"
55+
? await response.clone().text()
56+
: undefined,
57+
},
58+
log: logger,
59+
}
60+
}
61+
62+
export function createCSRF() {
63+
const secret = "secret"
64+
const value = "csrf"
65+
const token = createHash("sha256").update(`${value}${secret}`).digest("hex")
66+
67+
return {
68+
secret,
69+
csrf: { value, token, cookie: `next-auth.csrf-token=${value}|${token}` },
70+
}
71+
}
72+
73+
export function mockAdapter(): Adapter {
74+
const adapter: Adapter = {
75+
createVerificationToken: jest.fn(() => {}),
76+
useVerificationToken: jest.fn(() => {}),
77+
getUserByEmail: jest.fn(() => {}),
78+
} as unknown as Adapter
79+
return adapter
80+
}
81+
82+
export async function nodeHandler(
83+
params: {
84+
req?: Partial<NextApiRequest>
85+
res?: Partial<NextApiResponse>
86+
options?: Partial<NextAuthOptions>
87+
} = {}
88+
) {
89+
const req = {
90+
body: {},
91+
cookies: {},
92+
headers: {},
93+
method: "GET",
94+
...params.req,
95+
}
96+
97+
const res = {
98+
...params.res,
99+
end: jest.fn(),
100+
json: jest.fn(),
101+
status: jest.fn().mockReturnValue({ end: jest.fn() }),
102+
setHeader: jest.fn(),
103+
removeHeader: jest.fn(),
104+
send: jest.fn(),
105+
}
106+
107+
const logger = mockLogger()
108+
109+
await NextAuth(req as any, res as any, {
110+
providers: [],
111+
secret: "secret",
112+
logger,
113+
...params.options,
114+
})
115+
return { req, res, logger }
116+
}

0 commit comments

Comments
 (0)
Please sign in to comment.