diff --git a/apps/dev/pages/api/auth/[...nextauth].ts b/apps/dev/pages/api/auth/[...nextauth].ts
index 8991defbde..cb8335569a 100644
--- a/apps/dev/pages/api/auth/[...nextauth].ts
+++ b/apps/dev/pages/api/auth/[...nextauth].ts
@@ -31,7 +31,7 @@ import Slack from "next-auth-core/providers/slack"
import Spotify from "next-auth-core/providers/spotify"
import Trakt from "next-auth-core/providers/trakt"
import Twitch from "next-auth-core/providers/twitch"
-import Twitter, { TwitterLegacy } from "next-auth-core/providers/twitter"
+import Twitter from "next-auth-core/providers/twitter"
import Vk from "next-auth-core/providers/vk"
import Wikimedia from "next-auth-core/providers/wikimedia"
import WorkOS from "next-auth-core/providers/workos"
@@ -113,7 +113,7 @@ export const authOptions: AuthOptions = {
Spotify({ clientId: process.env.SPOTIFY_ID, clientSecret: process.env.SPOTIFY_SECRET }),
Trakt({ clientId: process.env.TRAKT_ID, clientSecret: process.env.TRAKT_SECRET }),
Twitch({ clientId: process.env.TWITCH_ID, clientSecret: process.env.TWITCH_SECRET }),
- Twitter({ version: "2.0", clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET }),
+ Twitter({ clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET }),
// TwitterLegacy({ clientId: process.env.TWITTER_LEGACY_ID, clientSecret: process.env.TWITTER_LEGACY_SECRET }),
Vk({ clientId: process.env.VK_ID, clientSecret: process.env.VK_SECRET }),
Wikimedia({ clientId: process.env.WIKIMEDIA_ID, clientSecret: process.env.WIKIMEDIA_SECRET }),
@@ -132,25 +132,24 @@ if (authOptions.adapter) {
// TODO: move to next-auth/edge
function Auth(...args: any[]) {
- if (args.length === 1)
- return async (req: Request) => {
- args[0].secret ??= process.env.NEXTAUTH_SECRET
-
- // TODO: remove when `next-auth/react` sends `X-Auth-Return-Redirect`
- const shouldRedirect = req.method === "POST" && req.headers.get("Content-Type") === "application/json" ? (await req.clone().json()).json : false
-
- // TODO: This can be directly in core
- const res = await AuthHandler(req, args[0])
- if (req.headers.get("X-Auth-Return-Redirect") || shouldRedirect) {
- const url = res.headers.get("Location")
- res.headers.delete("Location")
- return new Response(JSON.stringify({ url }), res)
- }
- return res
+ const envSecret = process.env.NEXTAUTH_SECRET
+ const envTrustHost = !!(process.env.NEXTAUTH_URL ?? process.env.AUTH_TRUST_HOST ?? process.env.VERCEL ?? process.env.NODE_ENV !== "production")
+ if (args.length === 1) {
+ return (req: Request) => {
+ args[0].secret ??= envSecret
+ args[0].trustHost ??= envTrustHost
+ return AuthHandler(req, args[0])
}
+ }
+ args[1].secret ??= envSecret
+ args[1].trustHost ??= envTrustHost
return AuthHandler(args[0], args[1])
}
-export default Auth(authOptions)
+// export default Auth(authOptions)
+
+export default function handle(request: Request) {
+ return Auth(request, authOptions)
+}
export const config = { runtime: "experimental-edge" }
diff --git a/docs/docs/tutorials.md b/docs/docs/tutorials.md
index d9b37e0f3a..f6548a17f5 100644
--- a/docs/docs/tutorials.md
+++ b/docs/docs/tutorials.md
@@ -46,6 +46,10 @@ title: Tutorials and Explainers
- Learn how to use Sign-In With Ethereum to authenticate your users with their existing Ethereum wallets - identifiers they personally control.
- Example application: [spruceid/siwe-next-auth-example](https://github.com/spruceid/siwe-next-auth-example)
+#### [Next.js Authentication with Okta and NextAuth.js 4.0](https://thetombomb.com/posts/nextjs-nextauth-okta)
+
+- Learn how to perform authentication with an OIDC Application in Okta and NextAuth.js.
+
## Fullstack
#### [Build a FullStack App with Next.js, NextAuth.js, Supabase & Prisma](https://themodern.dev/courses/build-a-fullstack-app-with-nextjs-supabase-and-prisma-322389284337222224)
diff --git a/docs/versioned_docs/version-beta/guides/09-resources.md b/docs/versioned_docs/version-beta/guides/09-resources.md
index 9387ca16f7..5547d78f71 100644
--- a/docs/versioned_docs/version-beta/guides/09-resources.md
+++ b/docs/versioned_docs/version-beta/guides/09-resources.md
@@ -14,6 +14,10 @@ If you did not find a guide or tutorial covering your use case, please [open an
- How to restrict access to pages and API routes.
- [Usage with class components](/tutorials/usage-with-class-components)
- How to use `useSession()` hook with class components.
+- [Next.js Authentication with Okta and NextAuth.js 4.0](https://thetombomb.com/posts/nextjs-nextauth-okta)
+ - Learn how to perform authentication with an OIDC Application in Okta and NextAuth.js.
+
+
### Advanced
diff --git a/packages/adapter-sequelize/package.json b/packages/adapter-sequelize/package.json
index 0fbbea6833..c436fb950a 100644
--- a/packages/adapter-sequelize/package.json
+++ b/packages/adapter-sequelize/package.json
@@ -1,6 +1,6 @@
{
"name": "@next-auth/sequelize-adapter",
- "version": "1.0.6",
+ "version": "1.0.7",
"description": "Sequelize adapter for next-auth.",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth",
@@ -42,4 +42,4 @@
"jest": {
"preset": "@next-auth/adapter-test/jest"
}
-}
+}
\ No newline at end of file
diff --git a/packages/adapter-sequelize/src/models.ts b/packages/adapter-sequelize/src/models.ts
index eb5da7f028..c0d38b1fa4 100644
--- a/packages/adapter-sequelize/src/models.ts
+++ b/packages/adapter-sequelize/src/models.ts
@@ -14,7 +14,7 @@ export const Account = {
expires_at: { type: DataTypes.INTEGER },
token_type: { type: DataTypes.STRING },
scope: { type: DataTypes.STRING },
- id_token: { type: DataTypes.STRING },
+ id_token: { type: DataTypes.TEXT },
session_state: { type: DataTypes.STRING },
userId: { type: DataTypes.UUID },
}
diff --git a/packages/core/package.json b/packages/core/package.json
index 99deb972f9..5e55f358da 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -44,7 +44,8 @@
}
},
"scripts": {
- "build": "tsc && pnpm css",
+ "build": "pnpm clean && tsc && pnpm css",
+ "clean": "rm -rf dist",
"css": "node ./scripts/generate-css.js",
"dev": "pnpm css && tsc -w",
"test": "jest"
diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts
index 2e3df56f62..1f569103ed 100644
--- a/packages/core/src/errors.ts
+++ b/packages/core/src/errors.ts
@@ -1,4 +1,4 @@
-import type { EventCallbacks, LoggerInstance } from "."
+import type { EventCallbacks, LoggerInstance } from "./types"
/**
* Same as the default `Error`, but it is JSON serializable.
@@ -76,6 +76,15 @@ export class InvalidEndpoints extends UnknownError {
name = "InvalidEndpoints"
code = "INVALID_ENDPOINTS_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
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 6341f179f1..c20c071877 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -8,9 +8,13 @@ import logger, { setLogger } from "./utils/logger"
import type { ErrorType } from "./pages/error"
import type { AuthOptions, RequestInternal, ResponseInternal } from "./types"
+import { UntrustedHost } from "./errors"
export * from "./types"
+const configErrorMessage =
+ "There is a problem with the server configuration. Check the server logs for more information."
+
async function AuthHandlerInternal<
Body extends string | Record | any[]
>(params: {
@@ -19,10 +23,9 @@ async function AuthHandlerInternal<
/** 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 { options: authOptions, req } = params
- const assertionResult = assertConfig({ options: userOptions, req })
+ const assertionResult = assertConfig({ options: authOptions, req })
if (Array.isArray(assertionResult)) {
assertionResult.forEach(logger.warn)
@@ -32,18 +35,13 @@ 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 } as any,
+ body: { message: configErrorMessage } as any,
}
}
-
- // We can throw in development to surface the issue in the browser too.
- if (process.env.NODE_ENV === "development") throw assertionResult
-
- const { pages, theme } = userOptions
+ const { pages, theme } = authOptions
const authOnErrorPage =
pages?.error && req.query?.callbackUrl?.startsWith(pages.error)
@@ -66,13 +64,13 @@ async function AuthHandlerInternal<
}
}
- const { action, providerId, error, method = "GET" } = req
+ const { action, providerId, error, method } = req
const { options, cookies } = await init({
- userOptions,
+ authOptions,
action,
providerId,
- host: req.host,
+ url: req.url,
callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl,
csrfToken: req.body?.csrfToken,
cookies: req.cookies,
@@ -216,7 +214,7 @@ async function AuthHandlerInternal<
}
break
case "_log":
- if (userOptions.logger) {
+ if (authOptions.logger) {
try {
const { code, level, ...metadata } = req.body ?? {}
logger[level](code, metadata)
@@ -245,7 +243,41 @@ 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 })
- return toResponse(internalResponse)
+
+ 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/core/src/init.ts b/packages/core/src/init.ts
index 9f6b804900..ad014c6900 100644
--- a/packages/core/src/init.ts
+++ b/packages/core/src/init.ts
@@ -12,8 +12,8 @@ import parseUrl from "./utils/parse-url"
import type { AuthOptions, InternalOptions, RequestInternal } from "."
interface InitParams {
- host?: string
- userOptions: AuthOptions
+ url: URL
+ authOptions: AuthOptions
providerId?: string
action: InternalOptions["action"]
/** Callback URL value extracted from the incoming request. */
@@ -27,10 +27,10 @@ interface InitParams {
/** Initialize all internal options and cookies. */
export async function init({
- userOptions,
+ authOptions,
providerId,
action,
- host,
+ url: reqUrl,
cookies: reqCookies,
callbackUrl: reqCallbackUrl,
csrfToken: reqCsrfToken,
@@ -39,7 +39,12 @@ export async function init({
options: InternalOptions
cookies: cookie.Cookie[]
}> {
- const url = parseUrl(host)
+ // TODO: move this to web.ts
+ const parsed = parseUrl(
+ reqUrl.origin +
+ reqUrl.pathname.replace(`/${action}`, "").replace(`/${providerId}`, "")
+ )
+ const url = new URL(parsed.toString())
/**
* Secret used to salt cookies and tokens (e.g. for CSRF protection).
@@ -49,15 +54,14 @@ export async function init({
* If no secret provided in production, we throw an error.
*/
const secret =
- userOptions.secret ??
+ authOptions.secret ??
// TODO: Remove this, always ask the user for a secret, even in dev! (Fix assert.ts too)
- (await createHash(JSON.stringify({ ...url, ...userOptions })))
+ (await createHash(JSON.stringify({ ...url, ...authOptions })))
const { providers, provider } = parseProviders({
- providers: userOptions.providers,
+ providers: authOptions.providers,
url,
providerId,
- runtime: userOptions.__internal__?.runtime,
})
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default
@@ -74,7 +78,7 @@ export async function init({
buttonText: "",
},
// Custom options override defaults
- ...userOptions,
+ ...authOptions,
// These computed settings can have values in userOptions but we override them
// and are request-specific.
url,
@@ -83,21 +87,21 @@ export async function init({
provider,
cookies: {
...cookie.defaultCookies(
- userOptions.useSecureCookies ?? url.base.startsWith("https://")
+ authOptions.useSecureCookies ?? url.protocol === "https:"
),
// Allow user cookie options to override any cookie settings above
- ...userOptions.cookies,
+ ...authOptions.cookies,
},
secret,
providers,
// Session options
session: {
// If no adapter specified, force use of JSON Web Tokens (stateless)
- strategy: userOptions.adapter ? "database" : "jwt",
+ strategy: authOptions.adapter ? "database" : "jwt",
maxAge,
updateAge: 24 * 60 * 60,
generateSessionToken: crypto.randomUUID,
- ...userOptions.session,
+ ...authOptions.session,
},
// JWT options
jwt: {
@@ -105,13 +109,13 @@ export async function init({
maxAge, // same as session maxAge,
encode: jwt.encode,
decode: jwt.decode,
- ...userOptions.jwt,
+ ...authOptions.jwt,
},
// Event messages
- events: eventsErrorHandler(userOptions.events ?? {}, logger),
- adapter: adapterErrorHandler(userOptions.adapter, logger),
+ events: eventsErrorHandler(authOptions.events ?? {}, logger),
+ adapter: adapterErrorHandler(authOptions.adapter, logger),
// Callback functions
- callbacks: { ...defaultCallbacks, ...userOptions.callbacks },
+ callbacks: { ...defaultCallbacks, ...authOptions.callbacks },
logger,
callbackUrl: url.origin,
}
diff --git a/packages/core/src/lib/assert.ts b/packages/core/src/lib/assert.ts
index b9e68cd103..fdd6177f41 100644
--- a/packages/core/src/lib/assert.ts
+++ b/packages/core/src/lib/assert.ts
@@ -8,7 +8,6 @@ import {
MissingSecret,
UnsupportedStrategy,
} from "../errors"
-import parseUrl from "../utils/parse-url"
import { defaultCookies } from "./cookie"
import type { AuthOptions, RequestInternal } from ".."
@@ -48,11 +47,11 @@ export function assertConfig(params: {
req: RequestInternal
}): ConfigError | WarningCode[] {
const { options, req } = params
-
+ const { url } = req
const warnings: WarningCode[] = []
if (!warned) {
- if (!req.host) warnings.push("NEXTAUTH_URL")
+ if (!url.origin) 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")
@@ -74,21 +73,19 @@ export function assertConfig(params: {
const callbackUrlParam = req.query?.callbackUrl as string | undefined
- const url = parseUrl(req.host)
-
- if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.base)) {
+ if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.origin)) {
return new InvalidCallbackUrl(
`Invalid callback URL. Received: ${callbackUrlParam}`
)
}
const { callbackUrl: defaultCallbackUrl } = defaultCookies(
- options.useSecureCookies ?? url.base.startsWith("https://")
+ options.useSecureCookies ?? url.protocol === "https://"
)
const callbackUrlCookie =
req.cookies?.[options.cookies?.callbackUrl?.name ?? defaultCallbackUrl.name]
- if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.base)) {
+ if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.origin)) {
return new InvalidCallbackUrl(
`Invalid callback URL. Received: ${callbackUrlCookie}`
)
diff --git a/packages/core/src/lib/providers.ts b/packages/core/src/lib/providers.ts
index 9cbc5baef3..80f4e0b41e 100644
--- a/packages/core/src/lib/providers.ts
+++ b/packages/core/src/lib/providers.ts
@@ -8,7 +8,6 @@ import type {
OAuthUserConfig,
Provider,
} from "../providers"
-import type { InternalUrl } from "../utils/parse-url"
/**
* Adds `signinUrl` and `callbackUrl` to each provider
@@ -16,9 +15,8 @@ import type { InternalUrl } from "../utils/parse-url"
*/
export default function parseProviders(params: {
providers: Provider[]
- url: InternalUrl
+ url: URL
providerId?: string
- runtime?: "web" | "nodejs"
}): {
providers: InternalProvider[]
provider?: InternalProvider
diff --git a/packages/core/src/lib/web.ts b/packages/core/src/lib/web.ts
index 1ffbb32ad7..123b6b61dd 100644
--- a/packages/core/src/lib/web.ts
+++ b/packages/core/src/lib/web.ts
@@ -1,5 +1,7 @@
import { parse as parseCookie, serialize } from "cookie"
-import type { AuthAction, RequestInternal, ResponseInternal } from ".."
+import type { RequestInternal, ResponseInternal } from ".."
+import { UnknownAction } from "../errors"
+import type { AuthAction } from "../types"
async function getBody(req: Request): Promise | undefined> {
if (!("body" in req) || !req.body || req.method !== "POST") return
@@ -12,31 +14,46 @@ async function getBody(req: Request): Promise | undefined> {
return Object.fromEntries(params)
}
}
+// prettier-ignore
+const actions: AuthAction[] = [ "providers", "session", "csrf", "signin", "signout", "callback", "verify-request", "error", "_log" ]
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)
+): 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 cookieHeader = req.headers.get("cookie") ?? ""
- const cookies =
- parseCookie(
- Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader
- ) ?? {}
+ const action = actions.find((a) => pathname.includes(a))
+ if (!action) {
+ throw new UnknownAction("Cannot detect action.")
+ }
- return {
- action: nextauth[0] as AuthAction,
- method: req.method,
- headers,
- body: req.body ? await getBody(req) : undefined,
- cookies: cookies,
- providerId: nextauth[1],
- error: url.searchParams.get("error") ?? undefined,
- host: new URL(req.url).origin,
- query,
+ 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 getBody(req) : undefined,
+ cookies: parseCookie(req.headers.get("cookie") ?? "") ?? {},
+ error: url.searchParams.get("error") ?? undefined,
+ query: Object.fromEntries(url.searchParams),
+ }
+ } catch (error) {
+ return error
}
}
@@ -46,8 +63,11 @@ export function toResponse(res: ResponseInternal): Response {
res.cookies?.forEach((cookie) => {
const { name, value, options } = cookie
const cookieHeader = serialize(name, value, options)
- // FIXME: Should be .append. Cannot set multiple cookies right now.
- headers.set("Set-Cookie", cookieHeader)
+ if (headers.has("Set-Cookie")) {
+ headers.append("Set-Cookie", cookieHeader)
+ } else {
+ headers.set("Set-Cookie", cookieHeader)
+ }
})
const body =
diff --git a/packages/core/src/pages/error.tsx b/packages/core/src/pages/error.tsx
index a119bd98bb..38b4a8405f 100644
--- a/packages/core/src/pages/error.tsx
+++ b/packages/core/src/pages/error.tsx
@@ -1,5 +1,4 @@
import type { Theme } from ".."
-import type { InternalUrl } from "../utils/parse-url"
/**
* The following errors are passed as error query parameters to the default or overridden error page.
@@ -12,7 +11,7 @@ export type ErrorType =
| "verification"
export interface ErrorProps {
- url?: InternalUrl
+ url?: URL
theme?: Theme
error?: ErrorType
}
diff --git a/packages/core/src/pages/signin.tsx b/packages/core/src/pages/signin.tsx
index db36c3f352..bae1d4634b 100644
--- a/packages/core/src/pages/signin.tsx
+++ b/packages/core/src/pages/signin.tsx
@@ -102,19 +102,22 @@ export default function SigninPage(props: SignInServerPageParams) {
} as CSSProperties
}
>
-
-
-
+ {provider.style?.logo && (
+
+ )}
+ {provider.style?.logoDark && (
+
+ )}
Sign in with {provider.name}
diff --git a/packages/core/src/pages/signout.tsx b/packages/core/src/pages/signout.tsx
index 1d8de05411..2108d3fcf5 100644
--- a/packages/core/src/pages/signout.tsx
+++ b/packages/core/src/pages/signout.tsx
@@ -1,8 +1,7 @@
import type { Theme } from ".."
-import type { InternalUrl } from "../utils/parse-url"
export interface SignoutProps {
- url: InternalUrl
+ url: URL
csrfToken: string
theme: Theme
}
diff --git a/packages/core/src/pages/verify-request.tsx b/packages/core/src/pages/verify-request.tsx
index 91b378ab4b..fdd827a0a5 100644
--- a/packages/core/src/pages/verify-request.tsx
+++ b/packages/core/src/pages/verify-request.tsx
@@ -1,8 +1,7 @@
import type { Theme } from ".."
-import type { InternalUrl } from "../utils/parse-url"
interface VerifyRequestPageProps {
- url: InternalUrl
+ url: URL
theme: Theme
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index ff9f8fa85f..1cd8f5ef9e 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -15,7 +15,6 @@ import type {
import type { JWT, JWTOptions } from "./jwt"
import type { Cookie } from "./lib/cookie"
import type { LoggerInstance } from "./utils/logger"
-import type { InternalUrl } from "./utils/parse-url"
export type Awaitable = T | PromiseLike
@@ -211,7 +210,7 @@ export interface AuthOptions {
* - ⚠ **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.AUTH_TRUST_HOST ?? process.env.VERCEL)
+ * @default Boolean(process.env.NEXTAUTH_URL ?? process.env.AUTH_TRUST_HOST ?? process.env.VERCEL)
*/
trustHost?: boolean
/** @internal */
@@ -532,8 +531,7 @@ export type AuthAction =
/** @internal */
export interface RequestInternal {
- /** @default "http://localhost:3000" */
- host?: string
+ url: URL
method?: string
cookies?: Partial>
headers?: Record
@@ -561,11 +559,7 @@ export interface InternalOptions<
WithVerificationToken = TProviderType extends "email" ? true : false
> {
providers: InternalProvider[]
- /**
- * Parsed from `NEXTAUTH_URL` or `x-forwarded-host` on Vercel.
- * @default "http://localhost:3000/api/auth"
- */
- url: InternalUrl
+ url: URL
action: AuthAction
provider: InternalProvider
csrfToken?: string
diff --git a/packages/core/src/utils/parse-url.ts b/packages/core/src/utils/parse-url.ts
index 7f63b0ada2..e8e4bca913 100644
--- a/packages/core/src/utils/parse-url.ts
+++ b/packages/core/src/utils/parse-url.ts
@@ -1,4 +1,4 @@
-export interface InternalUrl {
+interface InternalUrl {
/** @default "http://localhost:3000" */
origin: string
/** @default "localhost:3000" */
diff --git a/packages/next-auth/package.json b/packages/next-auth/package.json
index e04283006d..719fff0f4f 100644
--- a/packages/next-auth/package.json
+++ b/packages/next-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "next-auth",
- "version": "4.18.1",
+ "version": "4.18.6",
"description": "Authentication for Next.js",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git",
diff --git a/packages/next-auth/src/core/errors.ts b/packages/next-auth/src/core/errors.ts
index 7bb30fabfa..a52d0b1fc7 100644
--- a/packages/next-auth/src/core/errors.ts
+++ b/packages/next-auth/src/core/errors.ts
@@ -76,6 +76,15 @@ export class InvalidEndpoints extends UnknownError {
name = "InvalidEndpoints"
code = "INVALID_ENDPOINTS_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
diff --git a/packages/next-auth/src/core/index.ts b/packages/next-auth/src/core/index.ts
index b0bfe86cee..f027fbe6b7 100644
--- a/packages/next-auth/src/core/index.ts
+++ b/packages/next-auth/src/core/index.ts
@@ -1,19 +1,21 @@
import logger, { setLogger } from "../utils/logger"
-import { toInternalRequest, toResponse } from "./lib/web"
-import * as routes from "./routes"
-import renderPage from "./pages"
+import { toInternalRequest, toResponse } from "../utils/web"
import { init } from "./init"
import { assertConfig } from "./lib/assert"
import { SessionStore } from "./lib/cookie"
+import renderPage from "./pages"
+import * as routes from "./routes"
-import type { AuthAction, AuthOptions } from "./types"
+import { UntrustedHost } from "./errors"
import type { Cookie } from "./lib/cookie"
import type { ErrorType } from "./pages/error"
+import type { AuthAction, AuthOptions } from "./types"
+/** @internal */
export interface RequestInternal {
- /** @default "http://localhost:3000" */
- host?: string
- method?: string
+ url: URL
+ /** @default "GET" */
+ method: string
cookies?: Partial>
headers?: Record
query?: Record
@@ -23,16 +25,20 @@ export interface RequestInternal {
error?: string
}
+/** @internal */
export interface ResponseInternal<
Body extends string | Record | any[] = any
> {
status?: number
- headers?: Headers | HeadersInit
+ headers?: Record
body?: Body
redirect?: URL | string // TODO: refactor to only allow URL
cookies?: Cookie[]
}
+const configErrorMessage =
+ "There is a problem with the server configuration. Check the server logs for more information."
+
async function AuthHandlerInternal<
Body extends string | Record | any[]
>(params: {
@@ -41,10 +47,9 @@ async function AuthHandlerInternal<
/** 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 { options: authOptions, req } = params
- const assertionResult = assertConfig({ options: userOptions, req })
+ const assertionResult = assertConfig({ options: authOptions, req })
if (Array.isArray(assertionResult)) {
assertionResult.forEach(logger.warn)
@@ -54,18 +59,13 @@ 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 } as any,
+ body: { message: configErrorMessage } as any,
}
}
-
- // We can throw in development to surface the issue in the browser too.
- if (process.env.NODE_ENV === "development") throw assertionResult
-
- const { pages, theme } = userOptions
+ const { pages, theme } = authOptions
const authOnErrorPage =
pages?.error && req.query?.callbackUrl?.startsWith(pages.error)
@@ -88,13 +88,13 @@ async function AuthHandlerInternal<
}
}
- const { action, providerId, error, method = "GET" } = req
+ const { action, providerId, error, method } = req
const { options, cookies } = await init({
- userOptions,
+ authOptions,
action,
providerId,
- host: req.host,
+ url: req.url,
callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl,
csrfToken: req.body?.csrfToken,
cookies: req.cookies,
@@ -238,7 +238,7 @@ async function AuthHandlerInternal<
}
break
case "_log":
- if (userOptions.logger) {
+ if (authOptions.logger) {
try {
const { code, level, ...metadata } = req.body ?? {}
logger[level](code, metadata)
@@ -267,15 +267,41 @@ 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)
- const redirect = response.headers.get("Location")
+
// If the request expects a return URL, send it as JSON
// instead of doing an actual redirect.
- if (request.headers.get("X-Auth-Return-Redirect") && redirect) {
+ const redirect = response.headers.get("Location")
+ if (request.headers.has("X-Auth-Return-Redirect") && redirect) {
response.headers.delete("Location")
- return new Response(JSON.stringify({ url: redirect }), response)
+ 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 2ec8b8c7b0..804a8bc337 100644
--- a/packages/next-auth/src/core/init.ts
+++ b/packages/next-auth/src/core/init.ts
@@ -1,7 +1,6 @@
import { createHash, randomUUID } from "./lib/web"
import { AuthOptions } from ".."
import logger from "../utils/logger"
-import parseUrl from "../utils/parse-url"
import { adapterErrorHandler, eventsErrorHandler } from "./errors"
import parseProviders from "./lib/providers"
import * as cookie from "./lib/cookie"
@@ -12,10 +11,11 @@ import { createCallbackUrl } from "./lib/callback-url"
import { RequestInternal } from "."
import type { InternalOptions } from "./types"
+import parseUrl from "../utils/parse-url"
interface InitParams {
- host?: string
- userOptions: AuthOptions
+ url: URL
+ authOptions: AuthOptions
providerId?: string
action: InternalOptions["action"]
/** Callback URL value extracted from the incoming request. */
@@ -29,10 +29,10 @@ interface InitParams {
/** Initialize all internal options and cookies. */
export async function init({
- userOptions,
+ authOptions,
providerId,
action,
- host,
+ url: reqUrl,
cookies: reqCookies,
callbackUrl: reqCallbackUrl,
csrfToken: reqCsrfToken,
@@ -41,7 +41,12 @@ export async function init({
options: InternalOptions
cookies: cookie.Cookie[]
}> {
- const url = parseUrl(host)
+ // TODO: move this to web.ts
+ const parsed = parseUrl(
+ reqUrl.origin +
+ reqUrl.pathname.replace(`/${action}`, "").replace(`/${providerId}`, "")
+ )
+ const url = new URL(parsed.toString())
/**
* Secret used to salt cookies and tokens (e.g. for CSRF protection).
@@ -51,15 +56,14 @@ export async function init({
* If no secret provided in production, we throw an error.
*/
const secret =
- userOptions.secret ??
+ authOptions.secret ??
// TODO: Remove this, always ask the user for a secret, even in dev! (Fix assert.ts too)
- (await createHash(JSON.stringify({ ...url, ...userOptions })))
+ (await createHash(JSON.stringify({ ...url, ...authOptions })))
const { providers, provider } = parseProviders({
- providers: userOptions.providers,
+ providers: authOptions.providers,
url,
providerId,
- runtime: userOptions.__internal__?.runtime,
})
const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default
@@ -76,7 +80,7 @@ export async function init({
buttonText: "",
},
// Custom options override defaults
- ...userOptions,
+ ...authOptions,
// These computed settings can have values in userOptions but we override them
// and are request-specific.
url,
@@ -85,21 +89,21 @@ export async function init({
provider,
cookies: {
...cookie.defaultCookies(
- userOptions.useSecureCookies ?? url.base.startsWith("https://")
+ authOptions.useSecureCookies ?? url.protocol === "https:"
),
// Allow user cookie options to override any cookie settings above
- ...userOptions.cookies,
+ ...authOptions.cookies,
},
secret,
providers,
// Session options
session: {
// If no adapter specified, force use of JSON Web Tokens (stateless)
- strategy: userOptions.adapter ? "database" : "jwt",
+ strategy: authOptions.adapter ? "database" : "jwt",
maxAge,
updateAge: 24 * 60 * 60,
generateSessionToken: randomUUID,
- ...userOptions.session,
+ ...authOptions.session,
},
// JWT options
jwt: {
@@ -107,13 +111,13 @@ export async function init({
maxAge, // same as session maxAge,
encode: jwt.encode,
decode: jwt.decode,
- ...userOptions.jwt,
+ ...authOptions.jwt,
},
// Event messages
- events: eventsErrorHandler(userOptions.events ?? {}, logger),
- adapter: adapterErrorHandler(userOptions.adapter, logger),
+ events: eventsErrorHandler(authOptions.events ?? {}, logger),
+ adapter: adapterErrorHandler(authOptions.adapter, logger),
// Callback functions
- callbacks: { ...defaultCallbacks, ...userOptions.callbacks },
+ callbacks: { ...defaultCallbacks, ...authOptions.callbacks },
logger,
callbackUrl: url.origin,
}
diff --git a/packages/next-auth/src/core/lib/assert.ts b/packages/next-auth/src/core/lib/assert.ts
index 23e348e0e6..37fad8dfe0 100644
--- a/packages/next-auth/src/core/lib/assert.ts
+++ b/packages/next-auth/src/core/lib/assert.ts
@@ -8,7 +8,6 @@ import {
InvalidEndpoints,
UnsupportedStrategy,
} from "../errors"
-import parseUrl from "../../utils/parse-url"
import { defaultCookies } from "./cookie"
import type { RequestInternal } from ".."
@@ -49,11 +48,11 @@ export function assertConfig(params: {
req: RequestInternal
}): ConfigError | WarningCode[] {
const { options, req } = params
-
+ const { url } = req
const warnings: WarningCode[] = []
if (!warned) {
- if (!req.host) warnings.push("NEXTAUTH_URL")
+ if (!url.origin) 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")
@@ -75,21 +74,19 @@ export function assertConfig(params: {
const callbackUrlParam = req.query?.callbackUrl as string | undefined
- const url = parseUrl(req.host)
-
- if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.base)) {
+ if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.origin)) {
return new InvalidCallbackUrl(
`Invalid callback URL. Received: ${callbackUrlParam}`
)
}
const { callbackUrl: defaultCallbackUrl } = defaultCookies(
- options.useSecureCookies ?? url.base.startsWith("https://")
+ options.useSecureCookies ?? url.protocol === "https://"
)
const callbackUrlCookie =
req.cookies?.[options.cookies?.callbackUrl?.name ?? defaultCallbackUrl.name]
- if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.base)) {
+ if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.origin)) {
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 6cbf0a45d4..9d6b8768cb 100644
--- a/packages/next-auth/src/core/lib/providers.ts
+++ b/packages/next-auth/src/core/lib/providers.ts
@@ -8,7 +8,6 @@ import type {
OAuthUserConfig,
OAuthEndpointType,
} from "../../providers"
-import type { InternalUrl } from "../../utils/parse-url"
/**
* Adds `signinUrl` and `callbackUrl` to each provider
@@ -16,7 +15,7 @@ import type { InternalUrl } from "../../utils/parse-url"
*/
export default function parseProviders(params: {
providers: Provider[]
- url: InternalUrl
+ url: URL
providerId?: string
runtime?: "web" | "nodejs"
}): {
diff --git a/packages/next-auth/src/core/lib/web.ts b/packages/next-auth/src/core/lib/web.ts
index f66b47bd33..488fddc4e3 100644
--- a/packages/next-auth/src/core/lib/web.ts
+++ b/packages/next-auth/src/core/lib/web.ts
@@ -26,7 +26,7 @@ export async function toInternalRequest(
cookies: cookies,
providerId: nextauth[1],
error: url.searchParams.get("error") ?? undefined,
- host: new URL(req.url).origin,
+ url,
query,
}
}
diff --git a/packages/next-auth/src/core/pages/error.tsx b/packages/next-auth/src/core/pages/error.tsx
index e3f5562e57..b2b803b38f 100644
--- a/packages/next-auth/src/core/pages/error.tsx
+++ b/packages/next-auth/src/core/pages/error.tsx
@@ -1,5 +1,4 @@
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.
@@ -12,7 +11,7 @@ export type ErrorType =
| "verification"
export interface ErrorProps {
- url?: InternalUrl
+ url?: URL
theme?: Theme
error?: ErrorType
}
diff --git a/packages/next-auth/src/core/pages/signout.tsx b/packages/next-auth/src/core/pages/signout.tsx
index 352d825753..3d986a1040 100644
--- a/packages/next-auth/src/core/pages/signout.tsx
+++ b/packages/next-auth/src/core/pages/signout.tsx
@@ -1,8 +1,7 @@
import { Theme } from "../.."
-import { InternalUrl } from "../../utils/parse-url"
export interface SignoutProps {
- url: InternalUrl
+ url: URL
csrfToken: string
theme: Theme
}
diff --git a/packages/next-auth/src/core/types.ts b/packages/next-auth/src/core/types.ts
index 0b6650624a..09d065bf6d 100644
--- a/packages/next-auth/src/core/types.ts
+++ b/packages/next-auth/src/core/types.ts
@@ -12,8 +12,6 @@ import type { JWT, JWTOptions } from "../jwt"
import type { LoggerInstance } from "../utils/logger"
import type { CookieSerializeOptions } from "cookie"
-import type { InternalUrl } from "../utils/parse-url"
-
export type Awaitable = T | PromiseLike
export type { LoggerInstance }
@@ -208,7 +206,7 @@ export interface AuthOptions {
* - ⚠ **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.AUTH_TRUST_HOST ?? process.env.VERCEL)
+ * @default Boolean(process.env.NEXTAUTH_URL ?? process.env.AUTH_TRUST_HOST ?? process.env.VERCEL)
*/
trustHost?: boolean
/** @internal */
@@ -530,11 +528,7 @@ export interface InternalOptions<
WithVerificationToken = TProviderType extends "email" ? true : false
> {
providers: InternalProvider[]
- /**
- * Parsed from `NEXTAUTH_URL` or `x-forwarded-host` on Vercel.
- * @default "http://localhost:3000/api/auth"
- */
- url: InternalUrl
+ url: URL
action: AuthAction
provider: InternalProvider
csrfToken?: string
diff --git a/packages/next-auth/src/jwt/index.ts b/packages/next-auth/src/jwt/index.ts
index 03df8e8a10..aec2e0492a 100644
--- a/packages/next-auth/src/jwt/index.ts
+++ b/packages/next-auth/src/jwt/index.ts
@@ -94,7 +94,7 @@ export async function getToken(
const authorizationHeader =
req.headers instanceof Headers
? req.headers.get("authorization")
- : req.headers.authorization
+ : req.headers?.authorization
if (!token && authorizationHeader?.split(" ")[0] === "Bearer") {
const urlEncodedToken = authorizationHeader.split(" ")[1]
diff --git a/packages/next-auth/src/next/index.ts b/packages/next-auth/src/next/index.ts
index 72917ecbc2..5cf52c1779 100644
--- a/packages/next-auth/src/next/index.ts
+++ b/packages/next-auth/src/next/index.ts
@@ -1,6 +1,6 @@
import "./inject-globals"
import { AuthHandler } from "../core"
-import { getBody, getURL } from "../utils/node"
+import { getBody, getURL, setHeaders } from "../utils/node"
import type {
GetServerSidePropsContext,
@@ -15,37 +15,36 @@ async function NextAuthHandler(
res: NextApiResponse,
options: AuthOptions
) {
- 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 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: new Headers(req.headers as any),
+ headers,
method: req.method,
...getBody(req),
})
options.secret ??= options.jwt?.secret ?? process.env.NEXTAUTH_SECRET
- const response = await AuthHandler(request, options)
- const { status, headers } = response
- res.status(status)
-
- for (const [key, val] of headers.entries()) {
- const value = key === "set-cookie" ? val.split(",") : val
- res.setHeader(key, value)
- }
+ options.trustHost ??= !!(
+ process.env.NEXTAUTH_URL ??
+ process.env.AUTH_TRUST_HOST ??
+ process.env.VERCEL ??
+ process.env.NODE_ENV !== "production"
+ )
- // If the request expects a return URL, send it as JSON
- // instead of doing an actual redirect.
- const redirect = headers.get("Location")
- if (req.headers["x-auth-return-redirect"] && redirect) {
- res.removeHeader("Location")
- return res.json({ url: redirect })
- }
+ const response = await AuthHandler(request, options)
+ res.status(response.status)
+ setHeaders(response.headers, res)
return res.send(await response.text())
}
@@ -138,26 +137,31 @@ export async function unstable_getServerSession<
options = Object.assign({}, args[2], { providers: [] })
}
- const urlOrError = getURL(
- "/api/auth/session",
- options.trustHost,
- req.headers["x-forwarded-host"] ?? req.headers.host
- )
+ 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.",
+ })
+ }
- if (urlOrError instanceof Error) throw urlOrError
+ const request = new Request(url, { headers: new Headers(req.headers) })
options.secret ??= process.env.NEXTAUTH_SECRET
- const response = await AuthHandler(
- new Request(urlOrError, { headers: req.headers }),
- options
- )
+ options.trustHost = true
+ const response = await AuthHandler(request, options)
const { status = 200, headers } = response
- for (const [key, val] of headers.entries()) {
- const value = key === "set-cookie" ? val.split(",") : val
- res.setHeader(key, value)
- }
+ setHeaders(headers, res)
+
+ // This would otherwise break rendering
+ // with `getServerSideProps` that needs to always return HTML
+ res.removeHeader?.("Content-Type")
const data = await response.json()
diff --git a/packages/next-auth/src/next/middleware.ts b/packages/next-auth/src/next/middleware.ts
index 38ad5efaf5..f9dfe1c9c3 100644
--- a/packages/next-auth/src/next/middleware.ts
+++ b/packages/next-auth/src/next/middleware.ts
@@ -6,7 +6,17 @@ import { NextResponse, NextRequest } from "next/server"
import { getToken } from "../jwt"
import parseUrl from "../utils/parse-url"
-import { getURL } from "../utils/node"
+
+// // 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
@@ -113,18 +123,19 @@ async function handleMiddleware(
const signInPage = options?.pages?.signIn ?? "/api/auth/signin"
const errorPage = options?.pages?.error ?? "/api/auth/error"
- options.trustHost = Boolean(
- options.trustHost ?? process.env.VERCEL ?? process.env.AUTH_TRUST_HOST
+ options.trustHost ??= !!(
+ process.env.NEXTAUTH_URL ??
+ process.env.VERCEL ??
+ process.env.AUTH_TRUST_HOST
)
- let authPath
- const url = getURL(
- null,
+ const host = detectHost(
options.trustHost,
- req.headers.get("x-forwarded-host") ?? req.headers.get("host")
+ req.headers?.get("x-forwarded-host"),
+ process.env.NEXTAUTH_URL ??
+ (process.env.NODE_ENV !== "production" && "http://localhost:3000")
)
- if (url instanceof URL) authPath = parseUrl(url).path
- else authPath = "/api/auth"
+ const authPath = parseUrl(host).path
const publicPaths = ["/_next", "/favicon.ico"]
diff --git a/packages/next-auth/src/react/index.tsx b/packages/next-auth/src/react/index.tsx
index 9a6aa17a47..b1a3bdaa8f 100644
--- a/packages/next-auth/src/react/index.tsx
+++ b/packages/next-auth/src/react/index.tsx
@@ -292,9 +292,8 @@ export async function signOut(
"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,
}),
}
diff --git a/packages/next-auth/src/utils/node.ts b/packages/next-auth/src/utils/node.ts
index 92a5796362..eccdcef15b 100644
--- a/packages/next-auth/src/utils/node.ts
+++ b/packages/next-auth/src/utils/node.ts
@@ -1,4 +1,4 @@
-import type { IncomingMessage } from "http"
+import type { IncomingMessage, ServerResponse } from "http"
import type { GetServerSidePropsContext, NextApiRequest } from "next"
export function setCookie(res, value: string) {
@@ -30,31 +30,135 @@ export function getBody(
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 {
+/**
+ * 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 {
- let host =
- process.env.NEXTAUTH_URL ??
- (process.env.NODE_ENV !== "production" && "http://localhost:3000")
+ 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 (trusted && forwardedValue) {
- host = Array.isArray(forwardedValue) ? forwardedValue[0] : forwardedValue
+ 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)
}
-
- if (!host) throw new TypeError("Invalid host")
-
- return new URL(url ?? "", new URL(host))
+ 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 {
diff --git a/packages/next-auth/src/utils/parse-url.ts b/packages/next-auth/src/utils/parse-url.ts
index 7f63b0ada2..49add525b3 100644
--- a/packages/next-auth/src/utils/parse-url.ts
+++ b/packages/next-auth/src/utils/parse-url.ts
@@ -11,7 +11,10 @@ export interface InternalUrl {
toString: () => string
}
-/** Returns an `URL` like object to make requests/redirects from server-side */
+/**
+ * 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 {
const defaultUrl = new URL("http://localhost:3000/api/auth")
diff --git a/packages/next-auth/src/utils/web.ts b/packages/next-auth/src/utils/web.ts
index b57644f5f1..048b2a9dc2 100644
--- a/packages/next-auth/src/utils/web.ts
+++ b/packages/next-auth/src/utils/web.ts
@@ -1,4 +1,5 @@
import { serialize, parse as parseCookie } from "cookie"
+import { UnknownAction } from "../core/errors"
import type { ResponseInternal, RequestInternal } from "../core"
import type { AuthAction } from "../core/types"
@@ -41,30 +42,46 @@ async function readJSONBody(
}
}
+// prettier-ignore
+const actions: AuthAction[] = [ "providers", "session", "csrf", "signin", "signout", "callback", "verify-request", "error", "_log" ]
+
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 AuthAction,
- 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,
+): 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
}
}
diff --git a/packages/next-auth/tests/assert.test.ts b/packages/next-auth/tests/assert.test.ts
index 33e5680079..3b8570c27b 100644
--- a/packages/next-auth/tests/assert.test.ts
+++ b/packages/next-auth/tests/assert.test.ts
@@ -9,7 +9,7 @@ import EmailProvider from "../src/providers/email"
it("Show error page if secret is not defined", async () => {
const { res, log } = await handler(
- { providers: [], secret: undefined },
+ { providers: [], secret: undefined, trustHost: true },
{ prod: true }
)
@@ -28,6 +28,7 @@ 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 }
)
@@ -48,6 +49,7 @@ it("Show error page if adapter is not configured when using with email", async (
{
providers: [EmailProvider({ sendVerificationRequest })],
secret: "secret",
+ trustHost: true,
},
{ prod: true }
)
@@ -64,7 +66,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: [] },
+ { providers: [], trustHost: true },
{ prod: true, params: { callbackUrl: "invalid-callback" } }
)
@@ -80,7 +82,7 @@ it("Should show configuration error page on invalid `callbackUrl`", async () =>
it("Allow relative `callbackUrl`", async () => {
const { res, log } = await handler(
- { providers: [] },
+ { providers: [], trustHost: true },
{ prod: true, params: { callbackUrl: "/callback" } }
)
diff --git a/packages/next-auth/tests/email.test.ts b/packages/next-auth/tests/email.test.ts
index c98356f147..55d5590cc9 100644
--- a/packages/next-auth/tests/email.test.ts
+++ b/packages/next-auth/tests/email.test.ts
@@ -14,6 +14,7 @@ it("Send e-mail to the only address correctly", async () => {
providers: [EmailProvider({ sendVerificationRequest })],
callbacks: { signIn },
secret,
+ trustHost: true,
},
{
path: "signin/email",
@@ -54,6 +55,7 @@ it("Send e-mail to first address only", async () => {
providers: [EmailProvider({ sendVerificationRequest })],
callbacks: { signIn },
secret,
+ trustHost: true,
},
{
path: "signin/email",
@@ -94,6 +96,7 @@ it("Send e-mail to address with first domain", async () => {
providers: [EmailProvider({ sendVerificationRequest })],
callbacks: { signIn },
secret,
+ trustHost: true,
},
{
path: "signin/email",
@@ -140,6 +143,7 @@ it("Redirect to error page if multiple addresses aren't allowed", async () => {
}),
],
secret,
+ trustHost: true,
},
{
path: "signin/email",
diff --git a/packages/next-auth/tests/getURL.test.ts b/packages/next-auth/tests/getURL.test.ts
new file mode 100644
index 0000000000..61f24f4f3e
--- /dev/null
+++ b/packages/next-auth/tests/getURL.test.ts
@@ -0,0 +1,138 @@
+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/middleware.test.ts b/packages/next-auth/tests/middleware.test.ts
index 569e9301e7..b09cc4d3d3 100644
--- a/packages/next-auth/tests/middleware.test.ts
+++ b/packages/next-auth/tests/middleware.test.ts
@@ -6,91 +6,64 @@ it("should not match pages as public paths", async () => {
pages: { signIn: "/", error: "/" },
secret: "secret",
}
+ const handleMiddleware = withAuth(options) as NextMiddleware
- const req = new NextRequest("http://127.0.0.1/protected/pathA", {
- headers: { authorization: "" },
- })
+ const response = await handleMiddleware(
+ new NextRequest("http://127.0.0.1/protected/pathA"),
+ null as any
+ )
- const handleMiddleware = withAuth(options) as NextMiddleware
- const res = await handleMiddleware(req, null as any)
- expect(res).toBeDefined()
- expect(res?.status).toBe(307)
+ expect(response?.status).toBe(307)
+ expect(response?.headers.get("location")).toBe(
+ "http://localhost/?callbackUrl=%2Fprotected%2FpathA"
+ )
})
it("should not redirect on public paths", async () => {
const options: NextAuthMiddlewareOptions = { secret: "secret" }
- const req = new NextRequest("http://127.0.0.1/_next/foo", {
- headers: { authorization: "" },
- })
+ const req = new NextRequest("http://127.0.0.1/_next/foo")
const handleMiddleware = withAuth(options) as NextMiddleware
const res = await handleMiddleware(req, null as any)
expect(res).toBeUndefined()
})
-it("should redirect according to nextUrl basePath", async () => {
- const options: NextAuthMiddlewareOptions = { secret: "secret" }
-
- const req = {
- nextUrl: {
- pathname: "/protected/pathA",
- search: "",
- origin: "http://127.0.0.1",
- basePath: "/custom-base-path",
- },
- headers: new Headers({ authorization: "" }),
- }
-
- const handleMiddleware = withAuth(options) as NextMiddleware
- const res = await handleMiddleware(req as NextRequest, 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
+it("should respect NextURL#basePath when redirecting", async () => {
const options: NextAuthMiddlewareOptions = { secret: "secret" }
-
const handleMiddleware = withAuth(options) as NextMiddleware
- const req1 = {
- nextUrl: {
- pathname: "/protected/pathA",
- search: "",
- origin: "http://127.0.0.1",
- basePath: "/custom-base-path",
- },
- headers: new Headers({ authorization: "" }),
- }
- // when
- const res = await handleMiddleware(req1 as NextRequest, null as any)
-
- // then
- expect(res).toBeDefined()
- expect(res?.status).toEqual(307)
- expect(res?.headers.get("location")).toContain(
+ 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"
)
- const req2 = {
- nextUrl: {
- pathname: "/api/auth/signin",
- search: "callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA",
- origin: "http://127.0.0.1",
- basePath: "/custom-base-path",
- },
- headers: new Headers({ authorization: "" }),
- }
- // and when follow redirect
- const resFromRedirectedUrl = await handleMiddleware(
- req2 as NextRequest,
+ // Should not redirect when invoked on sign in page
+
+ 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
)
- // then return sign in page
- expect(resFromRedirectedUrl).toBeUndefined()
+ expect(response2).toBeUndefined()
})
diff --git a/packages/next-auth/tests/next.test.ts b/packages/next-auth/tests/next.test.ts
index 92e9a87889..85d7be4bf1 100644
--- a/packages/next-auth/tests/next.test.ts
+++ b/packages/next-auth/tests/next.test.ts
@@ -1,28 +1,47 @@
-import { MissingAPIRoute } from "../src/core/errors"
-import { nodeHandler } from "./utils"
+import { mockReqRes, nextHandler } from "./utils"
-it("Missing req.url throws MISSING_NEXTAUTH_API_ROUTE_ERROR", async () => {
- const { res, logger } = await nodeHandler()
+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(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.",
- })
+ 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 throws 400 in production", async () => {
+it("Missing host config error in prod", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
- const { res } = await nodeHandler()
+ 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"
})
@@ -30,7 +49,7 @@ it("Missing host throws 400 in production", async () => {
it("Defined host throws 400 in production if not trusted", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
- const { res } = await nodeHandler({
+ const { res } = await nextHandler({
req: { headers: { host: "http://localhost" } },
})
expect(res.status).toBeCalledWith(400)
@@ -41,7 +60,7 @@ it("Defined host throws 400 in production if not trusted", async () => {
it("Defined host throws 400 in production if trusted but invalid URL", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
- const { res } = await nodeHandler({
+ const { res } = await nextHandler({
req: { headers: { host: "localhost" } },
options: { trustHost: true },
})
@@ -53,7 +72,7 @@ it("Defined host throws 400 in production if trusted but invalid URL", async ()
it("Defined host does not throw in production if trusted and valid URL", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
- const { res } = await nodeHandler({
+ const { res } = await nextHandler({
req: {
url: "/api/auth/session",
headers: { host: "http://localhost" },
@@ -61,6 +80,7 @@ it("Defined host does not throw in production if trusted and valid URL", async (
options: { trustHost: true },
})
expect(res.status).toBeCalledWith(200)
+ // @ts-expect-error
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
// @ts-expect-error
process.env.NODE_ENV = "test"
@@ -68,25 +88,92 @@ it("Defined host does not throw in production if trusted and valid URL", async (
it("Use process.env.NEXTAUTH_URL for host if present", async () => {
process.env.NEXTAUTH_URL = "http://localhost"
- const { res } = await nodeHandler({
+ const { res } = await nextHandler({
req: { url: "/api/auth/session" },
})
expect(res.status).toBeCalledWith(200)
+ // @ts-expect-error
expect(JSON.parse(res.send.mock.calls[0][0])).toEqual({})
})
it("Redirects if necessary", async () => {
process.env.NEXTAUTH_URL = "http://localhost"
- const { res } = await nodeHandler({
+ const { res } = await nextHandler({
req: {
method: "post",
url: "/api/auth/signin/github",
- 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",
+ 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/utils.ts b/packages/next-auth/tests/utils.ts
index 366c111c39..8e55e20bc1 100644
--- a/packages/next-auth/tests/utils.ts
+++ b/packages/next-auth/tests/utils.ts
@@ -1,11 +1,14 @@
-import { createHash } from "crypto"
-import { AuthHandler } from "../src/core"
-import type { LoggerInstance, AuthOptions } from "../src"
+import { createHash } from "node:crypto"
+import { IncomingMessage, ServerResponse } from "node:http"
+import { Socket } from "node:net"
+import type { AuthOptions, LoggerInstance } from "../src"
import type { Adapter } from "../src/adapters"
+import { AuthHandler } from "../src/core"
import NextAuth from "../src/next"
import type { NextApiRequest, NextApiResponse } from "next"
+import { Stream } from "node:stream"
export function mockLogger(): Record {
return {
@@ -79,38 +82,143 @@ export function mockAdapter(): Adapter {
return adapter
}
-export async function nodeHandler(
+export async function nextHandler(
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(),
+ let req = params.req
+ // @ts-expect-error
+ let res: NextApiResponse = params.res
+ if (!params.res) {
+ ;({ req, res } = mockReqRes(params.req))
}
const logger = mockLogger()
-
- await NextAuth(req as any, res as any, {
+ // @ts-expect-error
+ await NextAuth(req, res, {
providers: [],
secret: "secret",
logger,
...params.options,
})
+
return { req, res, logger }
}
+
+export function mockReqRes(req?: Partial): {
+ 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))
+}