Compare commits

..

4 Commits

Author SHA1 Message Date
Balázs Orbán
d5dd901b03 chore(release): bump package version(s) [skip ci] 2022-12-11 14:52:30 +00:00
Balázs Orbán
62f672ae30 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
2022-12-11 14:48:28 +00:00
Balázs Orbán
2c669b32fc 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
2022-12-11 12:55:16 +00:00
Cyril Perraud
2dea8919e5 fix(sequelize): increase sequelize id_token column length (#5929)
Co-authored-by: Nico Domino <yo@ndo.dev>
2022-12-10 12:34:45 +01:00
27 changed files with 510 additions and 267 deletions

View File

@@ -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"
}
}
}

View File

@@ -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 },
}

View File

@@ -1,6 +1,6 @@
{
"name": "next-auth",
"version": "4.18.4",
"version": "4.18.5",
"description": "Authentication for Next.js",
"homepage": "https://next-auth.js.org",
"repository": "https://github.com/nextauthjs/next-auth.git",

View File

@@ -72,6 +72,16 @@ export class InvalidCallbackUrl extends UnknownError {
code = "INVALID_CALLBACK_URL_ERROR"
}
export class UnknownAction extends UnknownError {
name = "UnknownAction"
code = "UNKNOWN_ACTION_ERROR"
}
export class UntrustedHost extends UnknownError {
name = "UntrustedHost"
code = "UNTRUST_HOST_ERROR"
}
type Method = (...args: any[]) => Promise<any>
export function upperSnake(s: string) {

View File

@@ -1,19 +1,21 @@
import logger, { setLogger } from "../utils/logger"
import { toInternalRequest, toResponse } from "../utils/web"
import * as routes from "./routes"
import renderPage from "./pages"
import { init } from "./init"
import { assertConfig } from "./lib/assert"
import { SessionStore } from "./lib/cookie"
import renderPage from "./pages"
import * as routes from "./routes"
import 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<Record<string, string>>
headers?: Record<string, any>
query?: Record<string, any>
@@ -23,22 +25,20 @@ export interface RequestInternal {
error?: string
}
export interface NextAuthHeader {
key: string
value: string
}
// TODO: Rename to `ResponseInternal`
/** @internal */
export interface ResponseInternal<
Body extends string | Record<string, any> | any[] = any
> {
status?: number
headers?: NextAuthHeader[]
headers?: Record<string, string>
body?: Body
redirect?: string
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<string, any> | any[]
>(params: {
@@ -47,10 +47,9 @@ async function AuthHandlerInternal<
/** REVIEW: Is this the best way to skip parsing the body in Node.js? */
parsedBody?: any
}): Promise<ResponseInternal<Body>> {
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)
@@ -60,14 +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: [{ key: "Content-Type", value: "application/json" }],
body: { message } as any,
headers: { "Content-Type": "application/json" },
body: { message: configErrorMessage } as any,
}
}
const { pages, theme } = userOptions
const { pages, theme } = authOptions
const authOnErrorPage =
pages?.error && req.query?.callbackUrl?.startsWith(pages.error)
@@ -90,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,
@@ -123,7 +121,7 @@ async function AuthHandlerInternal<
}
case "csrf":
return {
headers: [{ key: "Content-Type", value: "application/json" }],
headers: { "Content-Type": "application/json" },
body: { csrfToken: options.csrfToken } as any,
cookies,
}
@@ -240,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)
@@ -269,7 +267,41 @@ export async function AuthHandler(
request: Request,
options: AuthOptions
): Promise<Response> {
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
}

View File

