Skip to content

Commit

Permalink
refactor(core): use standard Request and Response (#4769)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
balazsorban44 authored Dec 3, 2022
1 parent c142413 commit 7e91d7d
Show file tree
Hide file tree
Showing 17 changed files with 588 additions and 305 deletions.
76 changes: 25 additions & 51 deletions packages/next-auth/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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" */
Expand All @@ -29,6 +28,7 @@ export interface NextAuthHeader {
value: string
}

// TODO: Rename to `ResponseInternal`
export interface OutgoingResponse<
Body extends string | Record<string, any> | any[] = any
> {
Expand All @@ -39,56 +39,15 @@ export interface OutgoingResponse<
cookies?: Cookie[]
}

export interface NextAuthHandlerParams {
req: Request | RequestInternal
options: NextAuthOptions
}

async function getBody(req: Request): Promise<Record<string, any> | undefined> {
try {
return await req.json()
} catch {}
}

// TODO:
async function toInternalRequest(
req: RequestInternal | Request,
trustHost: boolean = false
): Promise<RequestInternal> {
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<string, any> = 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<string, any> | any[]
>(params: NextAuthHandlerParams): Promise<OutgoingResponse<Body>> {
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<OutgoingResponse<Body>> {
const { options: userOptions, req } = params
setLogger(userOptions.logger, userOptions.debug)

const assertionResult = assertConfig({ options: userOptions, req })
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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<Response> {
const req = await toInternalRequest(request)
const internalResponse = await AuthHandlerInternal({ req, options })
return toResponse(internalResponse)
}
2 changes: 0 additions & 2 deletions packages/next-auth/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
export * from "./core/types"

export type { RequestInternal, OutgoingResponse } from "./core"

export * from "./next"
export { default } from "./next"
137 changes: 56 additions & 81 deletions packages/next-auth/src/next/index.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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

This comment has been minimized.

Copy link
@pekarja5

pekarja5 Dec 8, 2022

This results in breaking cookies as the Expire part of the cookie can contain commas
Example: ... Expires=Sat, 07 Jan 2023 10:07:35 GMT; ... would be split in two parts that would be rejected as the cookie would be incomplete.

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
Expand All @@ -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
Expand All @@ -93,7 +81,7 @@ let experimentalWarningShown = false
let experimentalRSCWarningShown = false

type GetServerSessionOptions = Partial<Omit<NextAuthOptions, "callbacks">> & {
callbacks?: Omit<NextAuthOptions['callbacks'], "session"> & {
callbacks?: Omit<NextAuthOptions["callbacks"], "session"> & {
session?: (...args: Parameters<CallbacksOptions["session"]>) => any
}
}
Expand Down Expand Up @@ -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<Session | {} | string>({
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)
}
30 changes: 14 additions & 16 deletions packages/next-auth/src/next/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<NextMiddleware> | void // eslint-disable-line @typescript-eslint/no-invalid-void-type

async function handleMiddleware(
req: NextRequest,
options: NextAuthMiddlewareOptions | undefined = {},
onSuccess?: (token: JWT | null) => Promise<NextMiddlewareResult>
onSuccess?: (token: JWT | null) => ReturnType<NextMiddleware>
) {
const { pathname, search, origin, basePath } = req.nextUrl

Expand All @@ -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
Expand All @@ -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`
Expand All @@ -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 =
Expand All @@ -182,7 +180,7 @@ export interface NextRequestWithAuth extends NextRequest {
export type NextMiddlewareWithAuth = (
request: NextRequestWithAuth,
event: NextFetchEvent
) => NextMiddlewareResult | Promise<NextMiddlewareResult>
) => ReturnType<NextMiddleware>

export type WithAuthArgs =
| [NextRequestWithAuth]
Expand Down
Loading

0 comments on commit 7e91d7d

Please sign in to comment.