From e5bfbed2c4d03657a3e09d4381be556b27e4bf66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Mon, 12 Dec 2022 15:57:24 +0100 Subject: [PATCH] Sync (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(core): properly construct url (#5984) * chore(release): bump package version(s) [skip ci] * fix(core): add protocol if missing * fix(core): throw error if no action can be determined * test(core): fix test * chore(release): bump package version(s) [skip ci] * chore(docs): add new tutorial (#5604) Co-authored-by: Nico Domino * fix(core): handle `Request` -> `Response` regressions (#5991) * fix(next): don't override `Content-Type` by `unstable_getServerSession` * fix(core): handle `,` while setting `set-cookie` * chore(release): bump package version(s) [skip ci] * fix(sequelize): increase sequelize `id_token` column length (#5929) Co-authored-by: Nico Domino * fix(core): correct status code when returning redirects (#6004) * fix(core): correctly set status when returning redirect * update tests * forward other headers * update test * remove default 200 status * fix(core): host detection/NEXTAUTH_URL (#6007) * rename `host` to `origin` internally * rename `userOptions` to `authOptions` internally * use object for `headers` internally * default `method` to GET * simplify `unstable_getServerSession` * allow optional headers * revert middleware * wip getURL * revert host detection * use old `detectHost` * fix/add some tests wip * move more to core, refactor getURL * better type auth actions * fix custom path support (w/ api/auth) * add `getURL` tests * fix email tests * fix assert tests * custom base without api/auth, with trailing slash * remove parseUrl from assert.ts * return 400 when wrong url * fix tests * refactor * fix protocol in dev * fix tests * fix custom url handling * add todo comments * chore(release): bump package version(s) [skip ci] * update lock file * fix(next): correctly bundle next-auth/middleware fixes #6025 * fix(core): preserve incoming set cookies (#6029) * fix(core): preserve `set-cookie` by the user * add test * improve req/res mocking * refactor * fix comment typo * chore(release): bump package version(s) [skip ci] * make logos optional * sync with `next-auth` * clean up `next-auth/edge` * sync Co-authored-by: Balázs Orbán Co-authored-by: Thomas Desmond <24610108+thomas-desmond@users.noreply.github.com> Co-authored-by: Nico Domino Co-authored-by: Cyril Perraud --- apps/dev/pages/api/auth/[...nextauth].ts | 35 ++-- docs/docs/tutorials.md | 4 + .../version-beta/guides/09-resources.md | 4 + packages/adapter-sequelize/package.json | 4 +- packages/adapter-sequelize/src/models.ts | 2 +- packages/core/package.json | 3 +- packages/core/src/errors.ts | 11 +- packages/core/src/index.ts | 62 +++++-- packages/core/src/init.ts | 40 ++--- packages/core/src/lib/assert.ts | 13 +- packages/core/src/lib/providers.ts | 4 +- packages/core/src/lib/web.ts | 66 +++++--- packages/core/src/pages/error.tsx | 3 +- packages/core/src/pages/signin.tsx | 29 ++-- packages/core/src/pages/signout.tsx | 3 +- packages/core/src/pages/verify-request.tsx | 3 +- packages/core/src/types.ts | 12 +- packages/core/src/utils/parse-url.ts | 2 +- packages/next-auth/package.json | 2 +- packages/next-auth/src/core/errors.ts | 9 ++ packages/next-auth/src/core/index.ts | 76 ++++++--- packages/next-auth/src/core/init.ts | 42 ++--- packages/next-auth/src/core/lib/assert.ts | 13 +- packages/next-auth/src/core/lib/providers.ts | 3 +- packages/next-auth/src/core/lib/web.ts | 2 +- packages/next-auth/src/core/pages/error.tsx | 3 +- packages/next-auth/src/core/pages/signout.tsx | 3 +- packages/next-auth/src/core/types.ts | 10 +- packages/next-auth/src/jwt/index.ts | 2 +- packages/next-auth/src/next/index.ts | 80 ++++----- packages/next-auth/src/next/middleware.ts | 29 ++-- packages/next-auth/src/react/index.tsx | 3 +- packages/next-auth/src/utils/node.ts | 140 +++++++++++++--- packages/next-auth/src/utils/parse-url.ts | 5 +- packages/next-auth/src/utils/web.ts | 61 ++++--- packages/next-auth/tests/assert.test.ts | 8 +- packages/next-auth/tests/email.test.ts | 4 + packages/next-auth/tests/getURL.test.ts | 138 ++++++++++++++++ packages/next-auth/tests/middleware.test.ts | 103 +++++------- packages/next-auth/tests/next.test.ts | 139 +++++++++++++--- packages/next-auth/tests/utils.ts | 152 +++++++++++++++--- 41 files changed, 933 insertions(+), 394 deletions(-) create mode 100644 packages/next-auth/tests/getURL.test.ts 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) External + +- 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) External 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)) +}