@@ -1,7 +1,6 @@
import { randomBytes, randomUUID } from "crypto"
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 { createSecret } from "./lib/utils"
@@ -13,10 +12,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. */
@@ -30,10 +30,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,
@@ -42,12 +42,17 @@ 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())
const secret = createSecret({ userOptions, url })
const secret = createSecret({ authOptions, url })
const { providers, provider } = parseProviders({
providers: userOptions.providers,
providers: authOptions.providers,
url,
providerId,
})
@@ -66,7 +71,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,
@@ -75,24 +80,24 @@ 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: () => {
// Use `randomUUID` if available. (Node 15.6+)
return randomUUID?.() ?? randomBytes(32).toString("hex")
},
...userOptions.session,
...authOptions.session,
},
// JWT options
jwt: {
@@ -100,13 +105,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,
}

View File

@@ -7,7 +7,6 @@ import {
InvalidCallbackUrl,
MissingAdapterMethods,
} from "../errors"
import parseUrl from "../../utils/parse-url"
import { defaultCookies } from "./cookie"
import type { RequestInternal } from ".."
@@ -44,11 +43,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")
@@ -70,21 +69,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}`
)

View File

@@ -6,7 +6,6 @@ import type {
OAuthConfig,
Provider,
} from "../../providers"
import type { InternalUrl } from "../../utils/parse-url"
/**
* Adds `signinUrl` and `callbackUrl` to each provider
@@ -14,7 +13,7 @@ import type { InternalUrl } from "../../utils/parse-url"
*/
export default function parseProviders(params: {
providers: Provider[]
url: InternalUrl
url: URL
providerId?: string
}): {
providers: InternalProvider[]

View File

@@ -2,7 +2,6 @@ import { createHash } from "crypto"
import type { AuthOptions } from "../.."
import type { InternalOptions } from "../types"
import type { InternalUrl } from "../../utils/parse-url"
/**
* Takes a number in seconds and returns the date in the future.
@@ -28,17 +27,14 @@ export function hashToken(token: string, options: InternalOptions<"email">) {
* If no secret option is specified then it creates one on the fly
* based on options passed here. If options contains unique data, such as
* OAuth provider secrets and database credentials it should be sufficent. If no secret provided in production, we throw an error. */
export function createSecret(params: {
userOptions: AuthOptions
url: InternalUrl
}) {
const { userOptions, url } = params
export function createSecret(params: { authOptions: AuthOptions; url: URL }) {
const { authOptions, url } = params
return (
userOptions.secret ??
authOptions.secret ??
// TODO: Remove falling back to default secret, and error in dev if one isn't provided
createHash("sha256")
.update(JSON.stringify({ ...url, ...userOptions }))
.update(JSON.stringify({ ...url, ...authOptions }))
.digest("hex")
)
}

View File

@@ -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
}

View File

@@ -31,7 +31,7 @@ export default function renderPage(params: RenderPageParams) {
return {
cookies,
status,
headers: [{ key: "Content-Type", value: "text/html" }],
headers: { "Content-Type": "text/html" },
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css()}</style><title>${title}</title></head><body class="__next-auth-theme-${
theme?.colorScheme ?? "auto"
}"><div class="page">${renderToString(html)}</div></body></html>`,

View File

@@ -1,8 +1,7 @@
import { Theme } from "../.."
import { InternalUrl } from "../../utils/parse-url"
export interface SignoutProps {
url: InternalUrl
url: URL
csrfToken: string
theme: Theme
}

View File

