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
7 changed files with 220 additions and 78 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "@next-auth/sequelize-adapter", "name": "@next-auth/sequelize-adapter",
"version": "1.0.6", "version": "1.0.7",
"description": "Sequelize adapter for next-auth.", "description": "Sequelize adapter for next-auth.",
"homepage": "https://next-auth.js.org", "homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth", "repository": "https://github.com/nextauthjs/next-auth",
@@ -42,4 +42,4 @@
"jest": { "jest": {
"preset": "@next-auth/adapter-test/jest" "preset": "@next-auth/adapter-test/jest"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-auth", "name": "next-auth",
"version": "4.18.4", "version": "4.18.6",
"description": "Authentication for Next.js", "description": "Authentication for Next.js",
"homepage": "https://next-auth.js.org", "homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git", "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 { getToken } from "../jwt"
import parseUrl from "../utils/parse-url" 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: { type AuthorizedCallback = (params: {
token: JWT | null token: JWT | null

View File

@@ -142,8 +142,14 @@ function getSetCookies(cookiesString: string) {
export function setHeaders(headers: Headers, res: ServerResponse) { export function setHeaders(headers: Headers, res: ServerResponse) {
for (const [key, val] of headers.entries()) { for (const [key, val] of headers.entries()) {
let value: string | string[] = val
// See: https://github.com/whatwg/fetch/issues/973 // 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) res.setHeader(key, value)
} }
} }

View File

@@ -49,7 +49,7 @@ export async function toInternalRequest(
req: Request req: Request
): Promise<RequestInternal | Error> { ): Promise<RequestInternal | Error> {
try { try {
// TODO: .toString() should not inclide action and providerId // TODO: url.toString() should not include action and providerId
// see init.ts // see init.ts
const url = new URL(req.url.replace(/\/$/, "")) const url = new URL(req.url.replace(/\/$/, ""))
const { pathname } = url const { pathname } = url
@@ -69,8 +69,6 @@ export async function toInternalRequest(
providerId = providerIdOrAction providerId = providerIdOrAction
} }
const cookieHeader = req.headers.get("cookie") ?? ""
return { return {
url, url,
action, action,
@@ -78,10 +76,7 @@ export async function toInternalRequest(
method: req.method ?? "GET", method: req.method ?? "GET",
headers: Object.fromEntries(req.headers), headers: Object.fromEntries(req.headers),
body: req.body ? await readJSONBody(req.body) : undefined, body: req.body ? await readJSONBody(req.body) : undefined,
cookies: cookies: parseCookie(req.headers.get("cookie") ?? "") ?? {},
parseCookie(
Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader
) ?? {},
error: url.searchParams.get("error") ?? undefined, error: url.searchParams.get("error") ?? undefined,
query: Object.fromEntries(url.searchParams), query: Object.fromEntries(url.searchParams),
} }
@@ -119,17 +114,3 @@ export function toResponse(res: ResponseInternal): Response {
return 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 () => { 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 = const configErrorMessage =
@@ -10,7 +10,7 @@ const configErrorMessage =
it("Missing req.url returns config error in prod", async () => { it("Missing req.url returns config error in prod", async () => {
// @ts-expect-error // @ts-expect-error
process.env.NODE_ENV = "production" process.env.NODE_ENV = "production"
const { res, logger } = await nodeHandler() const { res, logger } = await nextHandler()
expect(logger.error).toBeCalledTimes(1) expect(logger.error).toBeCalledTimes(1)
const error = new Error("Missing url") 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 () => { it("Missing host throws in dev", async () => {
await expect( await expect(
async () => async () =>
await nodeHandler({ await nextHandler({
req: { query: { nextauth: ["session"] } }, req: { query: { nextauth: ["session"] } },
}) })
).rejects.toThrow(Error) ).rejects.toThrow(Error)
@@ -35,7 +35,7 @@ it("Missing host throws in dev", async () => {
it("Missing host config error in prod", async () => { it("Missing host config error in prod", async () => {
// @ts-expect-error // @ts-expect-error
process.env.NODE_ENV = "production" process.env.NODE_ENV = "production"
const { res, logger } = await nodeHandler({ const { res, logger } = await nextHandler({
req: { query: { nextauth: ["session"] } }, req: { query: { nextauth: ["session"] } },
}) })
expect(res.status).toBeCalledWith(400) 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 () => { it("Defined host throws 400 in production if not trusted", async () => {
// @ts-expect-error // @ts-expect-error
process.env.NODE_ENV = "production" process.env.NODE_ENV = "production"
const { res } = await nodeHandler({ const { res } = await nextHandler({
req: { headers: { host: "http://localhost" } }, req: { headers: { host: "http://localhost" } },
}) })
expect(res.status).toBeCalledWith(400) 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 () => { it("Defined host throws 400 in production if trusted but invalid URL", async () => {
// @ts-expect-error // @ts-expect-error
process.env.NODE_ENV = "production" process.env.NODE_ENV = "production"
const { res } = await nodeHandler({ const { res } = await nextHandler({
req: { headers: { host: "localhost" } }, req: { headers: { host: "localhost" } },
options: { trustHost: true }, 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 () => { it("Defined host does not throw in production if trusted and valid URL", async () => {
// @ts-expect-error // @ts-expect-error
process.env.NODE_ENV = "production" process.env.NODE_ENV = "production"
const { res } = await nodeHandler({ const { res } = await nextHandler({
req: { req: {
url: "/api/auth/session", url: "/api/auth/session",
headers: { host: "http://localhost" }, 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 }, options: { trustHost: true },
}) })
expect(res.status).toBeCalledWith(200) expect(res.status).toBeCalledWith(200)
// @ts-expect-error
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({}) expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
// @ts-expect-error // @ts-expect-error
process.env.NODE_ENV = "test" 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 () => { it("Use process.env.NEXTAUTH_URL for host if present", async () => {
process.env.NEXTAUTH_URL = "http://localhost" process.env.NEXTAUTH_URL = "http://localhost"
const { res } = await nodeHandler({ const { res } = await nextHandler({
req: { url: "/api/auth/session" }, req: { url: "/api/auth/session" },
}) })
expect(res.status).toBeCalledWith(200) expect(res.status).toBeCalledWith(200)
// @ts-expect-error
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({}) expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
}) })
it("Redirects if necessary", async () => { it("Redirects if necessary", async () => {
process.env.NEXTAUTH_URL = "http://localhost" process.env.NEXTAUTH_URL = "http://localhost"
const { res } = await nodeHandler({ const { res } = await nextHandler({
req: { req: {
method: "post", method: "post",
url: "/api/auth/signin/github", url: "/api/auth/signin/github",
}, },
}) })
expect(res.status).toBeCalledWith(302) expect(res.status).toBeCalledWith(302)
expect(res.setHeader).toBeCalledWith("set-cookie", [ expect(res.getHeaders()).toEqual({
expect.stringMatching( location: "http://localhost/api/auth/signin?csrf=true",
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/ "set-cookie": [
), expect.stringMatching(
`next-auth.callback-url=${encodeURIComponent( /next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
process.env.NEXTAUTH_URL ),
)}; Path=/; HttpOnly; SameSite=Lax`, `next-auth.callback-url=${encodeURIComponent(
]) process.env.NEXTAUTH_URL
expect(res.setHeader).toBeCalledTimes(2) )}; Path=/; HttpOnly; SameSite=Lax`,
],
})
expect(res.send).toBeCalledWith("") expect(res.send).toBeCalledWith("")
}) })
it("Returns redirect if `X-Auth-Return-Redirect` header is present", async () => { it("Returns redirect if `X-Auth-Return-Redirect` header is present", async () => {
process.env.NEXTAUTH_URL = "http://localhost" process.env.NEXTAUTH_URL = "http://localhost"
const { res } = await nodeHandler({ const { res } = await nextHandler({
req: { req: {
method: "post", method: "post",
url: "/api/auth/signin/github", 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.status).toBeCalledWith(200)
expect(res.setHeader).toBeCalledWith("content-type", "application/json")
expect(res.setHeader).toBeCalledWith("set-cookie", [ expect(res.getHeaders()).toEqual({
expect.stringMatching( "content-type": "application/json",
/next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/ "set-cookie": [
), expect.stringMatching(
`next-auth.callback-url=${encodeURIComponent( /next-auth.csrf-token=.*; Path=\/; HttpOnly; SameSite=Lax/
process.env.NEXTAUTH_URL ),
)}; Path=/; HttpOnly; SameSite=Lax`, `next-auth.callback-url=${encodeURIComponent(
]) process.env.NEXTAUTH_URL
expect(res.setHeader).toBeCalledTimes(2) )}; 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( expect(res.send).toBeCalledWith(
JSON.stringify({ url: "http://localhost/api/auth/signin?csrf=true" }) JSON.stringify({ url: "http://localhost/api/auth/signin?csrf=true" })
) )

View File

@@ -1,11 +1,14 @@
import { createHash } from "crypto" import { createHash } from "node:crypto"
import { AuthHandler } from "../src/core" import { IncomingMessage, ServerResponse } from "node:http"
import type { LoggerInstance, AuthOptions } from "../src" import { Socket } from "node:net"
import type { AuthOptions, LoggerInstance } from "../src"
import type { Adapter } from "../src/adapters" import type { Adapter } from "../src/adapters"
import { AuthHandler } from "../src/core"
import NextAuth from "../src/next" import NextAuth from "../src/next"
import type { NextApiRequest, NextApiResponse } from "next" import type { NextApiRequest, NextApiResponse } from "next"
import { Stream } from "node:stream"
export function mockLogger(): Record<keyof LoggerInstance, jest.Mock> { export function mockLogger(): Record<keyof LoggerInstance, jest.Mock> {
return { return {
@@ -79,38 +82,143 @@ export function mockAdapter(): Adapter {
return adapter return adapter
} }
export async function nodeHandler( export async function nextHandler(
params: { params: {
req?: Partial<NextApiRequest> req?: Partial<NextApiRequest>
res?: Partial<NextApiResponse> res?: Partial<NextApiResponse>
options?: Partial<AuthOptions> options?: Partial<AuthOptions>
} = {} } = {}
) { ) {
const req = { let req = params.req
body: {}, // @ts-expect-error
cookies: {}, let res: NextApiResponse = params.res
headers: {}, if (!params.res) {
method: "GET", ;({ req, res } = mockReqRes(params.req))
...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(),
} }
const logger = mockLogger() const logger = mockLogger()
// @ts-expect-error
await NextAuth(req as any, res as any, { await NextAuth(req, res, {
providers: [], providers: [],
secret: "secret", secret: "secret",
logger, logger,
...params.options, ...params.options,
}) })
return { req, res, logger } 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))
}