From 7e91d7df54f9d8ca782c93a63f583ce89c2d178d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sat, 3 Dec 2022 14:39:08 +0100 Subject: [PATCH] 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 --- packages/next-auth/src/core/index.ts | 76 ++++------ packages/next-auth/src/index.ts | 2 - packages/next-auth/src/next/index.ts | 137 +++++++----------- packages/next-auth/src/next/middleware.ts | 30 ++-- packages/next-auth/src/next/utils.ts | 15 -- packages/next-auth/src/utils/detect-host.ts | 12 -- packages/next-auth/src/utils/node.ts | 63 ++++++++ packages/next-auth/src/utils/parse-url.ts | 4 +- packages/next-auth/src/utils/web.ts | 104 +++++++++++++ packages/next-auth/tests/assert.test.ts | 2 +- packages/next-auth/tests/email.test.ts | 12 +- .../next-auth/tests/getServerSession.test.ts | 32 ++-- packages/next-auth/tests/lib.ts | 68 --------- packages/next-auth/tests/next.test.ts | 92 ++++++++++++ packages/next-auth/tests/pkce-handler.test.ts | 64 +++++--- .../next-auth/tests/state-handler.test.ts | 64 +++++--- packages/next-auth/tests/utils.ts | 116 +++++++++++++++ 17 files changed, 588 insertions(+), 305 deletions(-) delete mode 100644 packages/next-auth/src/next/utils.ts delete mode 100644 packages/next-auth/src/utils/detect-host.ts create mode 100644 packages/next-auth/src/utils/node.ts create mode 100644 packages/next-auth/src/utils/web.ts delete mode 100644 packages/next-auth/tests/lib.ts create mode 100644 packages/next-auth/tests/next.test.ts create mode 100644 packages/next-auth/tests/utils.ts diff --git a/packages/next-auth/src/core/index.ts b/packages/next-auth/src/core/index.ts index 5acba48e66..4153f88c01 100644 --- a/packages/next-auth/src/core/index.ts +++ b/packages/next-auth/src/core/index.ts @@ -1,5 +1,5 @@ import logger, { setLogger } from "../utils/logger" -import { detectHost } from "../utils/detect-host" +import { toInternalRequest, toResponse } from "../utils/web" import * as routes from "./routes" import renderPage from "./pages" import { init } from "./init" @@ -9,7 +9,6 @@ import { SessionStore } from "./lib/cookie" import type { NextAuthAction, NextAuthOptions } from "./types" import type { Cookie } from "./lib/cookie" import type { ErrorType } from "./pages/error" -import { parse as parseCookie } from "cookie" export interface RequestInternal { /** @default "http://localhost:3000" */ @@ -29,6 +28,7 @@ export interface NextAuthHeader { value: string } +// TODO: Rename to `ResponseInternal` export interface OutgoingResponse< Body extends string | Record | any[] = any > { @@ -39,56 +39,15 @@ export interface OutgoingResponse< cookies?: Cookie[] } -export interface NextAuthHandlerParams { - req: Request | RequestInternal - options: NextAuthOptions -} - -async function getBody(req: Request): Promise | undefined> { - try { - return await req.json() - } catch {} -} - -// TODO: -async function toInternalRequest( - req: RequestInternal | Request, - trustHost: boolean = false -): Promise { - if (req instanceof Request) { - const url = new URL(req.url) - // TODO: handle custom paths? - const nextauth = url.pathname.split("/").slice(3) - const headers = Object.fromEntries(req.headers) - const query: Record = Object.fromEntries(url.searchParams) - query.nextauth = nextauth - - return { - action: nextauth[0] as NextAuthAction, - method: req.method, - headers, - body: await getBody(req), - cookies: parseCookie(req.headers.get("cookie") ?? ""), - providerId: nextauth[1], - error: url.searchParams.get("error") ?? nextauth[1], - host: detectHost( - trustHost, - headers["x-forwarded-host"] ?? headers.host, - "http://localhost:3000" - ), - query, - } - } - return req -} - -export async function NextAuthHandler< +async function AuthHandlerInternal< Body extends string | Record | any[] ->(params: NextAuthHandlerParams): Promise> { - const { options: userOptions, req: incomingRequest } = params - - const req = await toInternalRequest(incomingRequest, userOptions.trustHost) - +>(params: { + req: RequestInternal + options: NextAuthOptions + /** REVIEW: Is this the best way to skip parsing the body in Node.js? */ + parsedBody?: any +}): Promise> { + const { options: userOptions, req } = params setLogger(userOptions.logger, userOptions.debug) const assertionResult = assertConfig({ options: userOptions, req }) @@ -159,6 +118,7 @@ export async function NextAuthHandler< case "session": { const session = await routes.session({ options, sessionStore }) if (session.cookies) cookies.push(...session.cookies) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion return { ...session, cookies } as any } case "csrf": @@ -299,3 +259,17 @@ export async function NextAuthHandler< body: `Error: This action with HTTP ${method} is not supported by NextAuth.js` as any, } } + +/** + * The core functionality of `next-auth`. + * It receives a standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) + * and returns a standard [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + */ +export async function AuthHandler( + request: Request, + options: NextAuthOptions +): Promise { + const req = await toInternalRequest(request) + const internalResponse = await AuthHandlerInternal({ req, options }) + return toResponse(internalResponse) +} diff --git a/packages/next-auth/src/index.ts b/packages/next-auth/src/index.ts index 401838bba1..f27a296aaf 100644 --- a/packages/next-auth/src/index.ts +++ b/packages/next-auth/src/index.ts @@ -1,6 +1,4 @@ export * from "./core/types" -export type { RequestInternal, OutgoingResponse } from "./core" - export * from "./next" export { default } from "./next" diff --git a/packages/next-auth/src/next/index.ts b/packages/next-auth/src/next/index.ts index 8fd55e761c..216424c16d 100644 --- a/packages/next-auth/src/next/index.ts +++ b/packages/next-auth/src/next/index.ts @@ -1,6 +1,5 @@ -import { NextAuthHandler } from "../core" -import { detectHost } from "../utils/detect-host" -import { setCookie } from "./utils" +import { AuthHandler } from "../core" +import { getURL, getBody } from "../utils/node" import type { GetServerSidePropsContext, @@ -10,60 +9,49 @@ import type { import type { NextAuthOptions, Session } from ".." import type { CallbacksOptions, - NextAuthAction, NextAuthRequest, NextAuthResponse, } from "../core/types" -async function NextAuthNextHandler( +async function NextAuthHandler( req: NextApiRequest, res: NextApiResponse, options: NextAuthOptions ) { - const { nextauth, ...query } = req.query - - options.secret ??= options.jwt?.secret ?? process.env.NEXTAUTH_SECRET - options.trustHost ??= !!(process.env.AUTH_TRUST_HOST ?? process.env.VERCEL) - - const handler = await NextAuthHandler({ - req: { - host: detectHost( - options.trustHost, - req.headers["x-forwarded-host"], - process.env.NEXTAUTH_URL ?? - (process.env.NODE_ENV !== "production" && "http://localhost:3000") - ), - body: req.body, - query, - cookies: req.cookies, - headers: req.headers, - method: req.method, - action: nextauth?.[0] as NextAuthAction, - providerId: nextauth?.[1], - error: (req.query.error as string | undefined) ?? nextauth?.[1], - }, - options, + const url = getURL( + req.url, + options.trustHost, + req.headers["x-forwarded-host"] ?? req.headers.host + ) + + if (url instanceof Error) return res.status(400).end() + + const request = new Request(url, { + headers: new Headers(req.headers as any), + method: req.method, + ...getBody(req), }) - res.status(handler.status ?? 200) + options.secret ??= options.jwt?.secret ?? process.env.NEXTAUTH_SECRET + const response = await AuthHandler(request, options) + const { status, headers } = response + res.status(status) - handler.cookies?.forEach((cookie) => setCookie(res, cookie)) + for (const [key, val] of headers.entries()) { + const value = key === "set-cookie" ? val.split(",") : val + res.setHeader(key, value) + } - handler.headers?.forEach((h) => res.setHeader(h.key, h.value)) + // If the request expects a return URL, send it as JSON + // instead of doing an actual redirect. + const redirect = headers.get("Location") - if (handler.redirect) { - // If the request expects a return URL, send it as JSON - // instead of doing an actual redirect. - if (req.body?.json !== "true") { - // Could chain. .end() when lowest target is Node 14 - // https://github.com/nodejs/node/issues/33148 - res.status(302).setHeader("Location", handler.redirect) - return res.end() - } - return res.json({ url: handler.redirect }) + if (req.body?.json === "true" && redirect) { + res.removeHeader("Location") + return res.json({ url: redirect }) } - return res.send(handler.body) + return res.send(await response.text()) } function NextAuth(options: NextAuthOptions): any @@ -81,10 +69,10 @@ function NextAuth( ) { if (args.length === 1) { return async (req: NextAuthRequest, res: NextAuthResponse) => - await NextAuthNextHandler(req, res, args[0]) + await NextAuthHandler(req, res, args[0]) } - return NextAuthNextHandler(args[0], args[1], args[2]) + return NextAuthHandler(args[0], args[1], args[2]) } export default NextAuth @@ -93,7 +81,7 @@ let experimentalWarningShown = false let experimentalRSCWarningShown = false type GetServerSessionOptions = Partial> & { - callbacks?: Omit & { + callbacks?: Omit & { session?: (...args: Parameters) => any } } @@ -156,47 +144,34 @@ export async function unstable_getServerSession< options = Object.assign(args[2], { providers: [] }) } - options.secret ??= process.env.NEXTAUTH_SECRET - options.trustHost ??= !!(process.env.AUTH_TRUST_HOST ?? process.env.VERCEL) - - const session = await NextAuthHandler({ - options, - req: { - host: detectHost( - options.trustHost, - req.headers["x-forwarded-host"], - process.env.NEXTAUTH_URL ?? - (process.env.NODE_ENV !== "production" && "http://localhost:3000") - ), - action: "session", - method: "GET", - cookies: req.cookies, - headers: req.headers, - }, - }) + const urlOrError = getURL( + "/api/auth/session", + options.trustHost, + req.headers["x-forwarded-host"] ?? req.headers.host + ) + + if (urlOrError instanceof Error) throw urlOrError - const { body, cookies, status = 200 } = session + options.secret ??= process.env.NEXTAUTH_SECRET + const response = await AuthHandler( + new Request(urlOrError, { headers: req.headers }), + options + ) - cookies?.forEach((cookie) => setCookie(res, cookie)) + const { status = 200, headers } = response - if (body && typeof body !== "string" && Object.keys(body).length) { - if (status === 200) { - // @ts-expect-error - if (isRSC) delete body.expires - return body as R - } - throw new Error((body as any).message) + for (const [key, val] of headers.entries()) { + const value = key === "set-cookie" ? val.split(",") : val + res.setHeader(key, value) } - return null -} + const data = await response.json() -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace NodeJS { - interface ProcessEnv { - NEXTAUTH_URL?: string - VERCEL?: "1" - } + if (!data || !Object.keys(data).length) return null + + if (status === 200) { + if (isRSC) delete data.expires + return data as R } + throw new Error(data.message) } diff --git a/packages/next-auth/src/next/middleware.ts b/packages/next-auth/src/next/middleware.ts index aaae9069ef..efc9e2d343 100644 --- a/packages/next-auth/src/next/middleware.ts +++ b/packages/next-auth/src/next/middleware.ts @@ -6,7 +6,7 @@ import { NextResponse, NextRequest } from "next/server" import { getToken } from "../jwt" import parseUrl from "../utils/parse-url" -import { detectHost } from "../utils/detect-host" +import { getURL } from "../utils/node" type AuthorizedCallback = (params: { token: JWT | null @@ -103,14 +103,10 @@ export interface NextAuthMiddlewareOptions { trustHost?: NextAuthOptions["trustHost"] } -// TODO: `NextMiddleware` should allow returning `void` -// Simplify when https://github.com/vercel/next.js/pull/38625 is merged. -type NextMiddlewareResult = ReturnType | void // eslint-disable-line @typescript-eslint/no-invalid-void-type - async function handleMiddleware( req: NextRequest, options: NextAuthMiddlewareOptions | undefined = {}, - onSuccess?: (token: JWT | null) => Promise + onSuccess?: (token: JWT | null) => ReturnType ) { const { pathname, search, origin, basePath } = req.nextUrl @@ -121,13 +117,15 @@ async function handleMiddleware( options.trustHost ?? process.env.VERCEL ?? process.env.AUTH_TRUST_HOST ) - const host = detectHost( + let authPath + const url = getURL( + null, options.trustHost, - req.headers.get("x-forwarded-host"), - process.env.NEXTAUTH_URL ?? - (process.env.NODE_ENV !== "production" && "http://localhost:3000") + req.headers.get("x-forwarded-host") ?? req.headers.get("host") ) - const authPath = parseUrl(host).path + if (url instanceof URL) authPath = parseUrl(url).path + else authPath = "/api/auth" + const publicPaths = ["/_next", "/favicon.ico"] // Avoid infinite redirects/invalid response @@ -140,8 +138,8 @@ async function handleMiddleware( return } - const secret = options?.secret ?? process.env.NEXTAUTH_SECRET - if (!secret) { + options.secret ??= process.env.NEXTAUTH_SECRET + if (!options.secret) { console.error( `[next-auth][error][NO_SECRET]`, `\nhttps://next-auth.js.org/errors#no_secret` @@ -155,9 +153,9 @@ async function handleMiddleware( const token = await getToken({ req, - decode: options?.jwt?.decode, + decode: options.jwt?.decode, cookieName: options?.cookies?.sessionToken?.name, - secret, + secret: options.secret, }) const isAuthorized = @@ -182,7 +180,7 @@ export interface NextRequestWithAuth extends NextRequest { export type NextMiddlewareWithAuth = ( request: NextRequestWithAuth, event: NextFetchEvent -) => NextMiddlewareResult | Promise +) => ReturnType export type WithAuthArgs = | [NextRequestWithAuth] diff --git a/packages/next-auth/src/next/utils.ts b/packages/next-auth/src/next/utils.ts deleted file mode 100644 index 6e5769a10f..0000000000 --- a/packages/next-auth/src/next/utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { serialize } from "cookie" -import { Cookie } from "../core/lib/cookie" - -export function setCookie(res, cookie: Cookie) { - // Preserve any existing cookies that have already been set in the same session - let setCookieHeader = res.getHeader("Set-Cookie") ?? [] - // If not an array (i.e. a string with a single cookie) convert it into an array - if (!Array.isArray(setCookieHeader)) { - setCookieHeader = [setCookieHeader] - } - const { name, value, options } = cookie - const cookieHeader = serialize(name, value, options) - setCookieHeader.push(cookieHeader) - res.setHeader("Set-Cookie", setCookieHeader) -} diff --git a/packages/next-auth/src/utils/detect-host.ts b/packages/next-auth/src/utils/detect-host.ts deleted file mode 100644 index 911d17a61e..0000000000 --- a/packages/next-auth/src/utils/detect-host.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** 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 -} diff --git a/packages/next-auth/src/utils/node.ts b/packages/next-auth/src/utils/node.ts new file mode 100644 index 0000000000..7cec0624d5 --- /dev/null +++ b/packages/next-auth/src/utils/node.ts @@ -0,0 +1,63 @@ +import type { IncomingMessage } from "http" +import type { GetServerSidePropsContext, NextApiRequest } from "next" + +export function setCookie(res, value: string) { + // Preserve any existing cookies that have already been set in the same session + let setCookieHeader = res.getHeader("Set-Cookie") ?? [] + // If not an array (i.e. a string with a single cookie) convert it into an array + if (!Array.isArray(setCookieHeader)) { + setCookieHeader = [setCookieHeader] + } + setCookieHeader.push(value) + res.setHeader("Set-Cookie", setCookieHeader) +} + +export function getBody( + req: IncomingMessage | NextApiRequest | GetServerSidePropsContext["req"] +) { + if (!("body" in req) || !req.body || req.method !== "POST") { + return + } + + if (req.body instanceof ReadableStream) { + return { body: req.body } + } + return { body: JSON.stringify(req.body) } +} + +/** Extract the host from the environment */ +export function getURL( + url: string | undefined | null, + trusted: boolean | undefined = !!( + process.env.AUTH_TRUST_HOST ?? process.env.VERCEL + ), + forwardedValue: string | string[] | undefined | null +): URL | Error { + try { + let host = + process.env.NEXTAUTH_URL ?? + (process.env.NODE_ENV !== "production" && "http://localhost:3000") + + if (trusted && forwardedValue) { + host = Array.isArray(forwardedValue) ? forwardedValue[0] : forwardedValue + } + + if (!host) throw new TypeError("Invalid host") + + return new URL(url ?? "", new URL(host)) + } catch (error) { + return error as Error + } +} + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + interface ProcessEnv { + AUTH_TRUST_HOST?: string + NEXTAUTH_URL?: string + NEXTAUTH_SECRET?: string + VERCEL?: "1" + } + } +} diff --git a/packages/next-auth/src/utils/parse-url.ts b/packages/next-auth/src/utils/parse-url.ts index 6494c097d6..7f63b0ada2 100644 --- a/packages/next-auth/src/utils/parse-url.ts +++ b/packages/next-auth/src/utils/parse-url.ts @@ -12,10 +12,10 @@ export interface InternalUrl { } /** Returns an `URL` like object to make requests/redirects from server-side */ -export default function parseUrl(url?: string): InternalUrl { +export default function parseUrl(url?: string | URL): InternalUrl { const defaultUrl = new URL("http://localhost:3000/api/auth") - if (url && !url.startsWith("http")) { + if (url && !url.toString().startsWith("http")) { url = `https://${url}` } diff --git a/packages/next-auth/src/utils/web.ts b/packages/next-auth/src/utils/web.ts new file mode 100644 index 0000000000..5205143e60 --- /dev/null +++ b/packages/next-auth/src/utils/web.ts @@ -0,0 +1,104 @@ +import { serialize, parse as parseCookie } from "cookie" +import type { OutgoingResponse, RequestInternal } from "../core" +import type { NextAuthAction } from "../core/types" + +const decoder = new TextDecoder() + +async function streamToString(stream): Promise { + const chunks: Uint8Array[] = [] + return await new Promise((resolve, reject) => { + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + stream.on("error", (err) => reject(err)) + stream.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + }) +} + +async function readJSONBody( + body: ReadableStream | Buffer +): Promise | undefined> { + try { + if ("getReader" in body) { + const reader = body.getReader() + const bytes: number[] = [] + while (true) { + const { value, done } = await reader.read() + if (done) break + bytes.push(...value) + } + const b = new Uint8Array(bytes) + return JSON.parse(decoder.decode(b)) + } + + // node-fetch + + if (typeof Buffer !== "undefined" && Buffer.isBuffer(body)) { + return JSON.parse(body.toString("utf8")) + } + + return JSON.parse(await streamToString(body)) + } catch (e) { + console.error(e) + } +} + +export async function toInternalRequest( + req: Request +): Promise { + const url = new URL(req.url) + const nextauth = url.pathname.split("/").slice(3) + const headers = Object.fromEntries(req.headers) + const query: Record = Object.fromEntries(url.searchParams) + + const cookieHeader = req.headers.get("cookie") ?? "" + const cookies = + parseCookie( + Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader + ) ?? {} + + return { + action: nextauth[0] as NextAuthAction, + method: req.method, + headers, + body: req.body ? await readJSONBody(req.body) : undefined, + cookies: cookies, + providerId: nextauth[1], + error: url.searchParams.get("error") ?? undefined, + host: new URL(req.url).origin, + query, + } +} + +export function toResponse(res: OutgoingResponse): Response { + const headers = new Headers( + res.headers?.reduce((acc, { key, value }) => { + acc[key] = value + return acc + }, {}) + ) + + res.cookies?.forEach((cookie) => { + const { name, value, options } = cookie + const cookieHeader = serialize(name, value, options) + if (headers.has("Set-Cookie")) { + headers.append("Set-Cookie", cookieHeader) + } else { + headers.set("Set-Cookie", cookieHeader) + } + }) + + const body = + headers.get("content-type") === "application/json" + ? JSON.stringify(res.body) + : res.body + + const response = new Response(body, { + headers, + status: res.redirect ? 302 : res.status ?? 200, + }) + + if (res.redirect) { + response.headers.set("Location", res.redirect) + } + + return response +} diff --git a/packages/next-auth/tests/assert.test.ts b/packages/next-auth/tests/assert.test.ts index 794992e9c0..33e5680079 100644 --- a/packages/next-auth/tests/assert.test.ts +++ b/packages/next-auth/tests/assert.test.ts @@ -4,7 +4,7 @@ import { MissingAdapterMethods, MissingSecret, } from "../src/core/errors" -import { handler } from "./lib" +import { handler } from "./utils" import EmailProvider from "../src/providers/email" it("Show error page if secret is not defined", async () => { diff --git a/packages/next-auth/tests/email.test.ts b/packages/next-auth/tests/email.test.ts index 6c7e4a2dd9..c98356f147 100644 --- a/packages/next-auth/tests/email.test.ts +++ b/packages/next-auth/tests/email.test.ts @@ -1,8 +1,9 @@ -import { createCSRF, handler, mockAdapter } from "./lib" +import { createCSRF, handler, mockAdapter } from "./utils" import EmailProvider from "../src/providers/email" it("Send e-mail to the only address correctly", async () => { const { secret, csrf } = await createCSRF() + const sendVerificationRequest = jest.fn() const signIn = jest.fn(() => true) @@ -18,7 +19,7 @@ it("Send e-mail to the only address correctly", async () => { path: "signin/email", requestInit: { method: "POST", - headers: { cookie: csrf.cookie }, + headers: { cookie: csrf.cookie, "content-type": "application/json" }, body: JSON.stringify({ email: email, csrfToken: csrf.value }), }, } @@ -58,7 +59,7 @@ it("Send e-mail to first address only", async () => { path: "signin/email", requestInit: { method: "POST", - headers: { cookie: csrf.cookie }, + headers: { cookie: csrf.cookie, "content-type": "application/json" }, body: JSON.stringify({ email: email, csrfToken: csrf.value }), }, } @@ -98,7 +99,7 @@ it("Send e-mail to address with first domain", async () => { path: "signin/email", requestInit: { method: "POST", - headers: { cookie: csrf.cookie }, + headers: { cookie: csrf.cookie, "content-type": "application/json" }, body: JSON.stringify({ email: email, csrfToken: csrf.value }), }, } @@ -144,7 +145,7 @@ it("Redirect to error page if multiple addresses aren't allowed", async () => { path: "signin/email", requestInit: { method: "POST", - headers: { cookie: csrf.cookie }, + headers: { cookie: csrf.cookie, "content-type": "application/json" }, body: JSON.stringify({ email: "email@email.com,email@email2.com", csrfToken: csrf.value, @@ -156,7 +157,6 @@ it("Redirect to error page if multiple addresses aren't allowed", async () => { expect(signIn).toBeCalledTimes(0) expect(sendVerificationRequest).toBeCalledTimes(0) - // @ts-expect-error expect(log.error.mock.calls[0]).toEqual([ "SIGNIN_EMAIL_ERROR", { error, providerId: "email" }, diff --git a/packages/next-auth/tests/getServerSession.test.ts b/packages/next-auth/tests/getServerSession.test.ts index 64c30cdc45..e169c5ee99 100644 --- a/packages/next-auth/tests/getServerSession.test.ts +++ b/packages/next-auth/tests/getServerSession.test.ts @@ -1,7 +1,7 @@ import * as core from "../src/core" import { MissingSecret } from "../src/core/errors" import { unstable_getServerSession } from "../src/next" -import { mockLogger } from "./lib" +import { mockLogger } from "./utils" const originalWarn = console.warn let logger = mockLogger() @@ -83,9 +83,9 @@ describe("Return correct data", () => { }) it("Should return null if there is no session", async () => { - const spy = jest.spyOn(core, "NextAuthHandler") - // @ts-expect-error - spy.mockReturnValue({ body: {} }) + const spy = jest.spyOn(core, "AuthHandler") + // @ts-expect-error [Response.json](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) + spy.mockReturnValue(Promise.resolve(Response.json(null))) const session = await unstable_getServerSession(req, res, { providers: [], @@ -97,21 +97,19 @@ describe("Return correct data", () => { }) it("Should return the session if one is found", async () => { - const mockedResponse = { - body: { - user: { - name: "John Doe", - email: "test@example.com", - image: "", - id: "1234", - }, - expires: "", + const mockedBody = { + user: { + name: "John Doe", + email: "test@example.com", + image: "", + id: "1234", }, + expires: "", } - const spy = jest.spyOn(core, "NextAuthHandler") - // @ts-expect-error - spy.mockReturnValue(mockedResponse) + const spy = jest.spyOn(core, "AuthHandler") + // @ts-expect-error [Response.json](https://developer.mozilla.org/en-US/docs/Web/API/Response/json) + spy.mockReturnValue(Promise.resolve(Response.json(mockedBody))) const session = await unstable_getServerSession(req, res, { providers: [], @@ -119,6 +117,6 @@ describe("Return correct data", () => { secret: "secret", }) - expect(session).toEqual(mockedResponse.body) + expect(session).toEqual(mockedBody) }) }) diff --git a/packages/next-auth/tests/lib.ts b/packages/next-auth/tests/lib.ts deleted file mode 100644 index 7d4a977390..0000000000 --- a/packages/next-auth/tests/lib.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { createHash } from "crypto" -import { NextAuthHandler } from "../src/core" -import type { LoggerInstance, NextAuthOptions } from "../src" -import type { Adapter } from "../src/adapters" - -export const mockLogger: () => LoggerInstance = () => ({ - error: jest.fn(() => {}), - warn: jest.fn(() => {}), - debug: jest.fn(() => {}), -}) - -interface HandlerOptions { - prod?: boolean - path?: string - params?: URLSearchParams | Record - requestInit?: RequestInit -} - -export async function handler( - options: NextAuthOptions, - { prod, path, params, requestInit }: HandlerOptions -) { - // @ts-expect-error - if (prod) process.env.NODE_ENV = "production" - - const url = new URL( - `http://localhost/api/auth/${path ?? "signin"}?${new URLSearchParams( - params ?? {} - )}` - ) - const req = new Request(url, { headers: { host: "" }, ...requestInit }) - const logger = mockLogger() - const response = await NextAuthHandler({ - req, - options: { secret: "secret", ...options, logger }, - }) - // @ts-expect-error - if (prod) process.env.NODE_ENV = "test" - - return { - res: { - ...response, - html: - response.headers?.[0].value === "text/html" ? response.body : undefined, - }, - log: logger, - } -} - -export function createCSRF() { - const secret = "secret" - const value = "csrf" - const token = createHash("sha256").update(`${value}${secret}`).digest("hex") - - return { - secret, - csrf: { value, token, cookie: `next-auth.csrf-token=${value}|${token}` }, - } -} - -export function mockAdapter(): Adapter { - const adapter: Adapter = { - createVerificationToken: jest.fn(() => {}), - useVerificationToken: jest.fn(() => {}), - getUserByEmail: jest.fn(() => {}), - } - return adapter -} diff --git a/packages/next-auth/tests/next.test.ts b/packages/next-auth/tests/next.test.ts new file mode 100644 index 0000000000..92e9a87889 --- /dev/null +++ b/packages/next-auth/tests/next.test.ts @@ -0,0 +1,92 @@ +import { MissingAPIRoute } from "../src/core/errors" +import { nodeHandler } from "./utils" + +it("Missing req.url throws MISSING_NEXTAUTH_API_ROUTE_ERROR", async () => { + const { res, logger } = await nodeHandler() + + expect(res.status).toBeCalledWith(500) + expect(logger.error).toBeCalledTimes(1) + expect(logger.error).toBeCalledWith( + "MISSING_NEXTAUTH_API_ROUTE_ERROR", + expect.any(MissingAPIRoute) + ) + expect(res.setHeader).toBeCalledWith("content-type", "application/json") + const body = res.send.mock.calls[0][0] + expect(JSON.parse(body)).toEqual({ + message: + "There is a problem with the server configuration. Check the server logs for more information.", + }) +}) + +it("Missing host throws 400 in production", async () => { + // @ts-expect-error + process.env.NODE_ENV = "production" + const { res } = await nodeHandler() + expect(res.status).toBeCalledWith(400) + // @ts-expect-error + process.env.NODE_ENV = "test" +}) + +it("Defined host throws 400 in production if not trusted", async () => { + // @ts-expect-error + process.env.NODE_ENV = "production" + const { res } = await nodeHandler({ + req: { headers: { host: "http://localhost" } }, + }) + expect(res.status).toBeCalledWith(400) + // @ts-expect-error + process.env.NODE_ENV = "test" +}) + +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({ + req: { headers: { host: "localhost" } }, + options: { trustHost: true }, + }) + expect(res.status).toBeCalledWith(400) + // @ts-expect-error + process.env.NODE_ENV = "test" +}) + +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({ + req: { + url: "/api/auth/session", + headers: { host: "http://localhost" }, + }, + options: { trustHost: true }, + }) + expect(res.status).toBeCalledWith(200) + expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({}) + // @ts-expect-error + process.env.NODE_ENV = "test" +}) + +it("Use process.env.NEXTAUTH_URL for host if present", async () => { + process.env.NEXTAUTH_URL = "http://localhost" + const { res } = await nodeHandler({ + req: { url: "/api/auth/session" }, + }) + expect(res.status).toBeCalledWith(200) + 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({ + req: { + method: "post", + url: "/api/auth/signin/github", + body: { json: "true" }, + }, + }) + expect(res.status).toBeCalledWith(302) + expect(res.removeHeader).toBeCalledWith("Location") + expect(res.json).toBeCalledWith({ + url: "http://localhost/api/auth/signin?csrf=true", + }) +}) diff --git a/packages/next-auth/tests/pkce-handler.test.ts b/packages/next-auth/tests/pkce-handler.test.ts index 31f569b9b1..45bfddaaf3 100644 --- a/packages/next-auth/tests/pkce-handler.test.ts +++ b/packages/next-auth/tests/pkce-handler.test.ts @@ -1,5 +1,16 @@ -import { mockLogger } from "./lib" -import type { InternalOptions, LoggerInstance, InternalProvider, CallbacksOptions, Account, Awaitable, Profile, Session, User, CookiesOptions } from "../src" +import { mockLogger } from "./utils" +import type { + InternalOptions, + LoggerInstance, + InternalProvider, + CallbacksOptions, + Account, + Awaitable, + Profile, + Session, + User, + CookiesOptions, +} from "../src" import { createPKCE } from "../src/core/lib/oauth/pkce-handler" import { InternalUrl } from "../src/utils/parse-url" import { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "../src/jwt" @@ -21,7 +32,7 @@ beforeEach(() => { host: "localhost:3000", path: "/api/auth", base: "http://localhost:3000/api/auth", - toString: () => "http://localhost:3000/api/auth" + toString: () => "http://localhost:3000/api/auth", } provider = { @@ -30,7 +41,7 @@ beforeEach(() => { name: "testName", signinUrl: "/", callbackUrl: "/", - checks: ["pkce", "state"] + checks: ["pkce", "state"], } jwt = { @@ -41,22 +52,41 @@ beforeEach(() => { }, decode: function (params: JWTDecodeParams): Awaitable { throw new Error("Function not implemented.") - } + }, } callbacks = { - signIn: function (params: { user: User; account: Account; profile: Profile & Record; email: { verificationRequest?: boolean | undefined }; credentials?: Record | undefined }): Awaitable { + signIn: function (params: { + user: User + account: Account + profile: Profile & Record + email: { verificationRequest?: boolean | undefined } + credentials?: Record | undefined + }): Awaitable { throw new Error("Function not implemented.") }, - redirect: function (params: { url: string; baseUrl: string }): Awaitable { + redirect: function (params: { + url: string + baseUrl: string + }): Awaitable { throw new Error("Function not implemented.") }, - session: function (params: { session: Session; user: User; token: JWT }): Awaitable { + session: function (params: { + session: Session + user: User + token: JWT + }): Awaitable { throw new Error("Function not implemented.") }, - jwt: function (params: { token: JWT; user?: User | undefined; account?: Account | undefined; profile?: Profile | undefined; isNewUser?: boolean | undefined }): Awaitable { + jwt: function (params: { + token: JWT + user?: User | undefined + account?: Account | undefined + profile?: Profile | undefined + isNewUser?: boolean | undefined + }): Awaitable { throw new Error("Function not implemented.") - } + }, } cookies = { @@ -65,7 +95,7 @@ beforeEach(() => { csrfToken: { name: "", options: undefined }, pkceCodeVerifier: { name: "", options: {} }, state: { name: "", options: undefined }, - nonce: { name: "", options: undefined } + nonce: { name: "", options: undefined }, } options = { @@ -81,9 +111,9 @@ beforeEach(() => { events: {}, callbacks, cookies, - callbackUrl: '', + callbackUrl: "", providers: [], - theme: {} + theme: {}, } }) @@ -109,7 +139,7 @@ describe("createPKCE", () => { const expires = new Date() expires.setTime(expires.getTime() + defaultMaxAge * 1000) - validateCookieExpiration({pkce, expires}) + validateCookieExpiration({ pkce, expires }) expect(pkce?.cookie.options.maxAge).toBeUndefined() }) @@ -122,18 +152,18 @@ describe("createPKCE", () => { const expires = new Date() expires.setTime(expires.getTime() + maxAge * 1000) - validateCookieExpiration({pkce, expires}) + validateCookieExpiration({ pkce, expires }) expect(pkce?.cookie.options.maxAge).toEqual(maxAge) }) }) // comparing the parts instead of getTime() because the milliseconds // will not match since the two Date objects are created milliseconds apart -const validateCookieExpiration = ({pkce, expires}) => { +const validateCookieExpiration = ({ pkce, expires }) => { const cookieExpires = pkce?.cookie.options.expires expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear()) expect(cookieExpires.getMonth()).toEqual(expires.getMonth()) expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear()) expect(cookieExpires.getHours()).toEqual(expires.getHours()) expect(cookieExpires.getMinutes()).toEqual(expires.getMinutes()) -} \ No newline at end of file +} diff --git a/packages/next-auth/tests/state-handler.test.ts b/packages/next-auth/tests/state-handler.test.ts index 062850c895..38bdc4ab86 100644 --- a/packages/next-auth/tests/state-handler.test.ts +++ b/packages/next-auth/tests/state-handler.test.ts @@ -1,5 +1,16 @@ -import { mockLogger } from "./lib" -import type { InternalOptions, LoggerInstance, InternalProvider, CallbacksOptions, Account, Awaitable, Profile, Session, User, CookiesOptions } from "../src" +import { mockLogger } from "./utils" +import type { + InternalOptions, + LoggerInstance, + InternalProvider, + CallbacksOptions, + Account, + Awaitable, + Profile, + Session, + User, + CookiesOptions, +} from "../src" import { createState } from "../src/core/lib/oauth/state-handler" import { InternalUrl } from "../src/utils/parse-url" import { JWT, JWTOptions, encode, decode } from "../src/jwt" @@ -21,7 +32,7 @@ beforeEach(() => { host: "localhost:3000", path: "/api/auth", base: "http://localhost:3000/api/auth", - toString: () => "http://localhost:3000/api/auth" + toString: () => "http://localhost:3000/api/auth", } provider = { @@ -30,29 +41,48 @@ beforeEach(() => { name: "testName", signinUrl: "/", callbackUrl: "/", - checks: ["pkce", "state"] + checks: ["pkce", "state"], } jwt = { secret: "secret", maxAge: 0, encode, - decode + decode, } callbacks = { - signIn: function (params: { user: User; account: Account; profile: Profile & Record; email: { verificationRequest?: boolean | undefined }; credentials?: Record | undefined }): Awaitable { + signIn: function (params: { + user: User + account: Account + profile: Profile & Record + email: { verificationRequest?: boolean | undefined } + credentials?: Record | undefined + }): Awaitable { throw new Error("Function not implemented.") }, - redirect: function (params: { url: string; baseUrl: string }): Awaitable { + redirect: function (params: { + url: string + baseUrl: string + }): Awaitable { throw new Error("Function not implemented.") }, - session: function (params: { session: Session; user: User; token: JWT }): Awaitable { + session: function (params: { + session: Session + user: User + token: JWT + }): Awaitable { throw new Error("Function not implemented.") }, - jwt: function (params: { token: JWT; user?: User | undefined; account?: Account | undefined; profile?: Profile | undefined; isNewUser?: boolean | undefined }): Awaitable { + jwt: function (params: { + token: JWT + user?: User | undefined + account?: Account | undefined + profile?: Profile | undefined + isNewUser?: boolean | undefined + }): Awaitable { throw new Error("Function not implemented.") - } + }, } cookies = { @@ -61,7 +91,7 @@ beforeEach(() => { csrfToken: { name: "", options: undefined }, pkceCodeVerifier: { name: "", options: undefined }, state: { name: "", options: {} }, - nonce: { name: "", options: undefined } + nonce: { name: "", options: undefined }, } options = { @@ -77,9 +107,9 @@ beforeEach(() => { events: {}, callbacks, cookies, - callbackUrl: '', + callbackUrl: "", providers: [], - theme: {} + theme: {}, } }) @@ -104,7 +134,7 @@ describe("createState", () => { const expires = new Date() expires.setTime(expires.getTime() + defaultMaxAge * 1000) - validateCookieExpiration({state, expires}) + validateCookieExpiration({ state, expires }) expect(state?.cookie.options.maxAge).toBeUndefined() }) @@ -117,18 +147,18 @@ describe("createState", () => { const expires = new Date() expires.setTime(expires.getTime() + maxAge * 1000) - validateCookieExpiration({state, expires}) + validateCookieExpiration({ state, expires }) expect(state?.cookie.options.maxAge).toEqual(maxAge) }) }) // comparing the parts instead of getTime() because the milliseconds // will not match since the two Date objects are created milliseconds apart -const validateCookieExpiration = ({state, expires}) => { +const validateCookieExpiration = ({ state, expires }) => { const cookieExpires = state?.cookie.options.expires expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear()) expect(cookieExpires.getMonth()).toEqual(expires.getMonth()) expect(cookieExpires.getFullYear()).toEqual(expires.getFullYear()) expect(cookieExpires.getHours()).toEqual(expires.getHours()) expect(cookieExpires.getMinutes()).toEqual(expires.getMinutes()) -} \ No newline at end of file +} diff --git a/packages/next-auth/tests/utils.ts b/packages/next-auth/tests/utils.ts new file mode 100644 index 0000000000..a8b8b4a3f4 --- /dev/null +++ b/packages/next-auth/tests/utils.ts @@ -0,0 +1,116 @@ +import { createHash } from "crypto" +import { AuthHandler } from "../src/core" +import type { LoggerInstance, NextAuthOptions } from "../src" +import type { Adapter } from "../src/adapters" + +import NextAuth from "../src/next" + +import type { NextApiRequest, NextApiResponse } from "next" + +export function mockLogger(): Record { + return { + error: jest.fn(() => {}), + warn: jest.fn(() => {}), + debug: jest.fn(() => {}), + } +} + +interface HandlerOptions { + prod?: boolean + path?: string + params?: URLSearchParams | Record + requestInit?: RequestInit +} + +export async function handler( + options: NextAuthOptions, + { prod, path, params, requestInit }: HandlerOptions +) { + // @ts-expect-error + if (prod) process.env.NODE_ENV = "production" + + const url = new URL( + `http://localhost:3000/api/auth/${path ?? "signin"}?${new URLSearchParams( + params ?? {} + )}` + ) + const req = new Request(url, { headers: { host: "" }, ...requestInit }) + const logger = mockLogger() + const response = await AuthHandler(req, { + secret: "secret", + ...options, + logger, + }) + // @ts-expect-error + if (prod) process.env.NODE_ENV = "test" + + return { + res: { + status: response.status, + headers: response.headers, + body: response.body, + redirect: response.headers.get("location"), + html: + response.headers?.get("content-type") === "text/html" + ? await response.clone().text() + : undefined, + }, + log: logger, + } +} + +export function createCSRF() { + const secret = "secret" + const value = "csrf" + const token = createHash("sha256").update(`${value}${secret}`).digest("hex") + + return { + secret, + csrf: { value, token, cookie: `next-auth.csrf-token=${value}|${token}` }, + } +} + +export function mockAdapter(): Adapter { + const adapter: Adapter = { + createVerificationToken: jest.fn(() => {}), + useVerificationToken: jest.fn(() => {}), + getUserByEmail: jest.fn(() => {}), + } as unknown as Adapter + return adapter +} + +export async function nodeHandler( + params: { + req?: Partial + res?: Partial + options?: Partial + } = {} +) { + 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(), + } + + const logger = mockLogger() + + await NextAuth(req as any, res as any, { + providers: [], + secret: "secret", + logger, + ...params.options, + }) + return { req, res, logger } +}