@@ -18,7 +18,7 @@ export default function providers(
providers: InternalProvider[]
): ResponseInternal<Record<string, PublicProvider>> {
return {
headers: [{ key: "Content-Type", value: "application/json" }],
headers: { "Content-Type": "application/json" },
body: providers.reduce<Record<string, PublicProvider>>(
(acc, { id, name, type, signinUrl, callbackUrl }) => {
acc[id] = { id, name, type, signinUrl, callbackUrl }

View File

@@ -31,7 +31,7 @@ export default async function session(
const response: ResponseInternal<Session | {}> = {
body: {},
headers: [{ key: "Content-Type", value: "application/json" }],
headers: { "Content-Type": "application/json" },
cookies: [],
}

View File

@@ -14,8 +14,6 @@ import type { CookieSerializeOptions } from "cookie"
import type { NextApiRequest, NextApiResponse } from "next"
import type { InternalUrl } from "../utils/parse-url"
export type Awaitable<T> = T | PromiseLike<T>
export type { LoggerInstance }
@@ -210,7 +208,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
}
@@ -528,11 +526,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<TProviderType>
csrfToken?: string

View File

@@ -94,7 +94,7 @@ export async function getToken<R extends boolean = false>(
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]

View File

@@ -1,5 +1,5 @@
import { AuthHandler } from "../core"
import { getURL, getBody, setHeaders } from "../utils/node"
import { getBody, getURL, setHeaders } from "../utils/node"
import type {
GetServerSidePropsContext,
@@ -18,35 +18,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
options.trustHost ??= !!(
process.env.NEXTAUTH_URL ??
process.env.AUTH_TRUST_HOST ??
process.env.VERCEL ??
process.env.NODE_ENV !== "production"
)
const response = await AuthHandler(request, options)
const { status, headers } = response
res.status(status)
setHeaders(headers, res)
// If the request expects a return URL, send it as JSON
// instead of doing an actual redirect.
const redirect = headers.get("Location")
if (req.body?.json === "true" && redirect) {
res.removeHeader("Location")
return res.json({ url: redirect })
}
res.status(response.status)
setHeaders(response.headers, res)
return res.send(await response.text())
}
@@ -139,19 +140,23 @@ 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

View File

@@ -6,7 +6,7 @@ import { NextResponse, NextRequest } from "next/server"
import { getToken } from "../jwt"
import parseUrl from "../utils/parse-url"
import { getURL } from "../utils/node"
import { detectHost } from "../utils/web"
type AuthorizedCallback = (params: {
token: JWT | null
@@ -113,18 +113,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"]

View File

@@ -241,13 +241,13 @@ export async function signIn<
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Auth-Return-Redirect": "1",
},
// @ts-expect-error
body: new URLSearchParams({
...options,
csrfToken: await getCsrfToken(),
callbackUrl,
json: true,
}),
})
@@ -291,12 +291,11 @@ export async function signOut<R extends boolean = true>(
method: "post",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"X-Auth-Return-Redirect": "1",
},
// @ts-expect-error
body: new URLSearchParams({
csrfToken: await getCsrfToken(),
csrfToken: (await getCsrfToken()) ?? "",
callbackUrl,
json: true,
}),
}
const res = await fetch(`${baseUrl}/signout`, fetchOptions)

View File

@@ -25,30 +25,37 @@ 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")
if (!url) throw new TypeError("Invalid URL, cannot determine action")
if (host.startsWith("http://") || host.startsWith("https://")) {
return new URL(`${host}${url}`)
}
return new URL(`https://${host}${url}`)
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
}

View File

@@ -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")

View File

@@ -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,40 +42,56 @@ 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<RequestInternal> {
const url = new URL(req.url)
const nextauth = url.pathname.split("/").slice(3)
const headers = Object.fromEntries(req.headers)
const query: Record<string, any> = Object.fromEntries(url.searchParams)
): Promise<RequestInternal | Error> {
try {
// TODO: .toString() should not inclide 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 readJSONBody(req.body) : 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
}
const cookieHeader = req.headers.get("cookie") ?? ""
return {
url,
action,
providerId,
method: req.method ?? "GET",
headers: Object.fromEntries(req.headers),
body: req.body ? await readJSONBody(req.body) : undefined,
cookies:
parseCookie(
Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader
) ?? {},
error: url.searchParams.get("error") ?? undefined,
query: Object.fromEntries(url.searchParams),
}
} catch (error) {
return error
}
}
export function toResponse(res: ResponseInternal): Response {
const headers = new Headers(
res.headers?.reduce((acc, { key, value }) => {
acc[key] = value
return acc
}, {})
)
const headers = new Headers(res.headers)
res.cookies?.forEach((cookie) => {
const { name, value, options } = cookie
@@ -102,3 +119,17 @@ export function toResponse(res: ResponseInternal): Response {
return response
}
// TODO: Remove
/** Extract the host from the environment */
export function detectHost(
trusted: boolean,
forwardedValue: string | string[] | undefined | null,
defaultValue: string | false
): string | undefined {
if (trusted && forwardedValue) {
return Array.isArray(forwardedValue) ? forwardedValue[0] : forwardedValue
}
return defaultValue || undefined
}

View File

@@ -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" } }
)

View File

@@ -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",

View File

@@ -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<typeof getURLOriginal>[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<R> {
toBeURL: (expected: string) => R
}
}
}

View File

@@ -6,91 +6,64 @@ it("should not match pages as public paths", async () => {
pages: { signIn: "/", error: "/" },
secret: "secret",
}
const req = new NextRequest("http://127.0.0.1/protected/pathA", {
headers: { authorization: "" },
})
const handleMiddleware = withAuth(options) as NextMiddleware
const res = await handleMiddleware(req, null as any)
expect(res).toBeDefined()
expect(res?.status).toBe(307)
const response = await handleMiddleware(
new NextRequest("http://127.0.0.1/protected/pathA"),
null as any
)
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 () => {
it("should respect NextURL#basePath when redirecting", 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"
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
)
})
it("should redirect according to nextUrl basePath", async () => {
// given
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(
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()
})

View File

@@ -1,29 +1,47 @@
// import { MissingAPIRoute } from "../src/core/errors"
import { nodeHandler } from "./utils"
it("Missing req.url throws MISSING_NEXTAUTH_API_ROUTE_ERROR", async () => {
const { res } = await nodeHandler()
expect(res.status).toBeCalledWith(400)
// Moved to host detection in getUrl
// expect(logger.error).toBeCalledTimes(1)
// expect(logger.error).toBeCalledWith(
// "MISSING_NEXTAUTH_API_ROUTE_ERROR",
// expect.any(MissingAPIRoute)
// )
// expect(res.setHeader).toBeCalledWith("content-type", "application/json")
// const body = res.send.mock.calls[0][0]
// expect(JSON.parse(body)).toEqual({
// message:
// "There is a problem with the server configuration. Check the server logs for more information.",
// })
it("Missing req.url throws in dev", async () => {
await expect(nodeHandler).rejects.toThrow(new Error("Missing url"))
})
it("Missing host throws 400 in production", async () => {
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 } = await nodeHandler()
const { res, logger } = await nodeHandler()
expect(logger.error).toBeCalledTimes(1)
const error = new Error("Missing url")
expect(logger.error).toBeCalledWith("INVALID_URL", error)
expect(res.status).toBeCalledWith(400)
expect(res.json).toBeCalledWith({ message: configErrorMessage })
// @ts-expect-error
process.env.NODE_ENV = "test"
})
it("Missing host throws in dev", async () => {
await expect(
async () =>
await nodeHandler({
req: { query: { nextauth: ["session"] } },
})
).rejects.toThrow(Error)
})
it("Missing host config error in prod", async () => {
// @ts-expect-error
process.env.NODE_ENV = "production"
const { res, logger } = await nodeHandler({
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"
})
@@ -82,12 +100,43 @@ it("Redirects if necessary", async () => {
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.setHeader).toBeCalledWith("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.setHeader).toBeCalledTimes(2)
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 nodeHandler({
req: {
method: "post",
url: "/api/auth/signin/github",
headers: { "X-Auth-Return-Redirect": "1" },
},
})
expect(res.status).toBeCalledWith(200)
expect(res.setHeader).toBeCalledWith("content-type", "application/json")
expect(res.setHeader).toBeCalledWith("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.setHeader).toBeCalledTimes(2)
expect(res.send).toBeCalledWith(
JSON.stringify({ url: "http://localhost/api/auth/signin?csrf=true" })
)
})