Skip to content

Commit 2875b49

Browse files
authoredDec 12, 2022
fix(core): preserve incoming set cookies (#6029)
* fix(core): preserve `set-cookie` by the user * add test * improve req/res mocking * refactor * fix comment typo
1 parent 5259d24 commit 2875b49

File tree

4 files changed

+206
-60
lines changed

4 files changed

+206
-60
lines changed
 

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

+7-1
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,14 @@ function getSetCookies(cookiesString: string) {
142142

143143
export function setHeaders(headers: Headers, res: ServerResponse) {
144144
for (const [key, val] of headers.entries()) {
145+
let value: string | string[] = val
145146
// See: https://github.com/whatwg/fetch/issues/973
146-
const value = key === "set-cookie" ? getSetCookies(val) : val
147+
if (key === "set-cookie") {
148+
const cookies = getSetCookies(value)
149+
let original = res.getHeader("set-cookie") as string[] | string
150+
original = Array.isArray(original) ? original : [original]
151+
value = original.concat(cookies).filter(Boolean)
152+
}
147153
res.setHeader(key, value)
148154
}
149155
}

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

+2-7
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export async function toInternalRequest(
4949
req: Request
5050
): Promise<RequestInternal | Error> {
5151
try {
52-
// TODO: .toString() should not inclide action and providerId
52+
// TODO: url.toString() should not include action and providerId
5353
// see init.ts
5454
const url = new URL(req.url.replace(/\/$/, ""))
5555
const { pathname } = url
@@ -69,19 +69,14 @@ export async function toInternalRequest(
6969
providerId = providerIdOrAction
7070
}
7171

72-
const cookieHeader = req.headers.get("cookie") ?? ""
73-
7472
return {
7573
url,
7674
action,
7775
providerId,
7876
method: req.method ?? "GET",
7977
headers: Object.fromEntries(req.headers),
8078
body: req.body ? await readJSONBody(req.body) : undefined,
81-
cookies:
82-
parseCookie(
83-
Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader
84-
) ?? {},
79+
cookies: parseCookie(req.headers.get("cookie") ?? "") ?? {},
8580
error: url.searchParams.get("error") ?? undefined,
8681
query: Object.fromEntries(url.searchParams),
8782
}

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

+67-30
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { nodeHandler } from "./utils"
1+
import { mockReqRes, nextHandler } from "./utils"
22

33
it("Missing req.url throws in dev", async () => {
4-
await expect(nodeHandler).rejects.toThrow(new Error("Missing url"))
4+
await expect(nextHandler).rejects.toThrow(new Error("Missing url"))
55
})
66

77
const configErrorMessage =
@@ -10,7 +10,7 @@ const configErrorMessage =
1010
it("Missing req.url returns config error in prod", async () => {
1111
// @ts-expect-error
1212
process.env.NODE_ENV = "production"
13-
const { res, logger } = await nodeHandler()
13+
const { res, logger } = await nextHandler()
1414

1515
expect(logger.error).toBeCalledTimes(1)
1616
const error = new Error("Missing url")
@@ -26,7 +26,7 @@ it("Missing req.url returns config error in prod", async () => {
2626
it("Missing host throws in dev", async () => {
2727
await expect(
2828
async () =>
29-
await nodeHandler({
29+
await nextHandler({
3030
req: { query: { nextauth: ["session"] } },
3131
})
3232
).rejects.toThrow(Error)
@@ -35,7 +35,7 @@ it("Missing host throws in dev", async () => {
3535
it("Missing host config error in prod", async () => {
3636
// @ts-expect-error
3737
process.env.NODE_ENV = "production"
38-
const { res, logger } = await nodeHandler({
38+
const { res, logger } = await nextHandler({
3939
req: { query: { nextauth: ["session"] } },
4040
})
4141
expect(res.status).toBeCalledWith(400)
@@ -49,7 +49,7 @@ it("Missing host config error in prod", async () => {
4949
it("Defined host throws 400 in production if not trusted", async () => {
5050
// @ts-expect-error
5151
process.env.NODE_ENV = "production"
52-
const { res } = await nodeHandler({
52+
const { res } = await nextHandler({
5353
req: { headers: { host: "http://localhost" } },
5454
})
5555
expect(res.status).toBeCalledWith(400)
@@ -60,7 +60,7 @@ it("Defined host throws 400 in production if not trusted", async () => {
6060
it("Defined host throws 400 in production if trusted but invalid URL", async () => {
6161
// @ts-expect-error
6262
process.env.NODE_ENV = "production"
63-
const { res } = await nodeHandler({
63+
const { res } = await nextHandler({
6464
req: { headers: { host: "localhost" } },
6565
options: { trustHost: true },
6666
})
@@ -72,52 +72,57 @@ it("Defined host throws 400 in production if trusted but invalid URL", async ()
7272
it("Defined host does not throw in production if trusted and valid URL", async () => {
7373
// @ts-expect-error
7474
process.env.NODE_ENV = "production"
75-
const { res } = await nodeHandler({
75+
const { res } = await nextHandler({
7676
req: {
7777
url: "/api/auth/session",
7878
headers: { host: "http://localhost" },
7979
},
8080
options: { trustHost: true },
8181
})
8282
expect(res.status).toBeCalledWith(200)
83+
// @ts-expect-error
8384
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
8485
// @ts-expect-error
8586
process.env.NODE_ENV = "test"
8687
})
8788

8889
it("Use process.env.NEXTAUTH_URL for host if present", async () => {
8990
process.env.NEXTAUTH_URL = "http://localhost"
90-
const { res } = await nodeHandler({
91+
const { res } = await nextHandler({
9192
req: { url: "/api/auth/session" },
9293
})
9394
expect(res.status).toBeCalledWith(200)
95+
// @ts-expect-error
9496
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
9597
})
9698

9799
it("Redirects if necessary", async () => {
98100
process.env.NEXTAUTH_URL = "http://localhost"
99-
const { res } = await nodeHandler({
101+
const { res } = await nextHandler({
100102
req: {
101103
method: "post",
102104
url: "/api/auth/signin/github",
103105
},
104106
})
105107
expect(res.status).toBeCalledWith(302)
106-
expect(res.setHeader).toBeCalledWith("set-cookie", [
107-
expect.stringMatching(
108-
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
109-
),
110-
`next-auth.callback-url=${encodeURIComponent(
111-
process.env.NEXTAUTH_URL
112-
)}; Path=/; HttpOnly; SameSite=Lax`,
113-
])
114-
expect(res.setHeader).toBeCalledTimes(2)
108+
expect(res.getHeaders()).toEqual({
109+
location: "http://localhost/api/auth/signin?csrf=true",
110+
"set-cookie": [
111+
expect.stringMatching(
112+
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
113+
),
114+
`next-auth.callback-url=${encodeURIComponent(
115+
process.env.NEXTAUTH_URL
116+
)}; Path=/; HttpOnly; SameSite=Lax`,
117+
],
118+
})
119+
115120
expect(res.send).toBeCalledWith("")
116121
})
117122

118123
it("Returns redirect if `X-Auth-Return-Redirect` header is present", async () => {
119124
process.env.NEXTAUTH_URL = "http://localhost"
120-
const { res } = await nodeHandler({
125+
const { res } = await nextHandler({
121126
req: {
122127
method: "post",
123128
url: "/api/auth/signin/github",
@@ -126,16 +131,48 @@ it("Returns redirect if `X-Auth-Return-Redirect` header is present", async () =>
126131
})
127132

128133
expect(res.status).toBeCalledWith(200)
129-
expect(res.setHeader).toBeCalledWith("content-type", "application/json")
130-
expect(res.setHeader).toBeCalledWith("set-cookie", [
131-
expect.stringMatching(
132-
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
133-
),
134-
`next-auth.callback-url=${encodeURIComponent(
135-
process.env.NEXTAUTH_URL
136-
)}; Path=/; HttpOnly; SameSite=Lax`,
137-
])
138-
expect(res.setHeader).toBeCalledTimes(2)
134+
135+
expect(res.getHeaders()).toEqual({
136+
"content-type": "application/json",
137+
"set-cookie": [
138+
expect.stringMatching(
139+
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
140+
),
141+
`next-auth.callback-url=${encodeURIComponent(
142+
process.env.NEXTAUTH_URL
143+
)}; Path=/; HttpOnly; SameSite=Lax`,
144+
],
145+
})
146+
147+
expect(res.send).toBeCalledWith(
148+
JSON.stringify({ url: "http://localhost/api/auth/signin?csrf=true" })
149+
)
150+
})
151+
152+
it("Should preserve user's `set-cookie` headers", async () => {
153+
const { req, res } = mockReqRes({
154+
method: "post",
155+
url: "/api/auth/signin/credentials",
156+
headers: { host: "localhost", "X-Auth-Return-Redirect": "1" },
157+
})
158+
res.setHeader("set-cookie", ["foo=bar", "bar=baz"])
159+
160+
await nextHandler({ req, res })
161+
162+
expect(res.getHeaders()).toEqual({
163+
"content-type": "application/json",
164+
"set-cookie": [
165+
"foo=bar",
166+
"bar=baz",
167+
expect.stringMatching(
168+
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
169+
),
170+
`next-auth.callback-url=${encodeURIComponent(
171+
"http://localhost"
172+
)}; Path=/; HttpOnly; SameSite=Lax`,
173+
],
174+
})
175+
139176
expect(res.send).toBeCalledWith(
140177
JSON.stringify({ url: "http://localhost/api/auth/signin?csrf=true" })
141178
)

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

+130-22
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1-
import { createHash } from "crypto"
2-
import { AuthHandler } from "../src/core"
3-
import type { LoggerInstance, AuthOptions } from "../src"
1+
import { createHash } from "node:crypto"
2+
import { IncomingMessage, ServerResponse } from "node:http"
3+
import { Socket } from "node:net"
4+
import type { AuthOptions, LoggerInstance } from "../src"
45
import type { Adapter } from "../src/adapters"
6+
import { AuthHandler } from "../src/core"
57

68
import NextAuth from "../src/next"
79

810
import type { NextApiRequest, NextApiResponse } from "next"
11+
import { Stream } from "node:stream"
912

1013
export function mockLogger(): Record<keyof LoggerInstance, jest.Mock> {
1114
return {
@@ -79,38 +82,143 @@ export function mockAdapter(): Adapter {
7982
return adapter
8083
}
8184

82-
export async function nodeHandler(
85+
export async function nextHandler(
8386
params: {
8487
req?: Partial<NextApiRequest>
8588
res?: Partial<NextApiResponse>
8689
options?: Partial<AuthOptions>
8790
} = {}
8891
) {
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(),
92+
let req = params.req
93+
// @ts-expect-error
94+
let res: NextApiResponse = params.res
95+
if (!params.res) {
96+
;({ req, res } = mockReqRes(params.req))
10597
}
10698

10799
const logger = mockLogger()
108-
109-
await NextAuth(req as any, res as any, {
100+
// @ts-expect-error
101+
await NextAuth(req, res, {
110102
providers: [],
111103
secret: "secret",
112104
logger,
113105
...params.options,
114106
})
107+
115108
return { req, res, logger }
116109
}
110+
111+
export function mockReqRes(req?: Partial<NextApiRequest>): {
112+
req: NextApiRequest
113+
res: NextApiResponse
114+
} {
115+
const request = new IncomingMessage(new Socket())
116+
request.headers = req?.headers ?? {}
117+
request.method = req?.method
118+
request.url = req?.url
119+
120+
const response = new ServerResponse(request)
121+
// @ts-expect-error
122+
response.status = (code) => (response.statusCode = code)
123+
// @ts-expect-error
124+
response.send = (data) => sendData(request, response, data)
125+
// @ts-expect-error
126+
response.json = (data) => sendJson(response, data)
127+
128+
const res: NextApiResponse = {
129+
...response,
130+
// @ts-expect-error
131+
setHeader: jest.spyOn(response, "setHeader"),
132+
// @ts-expect-error
133+
getHeader: jest.spyOn(response, "getHeader"),
134+
// @ts-expect-error
135+
removeHeader: jest.spyOn(response, "removeHeader"),
136+
// @ts-expect-error
137+
status: jest.spyOn(response, "status"),
138+
// @ts-expect-error
139+
send: jest.spyOn(response, "send"),
140+
// @ts-expect-error
141+
json: jest.spyOn(response, "json"),
142+
// @ts-expect-error
143+
end: jest.spyOn(response, "end"),
144+
// @ts-expect-error
145+
getHeaders: jest.spyOn(response, "getHeaders"),
146+
}
147+
148+
return { req: request as any, res }
149+
}
150+
151+
// Code below is copied from Next.js
152+
// https://github.com/vercel/next.js/tree/canary/packages/next/server/api-utils
153+
// TODO: Remove
154+
155+
/**
156+
* Send `any` body to response
157+
* @param req request object
158+
* @param res response object
159+
* @param body of response
160+
*/
161+
function sendData(req: NextApiRequest, res: NextApiResponse, body: any): void {
162+
if (body === null || body === undefined) {
163+
res.end()
164+
return
165+
}
166+
167+
// strip irrelevant headers/body
168+
if (res.statusCode === 204 || res.statusCode === 304) {
169+
res.removeHeader("Content-Type")
170+
res.removeHeader("Content-Length")
171+
res.removeHeader("Transfer-Encoding")
172+
173+
if (process.env.NODE_ENV === "development" && body) {
174+
console.warn(
175+
`A body was attempted to be set with a 204 statusCode for ${req.url}, this is invalid and the body was ignored.\n` +
176+
`See more info here https://nextjs.org/docs/messages/invalid-api-status-body`
177+
)
178+
}
179+
res.end()
180+
return
181+
}
182+
183+
const contentType = res.getHeader("Content-Type")
184+
185+
if (body instanceof Stream) {
186+
if (!contentType) {
187+
res.setHeader("Content-Type", "application/octet-stream")
188+
}
189+
body.pipe(res)
190+
return
191+
}
192+
193+
const isJSONLike = ["object", "number", "boolean"].includes(typeof body)
194+
const stringifiedBody = isJSONLike ? JSON.stringify(body) : body
195+
196+
if (Buffer.isBuffer(body)) {
197+
if (!contentType) {
198+
res.setHeader("Content-Type", "application/octet-stream")
199+
}
200+
res.setHeader("Content-Length", body.length)
201+
res.end(body)
202+
return
203+
}
204+
205+
if (isJSONLike) {
206+
res.setHeader("Content-Type", "application/json; charset=utf-8")
207+
}
208+
209+
res.setHeader("Content-Length", Buffer.byteLength(stringifiedBody))
210+
res.end(stringifiedBody)
211+
}
212+
213+
/**
214+
* Send `JSON` object
215+
* @param res response object
216+
* @param jsonBody of data
217+
*/
218+
function sendJson(res: NextApiResponse, jsonBody: any): void {
219+
// Set header to application/json
220+
res.setHeader("Content-Type", "application/json; charset=utf-8")
221+
222+
// Use send to handle request
223+
res.send(JSON.stringify(jsonBody))
224+
}

0 commit comments

Comments
 (0)
Please sign in to comment.