Compare commits

..

4 Commits

Author SHA1 Message Date
Balázs Orbán
2913fbac3b chore(release): bump package version(s) [skip ci] 2022-12-12 14:53:01 +01:00
Balázs Orbán
2875b49f11 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
2022-12-12 13:47:34 +00:00
Balázs Orbán
5259d247a2 fix(next): correctly bundle next-auth/middleware
fixes #6025
2022-12-12 11:59:37 +01:00
Balázs Orbán
d1d93fd75e chore(release): bump package version(s) [skip ci] 2022-12-11 15:52:57 +01:00
6 changed files with 218 additions and 76 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "next-auth",
"version": "4.18.5",
"version": "4.18.6",
"description": "Authentication for Next.js",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git",

View File

@@ -6,7 +6,17 @@ import { NextResponse, NextRequest } from "next/server"
import { getToken } from "../jwt"
import parseUrl from "../utils/parse-url"
import { detectHost } from "../utils/web"
// // TODO: Remove
/** Extract the host from the environment */
export function detectHost(
trusted: boolean,
forwardedValue: string | null,
defaultValue: string | false
): string | undefined {
if (trusted && forwardedValue) return forwardedValue
return defaultValue || undefined
}
type AuthorizedCallback = (params: {
token: JWT | null

View File

@@ -142,8 +142,14 @@ function getSetCookies(cookiesString: string) {
export function setHeaders(headers: Headers, res: ServerResponse) {
for (const [key, val] of headers.entries()) {
let value: string | string[] = val
// See: https://github.com/whatwg/fetch/issues/973
const value = key === "set-cookie" ? getSetCookies(val) : val
if (key === "set-cookie") {
const cookies = getSetCookies(value)
let original = res.getHeader("set-cookie") as string[] | string
original = Array.isArray(original) ? original : [original]
value = original.concat(cookies).filter(Boolean)
}
res.setHeader(key, value)
}
}

View File

@@ -49,7 +49,7 @@ export async function toInternalRequest(
req: Request
): Promise<RequestInternal | Error> {
try {
// TODO: .toString() should not inclide action and providerId
// TODO: url.toString() should not include action and providerId
// see init.ts
const url = new URL(req.url.replace(/\/$/, ""))
const { pathname } = url
@@ -69,8 +69,6 @@ export async function toInternalRequest(
providerId = providerIdOrAction
}
const cookieHeader = req.headers.get("cookie") ?? ""
return {
url,
action,
@@ -78,10 +76,7 @@ export async function toInternalRequest(
method: req.method ?? "GET",
headers: Object.fromEntries(req.headers),
body: req.body ? await readJSONBody(req.body) : undefined,
cookies:
parseCookie(
Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader
) ?? {},
cookies: parseCookie(req.headers.get("cookie") ?? "") ?? {},
error: url.searchParams.get("error") ?? undefined,
query: Object.fromEntries(url.searchParams),
}
@@ -119,17 +114,3 @@ export function toResponse(res: ResponseInternal): Response {
return response
}
// TODO: Remove
/** Extract the host from the environment */
export function detectHost(
trusted: boolean,
forwardedValue: string | string[] | undefined | null,
defaultValue: string | false
): string | undefined {
if (trusted && forwardedValue) {
return Array.isArray(forwardedValue) ? forwardedValue[0] : forwardedValue
}
return defaultValue || undefined
}

View File

@@ -1,7 +1,7 @@
import { nodeHandler } from "./utils"
import { mockReqRes, nextHandler } from "./utils"
it("Missing req.url throws in dev", async () => {
await expect(nodeHandler).rejects.toThrow(new Error("Missing url"))
await expect(nextHandler).rejects.toThrow(new Error("Missing url"))
})
const configErrorMessage =
@@ -10,7 +10,7 @@ const configErrorMessage =
it("Missing req.url returns config error in prod", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
const { res, logger } = await nodeHandler()
const { res, logger } = await nextHandler()
expect(logger.error).toBeCalledTimes(1)
const error = new Error("Missing url")
@@ -26,7 +26,7 @@ it("Missing req.url returns config error in prod", async () => {
it("Missing host throws in dev", async () => {
await expect(
async () =>
await nodeHandler({
await nextHandler({
req: { query: { nextauth: ["session"] } },
})
).rejects.toThrow(Error)
@@ -35,7 +35,7 @@ it("Missing host throws in dev", async () => {
it("Missing host config error in prod", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
const { res, logger } = await nodeHandler({
const { res, logger } = await nextHandler({
req: { query: { nextauth: ["session"] } },
})
expect(res.status).toBeCalledWith(400)
@@ -49,7 +49,7 @@ it("Missing host config error in prod", async () => {
it("Defined host throws 400 in production if not trusted", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
const { res } = await nodeHandler({
const { res } = await nextHandler({
req: { headers: { host: "http://localhost" } },
})
expect(res.status).toBeCalledWith(400)
@@ -60,7 +60,7 @@ it("Defined host throws 400 in production if not trusted", async () => {
it("Defined host throws 400 in production if trusted but invalid URL", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
const { res } = await nodeHandler({
const { res } = await nextHandler({
req: { headers: { host: "localhost" } },
options: { trustHost: true },
})
@@ -72,7 +72,7 @@ it("Defined host throws 400 in production if trusted but invalid URL", async ()
it("Defined host does not throw in production if trusted and valid URL", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
const { res } = await nodeHandler({
const { res } = await nextHandler({
req: {
url: "/api/auth/session",
headers: { host: "http://localhost" },
@@ -80,6 +80,7 @@ it("Defined host does not throw in production if trusted and valid URL", async (
options: { trustHost: true },
})
expect(res.status).toBeCalledWith(200)
// @ts-expect-error
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
// @ts-expect-error
process.env.NODE_ENV = "test"
@@ -87,37 +88,41 @@ it("Defined host does not throw in production if trusted and valid URL", async (
it("Use process.env.NEXTAUTH_URL for host if present", async () => {
process.env.NEXTAUTH_URL = "http://localhost"
const { res } = await nodeHandler({
const { res } = await nextHandler({
req: { url: "/api/auth/session" },
})
expect(res.status).toBeCalledWith(200)
// @ts-expect-error
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
})
it("Redirects if necessary", async () => {
process.env.NEXTAUTH_URL = "http://localhost"
const { res } = await nodeHandler({
const { res } = await nextHandler({
req: {
method: "post",
url: "/api/auth/signin/github",
},
})
expect(res.status).toBeCalledWith(302)
expect(res.setHeader).toBeCalledWith("set-cookie", [
expect.stringMatching(
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
),
`next-auth.callback-url=${encodeURIComponent(
process.env.NEXTAUTH_URL
)}; Path=/; HttpOnly; SameSite=Lax`,
])
expect(res.setHeader).toBeCalledTimes(2)
expect(res.getHeaders()).toEqual({
location: "http://localhost/api/auth/signin?csrf=true",
"set-cookie": [
expect.stringMatching(
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
),
`next-auth.callback-url=${encodeURIComponent(
process.env.NEXTAUTH_URL
)}; Path=/; HttpOnly; SameSite=Lax`,
],
})
expect(res.send).toBeCalledWith("")
})
it("Returns redirect if `X-Auth-Return-Redirect` header is present", async () => {
process.env.NEXTAUTH_URL = "http://localhost"
const { res } = await nodeHandler({
const { res } = await nextHandler({
req: {
method: "post",
url: "/api/auth/signin/github",
@@ -126,16 +131,48 @@ it("Returns redirect if `X-Auth-Return-Redirect` header is present", async () =>
})
expect(res.status).toBeCalledWith(200)
expect(res.setHeader).toBeCalledWith("content-type", "application/json")
expect(res.setHeader).toBeCalledWith("set-cookie", [
expect.stringMatching(
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
),
`next-auth.callback-url=${encodeURIComponent(
process.env.NEXTAUTH_URL
)}; Path=/; HttpOnly; SameSite=Lax`,
])
expect(res.setHeader).toBeCalledTimes(2)
expect(res.getHeaders()).toEqual({
"content-type": "application/json",
"set-cookie": [
expect.stringMatching(
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
),
`next-auth.callback-url=${encodeURIComponent(
process.env.NEXTAUTH_URL
)}; Path=/; HttpOnly; SameSite=Lax`,
],
})
expect(res.send).toBeCalledWith(
JSON.stringify({ url: "http://localhost/api/auth/signin?csrf=true" })
)
})
it("Should preserve user's `set-cookie` headers", async () => {
const { req, res } = mockReqRes({
method: "post",
url: "/api/auth/signin/credentials",
headers: { host: "localhost", "X-Auth-Return-Redirect": "1" },
})
res.setHeader("set-cookie", ["foo=bar", "bar=baz"])
await nextHandler({ req, res })
expect(res.getHeaders()).toEqual({
"content-type": "application/json",
"set-cookie": [
"foo=bar",
"bar=baz",
expect.stringMatching(
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
),
`next-auth.callback-url=${encodeURIComponent(
"http://localhost"
)}; Path=/; HttpOnly; SameSite=Lax`,
],
})
expect(res.send).toBeCalledWith(
JSON.stringify({ url: "http://localhost/api/auth/signin?csrf=true" })
)

View File

@@ -1,11 +1,14 @@
import { createHash } from "crypto"
import { AuthHandler } from "../src/core"
import type { LoggerInstance, AuthOptions } from "../src"
import { createHash } from "node:crypto"
import { IncomingMessage, ServerResponse } from "node:http"
import { Socket } from "node:net"
import type { AuthOptions, LoggerInstance } from "../src"
import type { Adapter } from "../src/adapters"
import { AuthHandler } from "../src/core"
import NextAuth from "../src/next"
import type { NextApiRequest, NextApiResponse } from "next"
import { Stream } from "node:stream"
export function mockLogger(): Record<keyof LoggerInstance, jest.Mock> {
return {
@@ -79,38 +82,143 @@ export function mockAdapter(): Adapter {
return adapter
}
export async function nodeHandler(
export async function nextHandler(
params: {
req?: Partial<NextApiRequest>
res?: Partial<NextApiResponse>
options?: Partial<AuthOptions>
} = {}
) {
const req = {
body: {},
cookies: {},
headers: {},
method: "GET",
...params.req,
}
const res = {
...params.res,
end: jest.fn(),
json: jest.fn(),
status: jest.fn().mockReturnValue({ end: jest.fn() }),
setHeader: jest.fn(),
removeHeader: jest.fn(),
send: jest.fn(),
let req = params.req
// @ts-expect-error
let res: NextApiResponse = params.res
if (!params.res) {
;({ req, res } = mockReqRes(params.req))
}
const logger = mockLogger()
await NextAuth(req as any, res as any, {
// @ts-expect-error
await NextAuth(req, res, {
providers: [],
secret: "secret",
logger,
...params.options,
})
return { req, res, logger }
}
export function mockReqRes(req?: Partial<NextApiRequest>): {
req: NextApiRequest
res: NextApiResponse
} {
const request = new IncomingMessage(new Socket())
request.headers = req?.headers ?? {}
request.method = req?.method
request.url = req?.url
const response = new ServerResponse(request)
// @ts-expect-error
response.status = (code) => (response.statusCode = code)
// @ts-expect-error
response.send = (data) => sendData(request, response, data)
// @ts-expect-error
response.json = (data) => sendJson(response, data)
const res: NextApiResponse = {
...response,
// @ts-expect-error
setHeader: jest.spyOn(response, "setHeader"),
// @ts-expect-error
getHeader: jest.spyOn(response, "getHeader"),
// @ts-expect-error
removeHeader: jest.spyOn(response, "removeHeader"),
// @ts-expect-error
status: jest.spyOn(response, "status"),
// @ts-expect-error
send: jest.spyOn(response, "send"),
// @ts-expect-error
json: jest.spyOn(response, "json"),
// @ts-expect-error
end: jest.spyOn(response, "end"),
// @ts-expect-error
getHeaders: jest.spyOn(response, "getHeaders"),
}
return { req: request as any, res }
}
// Code below is copied from Next.js
// https://github.com/vercel/next.js/tree/canary/packages/next/server/api-utils
// TODO: Remove
/**
* Send `any` body to response
* @param req request object
* @param res response object
* @param body of response
*/
function sendData(req: NextApiRequest, res: NextApiResponse, body: any): void {
if (body === null || body === undefined) {
res.end()
return
}
// strip irrelevant headers/body
if (res.statusCode === 204 || res.statusCode === 304) {
res.removeHeader("Content-Type")
res.removeHeader("Content-Length")
res.removeHeader("Transfer-Encoding")
if (process.env.NODE_ENV === "development" && body) {
console.warn(
`A body was attempted to be set with a 204 statusCode for ${req.url}, this is invalid and the body was ignored.\n` +
`See more info here https://nextjs.org/docs/messages/invalid-api-status-body`
)
}
res.end()
return
}
const contentType = res.getHeader("Content-Type")
if (body instanceof Stream) {
if (!contentType) {
res.setHeader("Content-Type", "application/octet-stream")
}
body.pipe(res)
return
}
const isJSONLike = ["object", "number", "boolean"].includes(typeof body)
const stringifiedBody = isJSONLike ? JSON.stringify(body) : body
if (Buffer.isBuffer(body)) {
if (!contentType) {
res.setHeader("Content-Type", "application/octet-stream")
}
res.setHeader("Content-Length", body.length)
res.end(body)
return
}
if (isJSONLike) {
res.setHeader("Content-Type", "application/json; charset=utf-8")
}
res.setHeader("Content-Length", Buffer.byteLength(stringifiedBody))
res.end(stringifiedBody)
}
/**
* Send `JSON` object
* @param res response object
* @param jsonBody of data
*/
function sendJson(res: NextApiResponse, jsonBody: any): void {
// Set header to application/json
res.setHeader("Content-Type", "application/json; charset=utf-8")
// Use send to handle request
res.send(JSON.stringify(jsonBody))
}