From a83573ed2f696108ad2294a1892aa204cfaf7c71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Thu, 22 Dec 2022 00:48:38 +0100 Subject: [PATCH] fix(next-auth): revert to 4.17 to fix host issues but keep other fixes (#6132) * fix(next-auth): revert to 4.17 and replay other fixes * revert line change * replay some TS changes to reduce diff * fix tests * revert more renames * revert renames * fix test, cleanup --- packages/next-auth/src/core/errors.ts | 10 - packages/next-auth/src/core/index.ts | 139 +++++------ packages/next-auth/src/core/init.ts | 15 +- packages/next-auth/src/core/lib/assert.ts | 13 +- packages/next-auth/src/core/lib/providers.ts | 3 +- packages/next-auth/src/core/lib/utils.ts | 6 +- packages/next-auth/src/core/pages/error.tsx | 3 +- packages/next-auth/src/core/pages/index.ts | 2 +- packages/next-auth/src/core/pages/signout.tsx | 3 +- .../next-auth/src/core/routes/providers.ts | 2 +- packages/next-auth/src/core/routes/session.ts | 2 +- packages/next-auth/src/core/types.ts | 18 +- packages/next-auth/src/index.ts | 6 + packages/next-auth/src/next/index.ts | 127 +++++----- packages/next-auth/src/next/middleware.ts | 59 ++--- packages/next-auth/src/next/utils.ts | 15 ++ packages/next-auth/src/react/index.tsx | 10 +- packages/next-auth/src/utils/detect-host.ts | 8 + packages/next-auth/src/utils/node.ts | 167 ------------- packages/next-auth/src/utils/parse-url.ts | 9 +- packages/next-auth/src/utils/web.ts | 116 --------- packages/next-auth/tests/assert.test.ts | 10 +- packages/next-auth/tests/email.test.ts | 16 +- .../next-auth/tests/getServerSession.test.ts | 28 ++- packages/next-auth/tests/getURL.test.ts | 138 ----------- packages/next-auth/tests/lib.ts | 68 ++++++ packages/next-auth/tests/middleware.test.ts | 118 +++++---- packages/next-auth/tests/next.test.ts | 179 -------------- packages/next-auth/tests/pkce-handler.test.ts | 47 ++-- .../next-auth/tests/state-handler.test.ts | 50 ++-- packages/next-auth/tests/utils.ts | 224 ------------------ 31 files changed, 418 insertions(+), 1193 deletions(-) create mode 100644 packages/next-auth/src/next/utils.ts create mode 100644 packages/next-auth/src/utils/detect-host.ts delete mode 100644 packages/next-auth/src/utils/node.ts delete mode 100644 packages/next-auth/src/utils/web.ts delete mode 100644 packages/next-auth/tests/getURL.test.ts create mode 100644 packages/next-auth/tests/lib.ts delete mode 100644 packages/next-auth/tests/next.test.ts delete mode 100644 packages/next-auth/tests/utils.ts diff --git a/packages/next-auth/src/core/errors.ts b/packages/next-auth/src/core/errors.ts index 1b9b91e26c..b2eaf0ba09 100644 --- a/packages/next-auth/src/core/errors.ts +++ b/packages/next-auth/src/core/errors.ts @@ -72,16 +72,6 @@ export class InvalidCallbackUrl extends UnknownError { code = "INVALID_CALLBACK_URL_ERROR" } -export class UnknownAction extends UnknownError { - name = "UnknownAction" - code = "UNKNOWN_ACTION_ERROR" -} - -export class UntrustedHost extends UnknownError { - name = "UntrustedHost" - code = "UNTRUST_HOST_ERROR" -} - type Method = (...args: any[]) => Promise export function upperSnake(s: string) { diff --git a/packages/next-auth/src/core/index.ts b/packages/next-auth/src/core/index.ts index 9b7e4c09a0..cba0f212f0 100644 --- a/packages/next-auth/src/core/index.ts +++ b/packages/next-auth/src/core/index.ts @@ -1,21 +1,20 @@ import logger, { setLogger } from "../utils/logger" -import { toInternalRequest, toResponse } from "../utils/web" +import { detectHost } from "../utils/detect-host" +import * as routes from "./routes" +import renderPage from "./pages" import { init } from "./init" import { assertConfig } from "./lib/assert" import { SessionStore } from "./lib/cookie" -import renderPage from "./pages" -import * as routes from "./routes" -import { UntrustedHost } from "./errors" +import type { AuthAction, AuthOptions } from "./types" import type { Cookie } from "./lib/cookie" import type { ErrorType } from "./pages/error" -import type { AuthAction, AuthOptions } from "./types" +import { parse as parseCookie } from "cookie" -/** @internal */ export interface RequestInternal { - url: URL - /** @default "GET" */ - method: string + /** @default "http://localhost:3000" */ + host?: string + method?: string cookies?: Partial> headers?: Record query?: Record @@ -25,29 +24,67 @@ export interface RequestInternal { error?: string } -/** @internal */ +export interface NextAuthHeader { + key: string + value: string +} + export interface ResponseInternal< Body extends string | Record | any[] = any > { status?: number - headers?: Record + headers?: NextAuthHeader[] body?: Body redirect?: string cookies?: Cookie[] } -const configErrorMessage = - "There is a problem with the server configuration. Check the server logs for more information." +export interface NextAuthHandlerParams { + req: Request | RequestInternal + options: AuthOptions +} + +async function getBody(req: Request): Promise | undefined> { + try { + return await req.json() + } catch {} +} + +// TODO: +async function toInternalRequest( + req: RequestInternal | Request +): 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 AuthAction, + 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(headers["x-forwarded-host"] ?? headers.host), + query, + } + } + return req +} -async function AuthHandlerInternal< +export async function AuthHandler< Body extends string | Record | any[] ->(params: { - req: RequestInternal - options: AuthOptions - /** REVIEW: Is this the best way to skip parsing the body in Node.js? */ - parsedBody?: any -}): Promise> { - const { options: authOptions, req } = params +>(params: NextAuthHandlerParams): Promise> { + const { options: authOptions, req: incomingRequest } = params + + const req = await toInternalRequest(incomingRequest) + + setLogger(authOptions.logger, authOptions.debug) const assertionResult = assertConfig({ options: authOptions, req }) @@ -59,10 +96,11 @@ async function AuthHandlerInternal< const htmlPages = ["signin", "signout", "error", "verify-request"] if (!htmlPages.includes(req.action) || req.method !== "GET") { + const message = `There is a problem with the server configuration. Check the server logs for more information.` return { status: 500, - headers: { "Content-Type": "application/json" }, - body: { message: configErrorMessage } as any, + headers: [{ key: "Content-Type", value: "application/json" }], + body: { message } as any, } } const { pages, theme } = authOptions @@ -88,13 +126,13 @@ async function AuthHandlerInternal< } } - const { action, providerId, error, method } = req + const { action, providerId, error, method = "GET" } = req const { options, cookies } = await init({ authOptions, action, providerId, - url: req.url, + host: req.host, callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl, csrfToken: req.body?.csrfToken, cookies: req.cookies, @@ -116,12 +154,11 @@ async function AuthHandlerInternal< 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": return { - headers: { "Content-Type": "application/json" }, + headers: [{ key: "Content-Type", value: "application/json" }], body: { csrfToken: options.csrfToken } as any, cookies, } @@ -257,51 +294,3 @@ async function AuthHandlerInternal< 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: AuthOptions -): Promise { - setLogger(options.logger, options.debug) - - if (!options.trustHost) { - const error = new UntrustedHost( - `Host must be trusted. URL was: ${request.url}` - ) - logger.error(error.code, error) - - return new Response(JSON.stringify({ message: configErrorMessage }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }) - } - - const req = await toInternalRequest(request) - if (req instanceof Error) { - logger.error((req as any).code, req) - return new Response( - `Error: This action with HTTP ${request.method} is not supported.`, - { status: 400 } - ) - } - const internalResponse = await AuthHandlerInternal({ req, options }) - - const response = await toResponse(internalResponse) - - // If the request expects a return URL, send it as JSON - // instead of doing an actual redirect. - const redirect = response.headers.get("Location") - if (request.headers.has("X-Auth-Return-Redirect") && redirect) { - response.headers.delete("Location") - response.headers.set("Content-Type", "application/json") - return new Response(JSON.stringify({ url: redirect }), { - headers: response.headers, - }) - } - return response -} diff --git a/packages/next-auth/src/core/init.ts b/packages/next-auth/src/core/init.ts index ede030a56e..2fd8491842 100644 --- a/packages/next-auth/src/core/init.ts +++ b/packages/next-auth/src/core/init.ts @@ -15,7 +15,7 @@ import type { InternalOptions } from "./types" import parseUrl from "../utils/parse-url" interface InitParams { - url: URL + host?: string authOptions: AuthOptions providerId?: string action: InternalOptions["action"] @@ -33,7 +33,7 @@ export async function init({ authOptions, providerId, action, - url: reqUrl, + host, cookies: reqCookies, callbackUrl: reqCallbackUrl, csrfToken: reqCsrfToken, @@ -42,12 +42,7 @@ export async function init({ options: InternalOptions cookies: cookie.Cookie[] }> { - // TODO: move this to web.ts - const parsed = parseUrl( - reqUrl.origin + - reqUrl.pathname.replace(`/${action}`, "").replace(`/${providerId}`, "") - ) - const url = new URL(parsed.toString()) + const url = parseUrl(host) const secret = createSecret({ authOptions, url }) @@ -72,7 +67,7 @@ export async function init({ }, // Custom options override defaults ...authOptions, - // These computed settings can have values in userOptions but we override them + // These computed settings can have values in authOptions but we override them // and are request-specific. url, action, @@ -80,7 +75,7 @@ export async function init({ provider, cookies: { ...cookie.defaultCookies( - authOptions.useSecureCookies ?? url.protocol === "https:" + authOptions.useSecureCookies ?? url.base.startsWith("https://") ), // Allow user cookie options to override any cookie settings above ...authOptions.cookies, diff --git a/packages/next-auth/src/core/lib/assert.ts b/packages/next-auth/src/core/lib/assert.ts index 589a025d82..b6644b8dc4 100644 --- a/packages/next-auth/src/core/lib/assert.ts +++ b/packages/next-auth/src/core/lib/assert.ts @@ -7,6 +7,7 @@ import { InvalidCallbackUrl, MissingAdapterMethods, } from "../errors" +import parseUrl from "../../utils/parse-url" import { defaultCookies } from "./cookie" import type { RequestInternal } from ".." @@ -43,11 +44,11 @@ export function assertConfig(params: { req: RequestInternal }): ConfigError | WarningCode[] { const { options, req } = params - const { url } = req + const warnings: WarningCode[] = [] if (!warned) { - if (!url.origin) warnings.push("NEXTAUTH_URL") + if (!req.host) warnings.push("NEXTAUTH_URL") // TODO: Make this throw an error in next major. This will also get rid of `NODE_ENV` if (!options.secret && process.env.NODE_ENV !== "production") @@ -69,19 +70,21 @@ export function assertConfig(params: { const callbackUrlParam = req.query?.callbackUrl as string | undefined - if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.origin)) { + const url = parseUrl(req.host) + + if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.base)) { return new InvalidCallbackUrl( `Invalid callback URL. Received: ${callbackUrlParam}` ) } const { callbackUrl: defaultCallbackUrl } = defaultCookies( - options.useSecureCookies ?? url.protocol === "https://" + options.useSecureCookies ?? url.base.startsWith("https://") ) const callbackUrlCookie = req.cookies?.[options.cookies?.callbackUrl?.name ?? defaultCallbackUrl.name] - if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.origin)) { + if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.base)) { return new InvalidCallbackUrl( `Invalid callback URL. Received: ${callbackUrlCookie}` ) diff --git a/packages/next-auth/src/core/lib/providers.ts b/packages/next-auth/src/core/lib/providers.ts index 5f350add7f..892bd374f8 100644 --- a/packages/next-auth/src/core/lib/providers.ts +++ b/packages/next-auth/src/core/lib/providers.ts @@ -6,6 +6,7 @@ import type { OAuthConfig, Provider, } from "../../providers" +import type { InternalUrl } from "../../utils/parse-url" /** * Adds `signinUrl` and `callbackUrl` to each provider @@ -13,7 +14,7 @@ import type { */ export default function parseProviders(params: { providers: Provider[] - url: URL + url: InternalUrl providerId?: string }): { providers: InternalProvider[] diff --git a/packages/next-auth/src/core/lib/utils.ts b/packages/next-auth/src/core/lib/utils.ts index c2ed6991c5..c8296a766b 100644 --- a/packages/next-auth/src/core/lib/utils.ts +++ b/packages/next-auth/src/core/lib/utils.ts @@ -2,6 +2,7 @@ import { createHash } from "crypto" import type { AuthOptions } from "../.." import type { InternalOptions } from "../types" +import type { InternalUrl } from "../../utils/parse-url" /** * Takes a number in seconds and returns the date in the future. @@ -27,7 +28,10 @@ export function hashToken(token: string, options: InternalOptions<"email">) { * If no secret option is specified then it creates one on the fly * based on options passed here. If options contains unique data, such as * OAuth provider secrets and database credentials it should be sufficent. If no secret provided in production, we throw an error. */ -export function createSecret(params: { authOptions: AuthOptions; url: URL }) { +export function createSecret(params: { + authOptions: AuthOptions + url: InternalUrl +}) { const { authOptions, url } = params return ( diff --git a/packages/next-auth/src/core/pages/error.tsx b/packages/next-auth/src/core/pages/error.tsx index b2b803b38f..e3f5562e57 100644 --- a/packages/next-auth/src/core/pages/error.tsx +++ b/packages/next-auth/src/core/pages/error.tsx @@ -1,4 +1,5 @@ import { Theme } from "../.." +import { InternalUrl } from "../../utils/parse-url" /** * The following errors are passed as error query parameters to the default or overridden error page. @@ -11,7 +12,7 @@ export type ErrorType = | "verification" export interface ErrorProps { - url?: URL + url?: InternalUrl theme?: Theme error?: ErrorType } diff --git a/packages/next-auth/src/core/pages/index.ts b/packages/next-auth/src/core/pages/index.ts index 8eed148e73..6938a4e016 100644 --- a/packages/next-auth/src/core/pages/index.ts +++ b/packages/next-auth/src/core/pages/index.ts @@ -31,7 +31,7 @@ export default function renderPage(params: RenderPageParams) { return { cookies, status, - headers: { "Content-Type": "text/html" }, + headers: [{ key: "Content-Type", value: "text/html" }], body: `${title}
${renderToString(html)}
`, diff --git a/packages/next-auth/src/core/pages/signout.tsx b/packages/next-auth/src/core/pages/signout.tsx index 3d986a1040..352d825753 100644 --- a/packages/next-auth/src/core/pages/signout.tsx +++ b/packages/next-auth/src/core/pages/signout.tsx @@ -1,7 +1,8 @@ import { Theme } from "../.." +import { InternalUrl } from "../../utils/parse-url" export interface SignoutProps { - url: URL + url: InternalUrl csrfToken: string theme: Theme } diff --git a/packages/next-auth/src/core/routes/providers.ts b/packages/next-auth/src/core/routes/providers.ts index 2f6f1b0fab..9ce34acd44 100644 --- a/packages/next-auth/src/core/routes/providers.ts +++ b/packages/next-auth/src/core/routes/providers.ts @@ -18,7 +18,7 @@ export default function providers( providers: InternalProvider[] ): ResponseInternal> { return { - headers: { "Content-Type": "application/json" }, + headers: [{ key: "Content-Type", value: "application/json" }], body: providers.reduce>( (acc, { id, name, type, signinUrl, callbackUrl }) => { acc[id] = { id, name, type, signinUrl, callbackUrl } diff --git a/packages/next-auth/src/core/routes/session.ts b/packages/next-auth/src/core/routes/session.ts index a7eabc5113..73caccdd9b 100644 --- a/packages/next-auth/src/core/routes/session.ts +++ b/packages/next-auth/src/core/routes/session.ts @@ -31,7 +31,7 @@ export default async function session( const response: ResponseInternal = { body: {}, - headers: { "Content-Type": "application/json" }, + headers: [{ key: "Content-Type", value: "application/json" }], cookies: [], } diff --git a/packages/next-auth/src/core/types.ts b/packages/next-auth/src/core/types.ts index 11137b7a2b..3213792197 100644 --- a/packages/next-auth/src/core/types.ts +++ b/packages/next-auth/src/core/types.ts @@ -14,6 +14,8 @@ import type { CookieSerializeOptions } from "cookie" import type { NextApiRequest, NextApiResponse } from "next" +import type { InternalUrl } from "../utils/parse-url" + export type Awaitable = T | PromiseLike export type { LoggerInstance } @@ -201,16 +203,6 @@ export interface AuthOptions { * [Documentation](https://next-auth.js.org/configuration/options#cookies) | [Usage example](https://next-auth.js.org/configuration/options#example) */ cookies?: Partial - /** - * If set to `true`, NextAuth.js will use either the `x-forwarded-host` or `host` headers, - * instead of `NEXTAUTH_URL` - * Make sure that reading `x-forwarded-host` on your hosting platform can be trusted. - * - ⚠ **This is an advanced option.** Advanced options are passed the same way as basic options, - * but **may have complex implications** or side effects. - * You should **try to avoid using advanced options** unless you are very comfortable using them. - * @default Boolean(process.env.NEXTAUTH_URL ?? process.env.AUTH_TRUST_HOST ?? process.env.VERCEL) - */ - trustHost?: boolean } /** @@ -526,7 +518,11 @@ export interface InternalOptions< WithVerificationToken = TProviderType extends "email" ? true : false > { providers: InternalProvider[] - url: URL + /** + * Parsed from `NEXTAUTH_URL` or `x-forwarded-host` on Vercel. + * @default "http://localhost:3000/api/auth" + */ + url: InternalUrl action: AuthAction provider: InternalProvider csrfToken?: string diff --git a/packages/next-auth/src/index.ts b/packages/next-auth/src/index.ts index 1efe460d51..fdb9e17020 100644 --- a/packages/next-auth/src/index.ts +++ b/packages/next-auth/src/index.ts @@ -1,4 +1,10 @@ export * from "./core/types" export type { AuthOptions as NextAuthOptions } from "./core/types" + +export type { + RequestInternal, + ResponseInternal as 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 7697892fad..b3f57b6674 100644 --- a/packages/next-auth/src/next/index.ts +++ b/packages/next-auth/src/next/index.ts @@ -1,5 +1,6 @@ import { AuthHandler } from "../core" -import { getBody, getURL, setHeaders } from "../utils/node" +import { detectHost } from "../utils/detect-host" +import { setCookie } from "./utils" import type { GetServerSidePropsContext, @@ -9,6 +10,7 @@ import type { import type { AuthOptions, Session } from ".." import type { CallbacksOptions, + AuthAction, NextAuthRequest, NextAuthResponse, } from "../core/types" @@ -18,38 +20,45 @@ async function NextAuthHandler( res: NextApiResponse, options: AuthOptions ) { - const headers = new Headers(req.headers as any) - const url = getURL(req.url, headers) - if (url instanceof Error) { - if (process.env.NODE_ENV !== "production") throw url - const errorLogger = options.logger?.error ?? console.error - errorLogger("INVALID_URL", url) - res.status(400) - return res.json({ - message: - "There is a problem with the server configuration. Check the server logs for more information.", - }) - } - - const request = new Request(url, { - headers, - method: req.method, - ...getBody(req), + const { nextauth, ...query } = req.query + + options.secret = + options.secret ?? options.jwt?.secret ?? process.env.NEXTAUTH_SECRET + + const handler = await AuthHandler({ + req: { + host: detectHost(req.headers["x-forwarded-host"]), + body: req.body, + query, + cookies: req.cookies, + headers: req.headers, + method: req.method, + action: nextauth?.[0] as AuthAction, + providerId: nextauth?.[1], + error: (req.query.error as string | undefined) ?? nextauth?.[1], + }, + options, }) - options.secret ??= options.jwt?.secret ?? process.env.NEXTAUTH_SECRET - options.trustHost ??= !!( - process.env.NEXTAUTH_URL ?? - process.env.AUTH_TRUST_HOST ?? - process.env.VERCEL ?? - process.env.NODE_ENV !== "production" - ) + res.status(handler.status ?? 200) + + handler.cookies?.forEach((cookie) => setCookie(res, cookie)) - const response = await AuthHandler(request, options) - res.status(response.status) - setHeaders(response.headers, res) + handler.headers?.forEach((h) => res.setHeader(h.key, h.value)) - return res.send(await response.text()) + 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 }) + } + + return res.send(handler.body) } function NextAuth(options: AuthOptions): any @@ -140,39 +149,41 @@ export async function unstable_getServerSession< options = Object.assign({}, args[2], { providers: [] }) } - const url = getURL("/api/auth/session", new Headers(req.headers)) - if (url instanceof Error) { - if (process.env.NODE_ENV !== "production") throw url - const errorLogger = options.logger?.error ?? console.error - errorLogger("INVALID_URL", url) - res.status(400) - return res.json({ - message: - "There is a problem with the server configuration. Check the server logs for more information.", - }) - } - - const request = new Request(url, { headers: new Headers(req.headers) }) - - options.secret ??= process.env.NEXTAUTH_SECRET - options.trustHost = true - const response = await AuthHandler(request, options) - - const { status = 200, headers } = response + options.secret = options.secret ?? process.env.NEXTAUTH_SECRET + + const session = await AuthHandler({ + options, + req: { + host: detectHost(req.headers["x-forwarded-host"]), + action: "session", + method: "GET", + cookies: req.cookies, + headers: req.headers, + }, + }) - setHeaders(headers, res) + const { body, cookies, status = 200 } = session - // This would otherwise break rendering - // with `getServerSideProps` that needs to always return HTML - res.removeHeader?.("Content-Type") + cookies?.forEach((cookie) => setCookie(res, cookie)) - const data = await response.json() + 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) + } - if (!data || !Object.keys(data).length) return null + return null +} - if (status === 200) { - if (isRSC) delete data.expires - return data as R +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace NodeJS { + interface ProcessEnv { + NEXTAUTH_URL?: string + VERCEL?: "1" + } } - throw new Error(data.message) } diff --git a/packages/next-auth/src/next/middleware.ts b/packages/next-auth/src/next/middleware.ts index f9dfe1c9c3..881902087b 100644 --- a/packages/next-auth/src/next/middleware.ts +++ b/packages/next-auth/src/next/middleware.ts @@ -7,17 +7,6 @@ import { NextResponse, NextRequest } from "next/server" import { getToken } from "../jwt" import parseUrl from "../utils/parse-url" -// // 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 req: NextRequest @@ -67,7 +56,7 @@ export interface NextAuthMiddlewareOptions { * Callback that receives the user's JWT payload * and returns `true` to allow the user to continue. * - * This is similar to the `signIn` callback in `AuthOptions`. + * This is similar to the `signIn` callback in `NextAuthOptions`. * * If it returns `false`, the user is redirected to the sign-in page instead * @@ -100,43 +89,23 @@ export interface NextAuthMiddlewareOptions { * The same `secret` used in the `NextAuth` configuration. * Defaults to the `NEXTAUTH_SECRET` environment variable. */ - secret?: AuthOptions["secret"] - /** - * If set to `true`, NextAuth.js will use either the `x-forwarded-host` or `host` headers, - * instead of `NEXTAUTH_URL` - * Make sure that reading `x-forwarded-host` on your hosting platform can be trusted. - * - ⚠ **This is an advanced option.** Advanced options are passed the same way as basic options, - * but **may have complex implications** or side effects. - * You should **try to avoid using advanced options** unless you are very comfortable using them. - * @default Boolean(process.env.VERCEL ?? process.env.AUTH_TRUST_HOST) - */ - trustHost?: AuthOptions["trustHost"] + secret?: string } +// 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) => ReturnType + options: NextAuthMiddlewareOptions | undefined, + onSuccess?: (token: JWT | null) => Promise ) { const { pathname, search, origin, basePath } = req.nextUrl const signInPage = options?.pages?.signIn ?? "/api/auth/signin" const errorPage = options?.pages?.error ?? "/api/auth/error" - - options.trustHost ??= !!( - process.env.NEXTAUTH_URL ?? - process.env.VERCEL ?? - process.env.AUTH_TRUST_HOST - ) - - const host = detectHost( - options.trustHost, - req.headers?.get("x-forwarded-host"), - process.env.NEXTAUTH_URL ?? - (process.env.NODE_ENV !== "production" && "http://localhost:3000") - ) - const authPath = parseUrl(host).path - + const authPath = parseUrl(process.env.NEXTAUTH_URL).path const publicPaths = ["/_next", "/favicon.ico"] // Avoid infinite redirects/invalid response @@ -149,8 +118,8 @@ async function handleMiddleware( return } - options.secret ??= process.env.NEXTAUTH_SECRET - if (!options.secret) { + const secret = options?.secret ?? process.env.NEXTAUTH_SECRET + if (!secret) { console.error( `[next-auth][error][NO_SECRET]`, `\nhttps://next-auth.js.org/errors#no_secret` @@ -164,9 +133,9 @@ async function handleMiddleware( const token = await getToken({ req, - decode: options.jwt?.decode, + decode: options?.jwt?.decode, cookieName: options?.cookies?.sessionToken?.name, - secret: options.secret, + secret, }) const isAuthorized = @@ -191,7 +160,7 @@ export interface NextRequestWithAuth extends NextRequest { export type NextMiddlewareWithAuth = ( request: NextRequestWithAuth, event: NextFetchEvent -) => ReturnType +) => NextMiddlewareResult | Promise export type WithAuthArgs = | [NextRequestWithAuth] diff --git a/packages/next-auth/src/next/utils.ts b/packages/next-auth/src/next/utils.ts new file mode 100644 index 0000000000..6e5769a10f --- /dev/null +++ b/packages/next-auth/src/next/utils.ts @@ -0,0 +1,15 @@ +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/react/index.tsx b/packages/next-auth/src/react/index.tsx index e40827a863..bab02ec8a4 100644 --- a/packages/next-auth/src/react/index.tsx +++ b/packages/next-auth/src/react/index.tsx @@ -124,8 +124,7 @@ export function useSession(options?: UseSessionOptions) { React.useEffect(() => { if (requiredAndNotLoading) { - const baseUrl = apiBaseUrl(__NEXTAUTH) - const url = `${baseUrl}/signin?${new URLSearchParams({ + const url = `/api/auth/signin?${new URLSearchParams({ error: "SessionRequired", callbackUrl: window.location.href, })}` @@ -242,13 +241,13 @@ export async function signIn< method: "post", headers: { "Content-Type": "application/x-www-form-urlencoded", - "X-Auth-Return-Redirect": "1", }, // @ts-expect-error body: new URLSearchParams({ ...options, csrfToken: await getCsrfToken(), callbackUrl, + json: true, }), }) @@ -292,11 +291,12 @@ export async function signOut( method: "post", headers: { "Content-Type": "application/x-www-form-urlencoded", - "X-Auth-Return-Redirect": "1", }, + // @ts-expect-error body: new URLSearchParams({ - csrfToken: (await getCsrfToken()) ?? "", + csrfToken: await getCsrfToken(), callbackUrl, + json: true, }), } const res = await fetch(`${baseUrl}/signout`, fetchOptions) diff --git a/packages/next-auth/src/utils/detect-host.ts b/packages/next-auth/src/utils/detect-host.ts new file mode 100644 index 0000000000..8c2d346492 --- /dev/null +++ b/packages/next-auth/src/utils/detect-host.ts @@ -0,0 +1,8 @@ +/** Extract the host from the environment */ +export function detectHost(forwardedHost: any) { + // If we detect a Vercel environment, we can trust the host + if (process.env.VERCEL ?? process.env.AUTH_TRUST_HOST) + return forwardedHost + // If `NEXTAUTH_URL` is `undefined` we fall back to "http://localhost:3000" + return process.env.NEXTAUTH_URL +} diff --git a/packages/next-auth/src/utils/node.ts b/packages/next-auth/src/utils/node.ts deleted file mode 100644 index 608af63cca..0000000000 --- a/packages/next-auth/src/utils/node.ts +++ /dev/null @@ -1,167 +0,0 @@ -import type { IncomingMessage, ServerResponse } 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 full request URL from the environment. - * NOTE: It does not verify if the host should be trusted. - */ -export function getURL(url: string | undefined, headers: Headers): URL | Error { - try { - if (!url) throw new Error("Missing url") - if (process.env.NEXTAUTH_URL) { - const base = new URL(process.env.NEXTAUTH_URL) - if (!["http:", "https:"].includes(base.protocol)) { - throw new Error("Invalid protocol") - } - const hasCustomPath = base.pathname !== "/" - - if (hasCustomPath) { - const apiAuthRe = /\/api\/auth\/?$/ - const basePathname = base.pathname.match(apiAuthRe) - ? base.pathname.replace(apiAuthRe, "") - : base.pathname - return new URL(basePathname.replace(/\/$/, "") + url, base.origin) - } - return new URL(url, base) - } - const proto = - headers.get("x-forwarded-proto") ?? - (process.env.NODE_ENV !== "production" ? "http" : "https") - const host = headers.get("x-forwarded-host") ?? headers.get("host") - if (!["http", "https"].includes(proto)) throw new Error("Invalid protocol") - const origin = `${proto}://${host}` - if (!host) throw new Error("Missing host") - return new URL(url, origin) - } catch (error) { - return error as Error - } -} - -/** - * Set-Cookie header field-values are sometimes comma joined in one string. This splits them without choking on commas - * that are within a single set-cookie field-value, such as in the Expires portion. - * This is uncommon, but explicitly allowed - see https://tools.ietf.org/html/rfc2616#section-4.2 - * Node.js does this for every header *except* set-cookie - see https://github.com/nodejs/node/blob/d5e363b77ebaf1caf67cd7528224b651c86815c1/lib/_http_incoming.js#L128 - * Based on: https://github.com/google/j2objc/commit/16820fdbc8f76ca0c33472810ce0cb03d20efe25 - * Credits to: https://github.com/tomball for original and https://github.com/chrusart for JavaScript implementation - * @source https://github.com/nfriedly/set-cookie-parser/blob/3eab8b7d5d12c8ed87832532861c1a35520cf5b3/lib/set-cookie.js#L144 - */ -function getSetCookies(cookiesString: string) { - if (typeof cookiesString !== "string") { - return [] - } - - const cookiesStrings: string[] = [] - let pos = 0 - let start - let ch - let lastComma: number - let nextStart - let cookiesSeparatorFound - - function skipWhitespace() { - while (pos < cookiesString.length && /\s/.test(cookiesString.charAt(pos))) { - pos += 1 - } - return pos < cookiesString.length - } - - function notSpecialChar() { - ch = cookiesString.charAt(pos) - - return ch !== "=" && ch !== ";" && ch !== "," - } - - while (pos < cookiesString.length) { - start = pos - cookiesSeparatorFound = false - - while (skipWhitespace()) { - ch = cookiesString.charAt(pos) - if (ch === ",") { - // ',' is a cookie separator if we have later first '=', not ';' or ',' - lastComma = pos - pos += 1 - - skipWhitespace() - nextStart = pos - - while (pos < cookiesString.length && notSpecialChar()) { - pos += 1 - } - - // currently special character - if (pos < cookiesString.length && cookiesString.charAt(pos) === "=") { - // we found cookies separator - cookiesSeparatorFound = true - // pos is inside the next cookie, so back up and return it. - pos = nextStart - cookiesStrings.push(cookiesString.substring(start, lastComma)) - start = pos - } else { - // in param ',' or param separator ';', - // we continue from that comma - pos = lastComma + 1 - } - } else { - pos += 1 - } - } - - if (!cookiesSeparatorFound || pos >= cookiesString.length) { - cookiesStrings.push(cookiesString.substring(start, cookiesString.length)) - } - } - - return cookiesStrings -} - -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 - 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) - } -} - -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 49add525b3..6494c097d6 100644 --- a/packages/next-auth/src/utils/parse-url.ts +++ b/packages/next-auth/src/utils/parse-url.ts @@ -11,14 +11,11 @@ export interface InternalUrl { toString: () => string } -/** - * TODO: Can we remove this? - * Returns an `URL` like object to make requests/redirects from server-side - */ -export default function parseUrl(url?: string | URL): InternalUrl { +/** Returns an `URL` like object to make requests/redirects from server-side */ +export default function parseUrl(url?: string): InternalUrl { const defaultUrl = new URL("http://localhost:3000/api/auth") - if (url && !url.toString().startsWith("http")) { + if (url && !url.startsWith("http")) { url = `https://${url}` } diff --git a/packages/next-auth/src/utils/web.ts b/packages/next-auth/src/utils/web.ts deleted file mode 100644 index 14db7c520c..0000000000 --- a/packages/next-auth/src/utils/web.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { serialize, parse as parseCookie } from "cookie" -import { UnknownAction } from "../core/errors" -import type { ResponseInternal, RequestInternal } from "../core" -import type { AuthAction } 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) - } -} - -// prettier-ignore -const actions: AuthAction[] = [ "providers", "session", "csrf", "signin", "signout", "callback", "verify-request", "error", "_log" ] - -export async function toInternalRequest( - req: Request -): Promise { - try { - // TODO: url.toString() should not include action and providerId - // see init.ts - const url = new URL(req.url.replace(/\/$/, "")) - const { pathname } = url - - const action = actions.find((a) => pathname.includes(a)) - if (!action) { - throw new UnknownAction("Cannot detect action.") - } - - const providerIdOrAction = pathname.split("/").pop() - let providerId - if ( - providerIdOrAction && - !action.includes(providerIdOrAction) && - ["signin", "callback"].includes(action) - ) { - providerId = providerIdOrAction - } - - return { - url, - action, - providerId, - method: req.method ?? "GET", - headers: Object.fromEntries(req.headers), - body: req.body ? await readJSONBody(req.body) : undefined, - cookies: parseCookie(req.headers.get("cookie") ?? "") ?? {}, - error: url.searchParams.get("error") ?? undefined, - query: Object.fromEntries(url.searchParams), - } - } catch (error) { - return error - } -} - -export function toResponse(res: ResponseInternal): Response { - const headers = new Headers(res.headers) - - 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 3b8570c27b..794992e9c0 100644 --- a/packages/next-auth/tests/assert.test.ts +++ b/packages/next-auth/tests/assert.test.ts @@ -4,12 +4,12 @@ import { MissingAdapterMethods, MissingSecret, } from "../src/core/errors" -import { handler } from "./utils" +import { handler } from "./lib" import EmailProvider from "../src/providers/email" it("Show error page if secret is not defined", async () => { const { res, log } = await handler( - { providers: [], secret: undefined, trustHost: true }, + { providers: [], secret: undefined }, { prod: true } ) @@ -28,7 +28,6 @@ it("Show error page if adapter is missing functions when using with email", asyn adapter: missingFunctionAdapter, providers: [EmailProvider({ sendVerificationRequest })], secret: "secret", - trustHost: true, }, { prod: true } ) @@ -49,7 +48,6 @@ it("Show error page if adapter is not configured when using with email", async ( { providers: [EmailProvider({ sendVerificationRequest })], secret: "secret", - trustHost: true, }, { prod: true } ) @@ -66,7 +64,7 @@ it("Show error page if adapter is not configured when using with email", async ( it("Should show configuration error page on invalid `callbackUrl`", async () => { const { res, log } = await handler( - { providers: [], trustHost: true }, + { providers: [] }, { prod: true, params: { callbackUrl: "invalid-callback" } } ) @@ -82,7 +80,7 @@ it("Should show configuration error page on invalid `callbackUrl`", async () => it("Allow relative `callbackUrl`", async () => { const { res, log } = await handler( - { providers: [], trustHost: true }, + { providers: [] }, { prod: true, params: { callbackUrl: "/callback" } } ) diff --git a/packages/next-auth/tests/email.test.ts b/packages/next-auth/tests/email.test.ts index 55d5590cc9..6c7e4a2dd9 100644 --- a/packages/next-auth/tests/email.test.ts +++ b/packages/next-auth/tests/email.test.ts @@ -1,9 +1,8 @@ -import { createCSRF, handler, mockAdapter } from "./utils" +import { createCSRF, handler, mockAdapter } from "./lib" 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) @@ -14,13 +13,12 @@ it("Send e-mail to the only address correctly", async () => { providers: [EmailProvider({ sendVerificationRequest })], callbacks: { signIn }, secret, - trustHost: true, }, { path: "signin/email", requestInit: { method: "POST", - headers: { cookie: csrf.cookie, "content-type": "application/json" }, + headers: { cookie: csrf.cookie }, body: JSON.stringify({ email: email, csrfToken: csrf.value }), }, } @@ -55,13 +53,12 @@ it("Send e-mail to first address only", async () => { providers: [EmailProvider({ sendVerificationRequest })], callbacks: { signIn }, secret, - trustHost: true, }, { path: "signin/email", requestInit: { method: "POST", - headers: { cookie: csrf.cookie, "content-type": "application/json" }, + headers: { cookie: csrf.cookie }, body: JSON.stringify({ email: email, csrfToken: csrf.value }), }, } @@ -96,13 +93,12 @@ it("Send e-mail to address with first domain", async () => { providers: [EmailProvider({ sendVerificationRequest })], callbacks: { signIn }, secret, - trustHost: true, }, { path: "signin/email", requestInit: { method: "POST", - headers: { cookie: csrf.cookie, "content-type": "application/json" }, + headers: { cookie: csrf.cookie }, body: JSON.stringify({ email: email, csrfToken: csrf.value }), }, } @@ -143,13 +139,12 @@ it("Redirect to error page if multiple addresses aren't allowed", async () => { }), ], secret, - trustHost: true, }, { path: "signin/email", requestInit: { method: "POST", - headers: { cookie: csrf.cookie, "content-type": "application/json" }, + headers: { cookie: csrf.cookie }, body: JSON.stringify({ email: "email@email.com,email@email2.com", csrfToken: csrf.value, @@ -161,6 +156,7 @@ 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 e169c5ee99..6643fa9bdd 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 "./utils" +import { mockLogger } from "./lib" const originalWarn = console.warn let logger = mockLogger() @@ -84,8 +84,8 @@ describe("Return correct data", () => { it("Should return null if there is no session", async () => { 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))) + // @ts-expect-error + spy.mockReturnValue({ body: {} }) const session = await unstable_getServerSession(req, res, { providers: [], @@ -97,19 +97,21 @@ describe("Return correct data", () => { }) it("Should return the session if one is found", async () => { - const mockedBody = { - user: { - name: "John Doe", - email: "test@example.com", - image: "", - id: "1234", + const mockedResponse = { + body: { + user: { + name: "John Doe", + email: "test@example.com", + image: "", + id: "1234", + }, + expires: "", }, - expires: "", } 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))) + // @ts-expect-error + spy.mockReturnValue(mockedResponse) const session = await unstable_getServerSession(req, res, { providers: [], @@ -117,6 +119,6 @@ describe("Return correct data", () => { secret: "secret", }) - expect(session).toEqual(mockedBody) + expect(session).toEqual(mockedResponse.body) }) }) diff --git a/packages/next-auth/tests/getURL.test.ts b/packages/next-auth/tests/getURL.test.ts deleted file mode 100644 index 61f24f4f3e..0000000000 --- a/packages/next-auth/tests/getURL.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { getURL as getURLOriginal } from "../src/utils/node" - -it("Should return error when missing url", () => { - expect(getURL(undefined, {})).toEqual(new Error("Missing url")) -}) - -it("Should return error when missing host", () => { - expect(getURL("/", {})).toEqual(new Error("Missing host")) -}) - -it("Should return error when invalid protocol", () => { - expect( - getURL("/", { host: "localhost", "x-forwarded-proto": "file" }) - ).toEqual(new Error("Invalid protocol")) -}) - -it("Should return error when invalid host", () => { - expect(getURL("/", { host: "/" })).toEqual( - new TypeError("Invalid base URL: http:///") - ) -}) - -it("Should read host headers", () => { - expect(getURL("/api/auth/session", { host: "localhost" })).toBeURL( - "http://localhost/api/auth/session" - ) - - expect( - getURL("/custom/api/auth/session", { "x-forwarded-host": "localhost:3000" }) - ).toBeURL("http://localhost:3000/custom/api/auth/session") - - // Prefer x-forwarded-host over host - expect( - getURL("/", { host: "localhost", "x-forwarded-host": "localhost:3000" }) - ).toBeURL("http://localhost:3000/") -}) - -it("Should read protocol headers", () => { - expect( - getURL("/", { host: "localhost", "x-forwarded-proto": "http" }) - ).toBeURL("http://localhost/") -}) - -describe("process.env.NEXTAUTH_URL", () => { - afterEach(() => delete process.env.NEXTAUTH_URL) - - it("Should prefer over headers if present", () => { - process.env.NEXTAUTH_URL = "http://localhost:3000" - expect(getURL("/api/auth/session", { host: "localhost" })).toBeURL( - "http://localhost:3000/api/auth/session" - ) - }) - - it("catch errors", () => { - process.env.NEXTAUTH_URL = "invald-url" - expect(getURL("/api/auth/session", {})).toEqual( - new TypeError("Invalid URL: invald-url") - ) - - process.env.NEXTAUTH_URL = "file://localhost" - expect(getURL("/api/auth/session", {})).toEqual( - new TypeError("Invalid protocol") - ) - }) - - it("Supports custom base path", () => { - process.env.NEXTAUTH_URL = "http://localhost:3000/custom/api/auth" - expect(getURL("/api/auth/session", {})).toBeURL( - "http://localhost:3000/custom/api/auth/session" - ) - - // With trailing slash - process.env.NEXTAUTH_URL = "http://localhost:3000/custom/api/auth/" - expect(getURL("/api/auth/session", {})).toBeURL( - "http://localhost:3000/custom/api/auth/session" - ) - - // Multiple custom segments - process.env.NEXTAUTH_URL = "http://localhost:3000/custom/path/api/auth" - expect(getURL("/api/auth/session", {})).toBeURL( - "http://localhost:3000/custom/path/api/auth/session" - ) - - process.env.NEXTAUTH_URL = "http://localhost:3000/custom/path/api/auth/" - expect(getURL("/api/auth/session", {})).toBeURL( - "http://localhost:3000/custom/path/api/auth/session" - ) - - // No /api/auth - process.env.NEXTAUTH_URL = "http://localhost:3000/custom/nextauth" - expect(getURL("/session", {})).toBeURL( - "http://localhost:3000/custom/nextauth/session" - ) - - // No /api/auth, with trailing slash - process.env.NEXTAUTH_URL = "http://localhost:3000/custom/nextauth/" - expect(getURL("/session", {})).toBeURL( - "http://localhost:3000/custom/nextauth/session" - ) - }) -}) - -// Utils - -function getURL( - url: Parameters[0], - headers: HeadersInit -) { - return getURLOriginal(url, new Headers(headers)) -} - -expect.extend({ - toBeURL(rec, exp) { - const r = rec.toString() - const e = exp.toString() - const printR = this.utils.printReceived - const printE = this.utils.printExpected - if (r === e) { - return { - message: () => `expected ${printE(e)} not to be ${printR(r)}`, - pass: true, - } - } - return { - message: () => `expected ${printE(e)}, got ${printR(r)}`, - pass: false, - } - }, -}) - -declare global { - // eslint-disable-next-line @typescript-eslint/no-namespace - namespace jest { - interface Matchers { - toBeURL: (expected: string) => R - } - } -} diff --git a/packages/next-auth/tests/lib.ts b/packages/next-auth/tests/lib.ts new file mode 100644 index 0000000000..2c6405acb0 --- /dev/null +++ b/packages/next-auth/tests/lib.ts @@ -0,0 +1,68 @@ +import { createHash } from "crypto" +import { AuthHandler } from "../src/core" +import type { LoggerInstance, AuthOptions } 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: AuthOptions, + { 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 AuthHandler({ + 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(() => {}), + } as unknown as Adapter + return adapter +} diff --git a/packages/next-auth/tests/middleware.test.ts b/packages/next-auth/tests/middleware.test.ts index b09cc4d3d3..b8a390ee35 100644 --- a/packages/next-auth/tests/middleware.test.ts +++ b/packages/next-auth/tests/middleware.test.ts @@ -1,69 +1,95 @@ -import { NextMiddleware, NextRequest } from "next/server" +import { NextMiddleware } from "next/server" import { NextAuthMiddlewareOptions, withAuth } from "../src/next/middleware" it("should not match pages as public paths", async () => { const options: NextAuthMiddlewareOptions = { - pages: { signIn: "/", error: "/" }, + pages: { + signIn: "/", + error: "/", + }, secret: "secret", } - const handleMiddleware = withAuth(options) as NextMiddleware - const response = await handleMiddleware( - new NextRequest("http://127.0.0.1/protected/pathA"), - null as any - ) + const nextUrl: any = { + pathname: "/protected/pathA", + search: "", + origin: "http://127.0.0.1", + } + const req: any = { nextUrl, headers: { authorization: "" } } - expect(response?.status).toBe(307) - expect(response?.headers.get("location")).toBe( - "http://localhost/?callbackUrl=%2Fprotected%2FpathA" - ) + const handleMiddleware = withAuth(options) as NextMiddleware + const res = await handleMiddleware(req, null as any) + expect(res).toBeDefined() + expect(res?.status).toBe(307) }) it("should not redirect on public paths", async () => { - const options: NextAuthMiddlewareOptions = { secret: "secret" } - - const req = new NextRequest("http://127.0.0.1/_next/foo") + const options: NextAuthMiddlewareOptions = { + secret: "secret", + } + const nextUrl: any = { + pathname: "/_next/foo", + search: "", + origin: "http://127.0.0.1", + } + const req: any = { nextUrl, headers: { authorization: "" } } const handleMiddleware = withAuth(options) as NextMiddleware const res = await handleMiddleware(req, null as any) expect(res).toBeUndefined() }) -it("should respect NextURL#basePath when redirecting", async () => { - const options: NextAuthMiddlewareOptions = { secret: "secret" } +it("should redirect according to nextUrl basePath", async () => { + const options: NextAuthMiddlewareOptions = { + secret: "secret" + } + const nextUrl: any = { + pathname: "/protected/pathA", + search: "", + origin: "http://127.0.0.1", + basePath: "/custom-base-path", + } + const req: any = { nextUrl, headers: { authorization: "" } } + + const handleMiddleware = withAuth(options) as NextMiddleware + const res = await handleMiddleware(req, null as any) + expect(res).toBeDefined() + expect(res.status).toEqual(307) + expect(res.headers.get('location')).toContain("http://127.0.0.1/custom-base-path/api/auth/signin?callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA") +}) + +it("should redirect according to nextUrl basePath", async () => { + // given + const options: NextAuthMiddlewareOptions = { + secret: "secret" + } const handleMiddleware = withAuth(options) as NextMiddleware - const response1 = await handleMiddleware( - { - nextUrl: { - pathname: "/protected/pathA", - search: "", - origin: "http://127.0.0.1", - basePath: "/custom-base-path", - }, - } as unknown as NextRequest, - null as any - ) - expect(response1?.status).toEqual(307) - expect(response1?.headers.get("location")).toBe( - "http://127.0.0.1/custom-base-path/api/auth/signin?callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA" - ) + // when + const res = await handleMiddleware({ + nextUrl: { + pathname: "/protected/pathA", + search: "", + origin: "http://127.0.0.1", + basePath: "/custom-base-path" + }, headers: { authorization: "" } + } as any, null as any) - // Should not redirect when invoked on sign in page + // then + expect(res).toBeDefined() + expect(res.status).toEqual(307) + expect(res.headers.get("location")).toContain("http://127.0.0.1/custom-base-path/api/auth/signin?callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA") - const response2 = await handleMiddleware( - { - nextUrl: { - pathname: "/api/auth/signin", - searchParams: new URLSearchParams({ - callbackUrl: "/custom-base-path/protected/pathA", - }), - origin: "http://127.0.0.1", - basePath: "/custom-base-path", - }, - } as unknown as NextRequest, - null as any - ) + // and when follow redirect + const resFromRedirectedUrl = await handleMiddleware({ + nextUrl: { + pathname: "/api/auth/signin", + search: "callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA", + origin: "http://127.0.0.1", + basePath: "/custom-base-path" + }, headers: { authorization: "" } + } as any, null as any) - expect(response2).toBeUndefined() + // then return sign in page + expect(resFromRedirectedUrl).toBeUndefined() }) diff --git a/packages/next-auth/tests/next.test.ts b/packages/next-auth/tests/next.test.ts deleted file mode 100644 index 85d7be4bf1..0000000000 --- a/packages/next-auth/tests/next.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { mockReqRes, nextHandler } from "./utils" - -it("Missing req.url throws in dev", async () => { - await expect(nextHandler).rejects.toThrow(new Error("Missing url")) -}) - -const configErrorMessage = - "There is a problem with the server configuration. Check the server logs for more information." - -it("Missing req.url returns config error in prod", async () => { - // @ts-expect-error - process.env.NODE_ENV = "production" - const { res, logger } = await nextHandler() - - expect(logger.error).toBeCalledTimes(1) - const error = new Error("Missing url") - expect(logger.error).toBeCalledWith("INVALID_URL", error) - - expect(res.status).toBeCalledWith(400) - expect(res.json).toBeCalledWith({ message: configErrorMessage }) - - // @ts-expect-error - process.env.NODE_ENV = "test" -}) - -it("Missing host throws in dev", async () => { - await expect( - async () => - await nextHandler({ - req: { query: { nextauth: ["session"] } }, - }) - ).rejects.toThrow(Error) -}) - -it("Missing host config error in prod", async () => { - // @ts-expect-error - process.env.NODE_ENV = "production" - const { res, logger } = await nextHandler({ - req: { query: { nextauth: ["session"] } }, - }) - expect(res.status).toBeCalledWith(400) - expect(res.json).toBeCalledWith({ message: configErrorMessage }) - - expect(logger.error).toBeCalledWith("INVALID_URL", new Error("Missing url")) - // @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 nextHandler({ - 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 nextHandler({ - 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 nextHandler({ - req: { - url: "/api/auth/session", - headers: { host: "http://localhost" }, - }, - 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" -}) - -it("Use process.env.NEXTAUTH_URL for host if present", async () => { - process.env.NEXTAUTH_URL = "http://localhost" - 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 nextHandler({ - req: { - method: "post", - url: "/api/auth/signin/github", - }, - }) - expect(res.status).toBeCalledWith(302) - 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 nextHandler({ - req: { - method: "post", - url: "/api/auth/signin/github", - headers: { "X-Auth-Return-Redirect": "1" }, - }, - }) - - expect(res.status).toBeCalledWith(200) - - 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" }) - ) -}) diff --git a/packages/next-auth/tests/pkce-handler.test.ts b/packages/next-auth/tests/pkce-handler.test.ts index 45bfddaaf3..a780853b01 100644 --- a/packages/next-auth/tests/pkce-handler.test.ts +++ b/packages/next-auth/tests/pkce-handler.test.ts @@ -1,20 +1,15 @@ -import { mockLogger } from "./utils" +import { mockLogger } from "./lib" 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" -import { CredentialInput } from "../src/providers" let logger: LoggerInstance let url: InternalUrl @@ -42,6 +37,9 @@ beforeEach(() => { signinUrl: "/", callbackUrl: "/", checks: ["pkce", "state"], + profile() { + return { id: "", name: "", email: "" } + }, } jwt = { @@ -56,35 +54,16 @@ beforeEach(() => { } callbacks = { - signIn: function (params: { - user: User - account: Account - profile: Profile & Record - email: { verificationRequest?: boolean | undefined } - credentials?: Record | undefined - }): Awaitable { + signIn: function () { throw new Error("Function not implemented.") }, - redirect: function (params: { - url: string - baseUrl: string - }): Awaitable { + redirect: function () { throw new Error("Function not implemented.") }, - session: function (params: { - session: Session - user: User - token: JWT - }): Awaitable { + session: function () { 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 () { throw new Error("Function not implemented.") }, } @@ -100,12 +79,20 @@ beforeEach(() => { options = { url, + adapter: undefined, action: "session", provider, secret: "", debug: false, logger, - session: { strategy: "jwt", maxAge: 0, updateAge: 0 }, + session: { + strategy: "jwt", + maxAge: 0, + updateAge: 0, + generateSessionToken() { + return "" + }, + }, pages: {}, jwt, events: {}, diff --git a/packages/next-auth/tests/state-handler.test.ts b/packages/next-auth/tests/state-handler.test.ts index 38bdc4ab86..b8f9b5f834 100644 --- a/packages/next-auth/tests/state-handler.test.ts +++ b/packages/next-auth/tests/state-handler.test.ts @@ -1,20 +1,14 @@ -import { mockLogger } from "./utils" +import { mockLogger } from "./lib" 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" -import { CredentialInput } from "../src/providers" +import { JWTOptions, encode, decode } from "../src/jwt" let logger: LoggerInstance let url: InternalUrl @@ -42,6 +36,9 @@ beforeEach(() => { signinUrl: "/", callbackUrl: "/", checks: ["pkce", "state"], + profile() { + return { id: "", name: "", email: "" } + }, } jwt = { @@ -52,35 +49,16 @@ beforeEach(() => { } callbacks = { - signIn: function (params: { - user: User - account: Account - profile: Profile & Record - email: { verificationRequest?: boolean | undefined } - credentials?: Record | undefined - }): Awaitable { + signIn: function () { throw new Error("Function not implemented.") }, - redirect: function (params: { - url: string - baseUrl: string - }): Awaitable { + redirect: function () { throw new Error("Function not implemented.") }, - session: function (params: { - session: Session - user: User - token: JWT - }): Awaitable { + session: function () { 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 () { throw new Error("Function not implemented.") }, } @@ -96,12 +74,20 @@ beforeEach(() => { options = { url, + adapter: undefined, action: "session", provider, secret: "", debug: false, logger, - session: { strategy: "jwt", maxAge: 0, updateAge: 0 }, + session: { + strategy: "jwt", + maxAge: 0, + updateAge: 0, + generateSessionToken() { + return "" + }, + }, pages: {}, jwt, events: {}, diff --git a/packages/next-auth/tests/utils.ts b/packages/next-auth/tests/utils.ts deleted file mode 100644 index 8e55e20bc1..0000000000 --- a/packages/next-auth/tests/utils.ts +++ /dev/null @@ -1,224 +0,0 @@ -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 { - 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: AuthOptions, - { 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 nextHandler( - params: { - req?: Partial - res?: Partial - options?: Partial - } = {} -) { - let req = params.req - // @ts-expect-error - let res: NextApiResponse = params.res - if (!params.res) { - ;({ req, res } = mockReqRes(params.req)) - } - - const logger = mockLogger() - // @ts-expect-error - await NextAuth(req, res, { - providers: [], - secret: "secret", - logger, - ...params.options, - }) - - return { req, res, logger } -} - -export function mockReqRes(req?: Partial): { - 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)) -}