Skip to content

Commit 62f672a

Browse files
authoredDec 11, 2022
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
1 parent 2c669b3 commit 62f672a

23 files changed

+451
-241
lines changed
 

‎packages/next-auth/src/core/errors.ts

+10
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,16 @@ export class InvalidCallbackUrl extends UnknownError {
7272
code = "INVALID_CALLBACK_URL_ERROR"
7373
}
7474

75+
export class UnknownAction extends UnknownError {
76+
name = "UnknownAction"
77+
code = "UNKNOWN_ACTION_ERROR"
78+
}
79+
80+
export class UntrustedHost extends UnknownError {
81+
name = "UntrustedHost"
82+
code = "UNTRUST_HOST_ERROR"
83+
}
84+
7585
type Method = (...args: any[]) => Promise<any>
7686

7787
export function upperSnake(s: string) {

‎packages/next-auth/src/core/index.ts

+44-25
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
import logger, { setLogger } from "../utils/logger"
22
import { toInternalRequest, toResponse } from "../utils/web"
3-
import * as routes from "./routes"
4-
import renderPage from "./pages"
53
import { init } from "./init"
64
import { assertConfig } from "./lib/assert"
75
import { SessionStore } from "./lib/cookie"
6+
import renderPage from "./pages"
7+
import * as routes from "./routes"
88

9-
import type { AuthAction, AuthOptions } from "./types"
9+
import { UntrustedHost } from "./errors"
1010
import type { Cookie } from "./lib/cookie"
1111
import type { ErrorType } from "./pages/error"
12+
import type { AuthAction, AuthOptions } from "./types"
1213

14+
/** @internal */
1315
export interface RequestInternal {
14-
/** @default "http://localhost:3000" */
15-
host?: string
16-
method?: string
16+
url: URL
17+
/** @default "GET" */
18+
method: string
1719
cookies?: Partial<Record<string, string>>
1820
headers?: Record<string, any>
1921
query?: Record<string, any>
@@ -23,22 +25,20 @@ export interface RequestInternal {
2325
error?: string
2426
}
2527

26-
export interface NextAuthHeader {
27-
key: string
28-
value: string
29-
}
30-
31-
// TODO: Rename to `ResponseInternal`
28+
/** @internal */
3229
export interface ResponseInternal<
3330
Body extends string | Record<string, any> | any[] = any
3431
> {
3532
status?: number
36-
headers?: NextAuthHeader[]
33+
headers?: Record<string, string>
3734
body?: Body
3835
redirect?: string
3936
cookies?: Cookie[]
4037
}
4138

39+
const configErrorMessage =
40+
"There is a problem with the server configuration. Check the server logs for more information."
41+
4242
async function AuthHandlerInternal<
4343
Body extends string | Record<string, any> | any[]
4444
>(params: {
@@ -47,10 +47,9 @@ async function AuthHandlerInternal<
4747
/** REVIEW: Is this the best way to skip parsing the body in Node.js? */
4848
parsedBody?: any
4949
}): Promise<ResponseInternal<Body>> {
50-
const { options: userOptions, req } = params
51-
setLogger(userOptions.logger, userOptions.debug)
50+
const { options: authOptions, req } = params
5251

53-
const assertionResult = assertConfig({ options: userOptions, req })
52+
const assertionResult = assertConfig({ options: authOptions, req })
5453

5554
if (Array.isArray(assertionResult)) {
5655
assertionResult.forEach(logger.warn)
@@ -60,14 +59,13 @@ async function AuthHandlerInternal<
6059

6160
const htmlPages = ["signin", "signout", "error", "verify-request"]
6261
if (!htmlPages.includes(req.action) || req.method !== "GET") {
63-
const message = `There is a problem with the server configuration. Check the server logs for more information.`
6462
return {
6563
status: 500,
66-
headers: [{ key: "Content-Type", value: "application/json" }],
67-
body: { message } as any,
64+
headers: { "Content-Type": "application/json" },
65+
body: { message: configErrorMessage } as any,
6866
}
6967
}
70-
const { pages, theme } = userOptions
68+
const { pages, theme } = authOptions
7169

7270
const authOnErrorPage =
7371
pages?.error && req.query?.callbackUrl?.startsWith(pages.error)
@@ -90,13 +88,13 @@ async function AuthHandlerInternal<
9088
}
9189
}
9290

93-
const { action, providerId, error, method = "GET" } = req
91+
const { action, providerId, error, method } = req
9492

9593
const { options, cookies } = await init({
96-
userOptions,
94+
authOptions,
9795
action,
9896
providerId,
99-
host: req.host,
97+
url: req.url,
10098
callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl,
10199
csrfToken: req.body?.csrfToken,
102100
cookies: req.cookies,
@@ -123,7 +121,7 @@ async function AuthHandlerInternal<
123121
}
124122
case "csrf":
125123
return {
126-
headers: [{ key: "Content-Type", value: "application/json" }],
124+
headers: { "Content-Type": "application/json" },
127125
body: { csrfToken: options.csrfToken } as any,
128126
cookies,
129127
}
@@ -240,7 +238,7 @@ async function AuthHandlerInternal<
240238
}
241239
break
242240
case "_log":
243-
if (userOptions.logger) {
241+
if (authOptions.logger) {
244242
try {
245243
const { code, level, ...metadata } = req.body ?? {}
246244
logger[level](code, metadata)
@@ -269,7 +267,28 @@ export async function AuthHandler(
269267
request: Request,
270268
options: AuthOptions
271269
): Promise<Response> {
270+
setLogger(options.logger, options.debug)
271+
272+
if (!options.trustHost) {
273+
const error = new UntrustedHost(
274+
`Host must be trusted. URL was: ${request.url}`
275+
)
276+
logger.error(error.code, error)
277+
278+
return new Response(JSON.stringify({ message: configErrorMessage }), {
279+
status: 500,
280+
headers: { "Content-Type": "application/json" },
281+
})
282+
}
283+
272284
const req = await toInternalRequest(request)
285+
if (req instanceof Error) {
286+
logger.error((req as any).code, req)
287+
return new Response(
288+
`Error: This action with HTTP ${request.method} is not supported.`,
289+
{ status: 400 }
290+
)
291+
}
273292
const internalResponse = await AuthHandlerInternal({ req, options })
274293

275294
const response = await toResponse(internalResponse)

‎packages/next-auth/src/core/init.ts

+22-17
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { randomBytes, randomUUID } from "crypto"
22
import { AuthOptions } from ".."
33
import logger from "../utils/logger"
4-
import parseUrl from "../utils/parse-url"
54
import { adapterErrorHandler, eventsErrorHandler } from "./errors"
65
import parseProviders from "./lib/providers"
76
import { createSecret } from "./lib/utils"
@@ -13,10 +12,11 @@ import { createCallbackUrl } from "./lib/callback-url"
1312
import { RequestInternal } from "."
1413

1514
import type { InternalOptions } from "./types"
15+
import parseUrl from "../utils/parse-url"
1616

1717
interface InitParams {
18-
host?: string
19-
userOptions: AuthOptions
18+
url: URL
19+
authOptions: AuthOptions
2020
providerId?: string
2121
action: InternalOptions["action"]
2222
/** Callback URL value extracted from the incoming request. */
@@ -30,10 +30,10 @@ interface InitParams {
3030

3131
/** Initialize all internal options and cookies. */
3232
export async function init({
33-
userOptions,
33+
authOptions,
3434
providerId,
3535
action,
36-
host,
36+
url: reqUrl,
3737
cookies: reqCookies,
3838
callbackUrl: reqCallbackUrl,
3939
csrfToken: reqCsrfToken,
@@ -42,12 +42,17 @@ export async function init({
4242
options: InternalOptions
4343
cookies: cookie.Cookie[]
4444
}> {
45-
const url = parseUrl(host)
45+
// TODO: move this to web.ts
46+
const parsed = parseUrl(
47+
reqUrl.origin +
48+
reqUrl.pathname.replace(`/${action}`, "").replace(`/${providerId}`, "")
49+
)
50+
const url = new URL(parsed.toString())
4651

47-
const secret = createSecret({ userOptions, url })
52+
const secret = createSecret({ authOptions, url })
4853

4954
const { providers, provider } = parseProviders({
50-
providers: userOptions.providers,
55+
providers: authOptions.providers,
5156
url,
5257
providerId,
5358
})
@@ -66,7 +71,7 @@ export async function init({
6671
buttonText: "",
6772
},
6873
// Custom options override defaults
69-
...userOptions,
74+
...authOptions,
7075
// These computed settings can have values in userOptions but we override them
7176
// and are request-specific.
7277
url,
@@ -75,38 +80,38 @@ export async function init({
7580
provider,
7681
cookies: {
7782
...cookie.defaultCookies(
78-
userOptions.useSecureCookies ?? url.base.startsWith("https://")
83+
authOptions.useSecureCookies ?? url.protocol === "https:"
7984
),
8085
// Allow user cookie options to override any cookie settings above
81-
...userOptions.cookies,
86+
...authOptions.cookies,
8287
},
8388
secret,
8489
providers,
8590
// Session options
8691
session: {
8792
// If no adapter specified, force use of JSON Web Tokens (stateless)
88-
strategy: userOptions.adapter ? "database" : "jwt",
93+
strategy: authOptions.adapter ? "database" : "jwt",
8994
maxAge,
9095
updateAge: 24 * 60 * 60,
9196
generateSessionToken: () => {
9297
// Use `randomUUID` if available. (Node 15.6+)
9398
return randomUUID?.() ?? randomBytes(32).toString("hex")
9499
},
95-
...userOptions.session,
100+
...authOptions.session,
96101
},
97102
// JWT options
98103
jwt: {
99104
secret, // Use application secret if no keys specified
100105
maxAge, // same as session maxAge,
101106
encode: jwt.encode,
102107
decode: jwt.decode,
103-
...userOptions.jwt,
108+
...authOptions.jwt,
104109
},
105110
// Event messages
106-
events: eventsErrorHandler(userOptions.events ?? {}, logger),
107-
adapter: adapterErrorHandler(userOptions.adapter, logger),
111+
events: eventsErrorHandler(authOptions.events ?? {}, logger),
112+
adapter: adapterErrorHandler(authOptions.adapter, logger),
108113
// Callback functions
109-
callbacks: { ...defaultCallbacks, ...userOptions.callbacks },
114+
callbacks: { ...defaultCallbacks, ...authOptions.callbacks },
110115
logger,
111116
callbackUrl: url.origin,
112117
}

‎packages/next-auth/src/core/lib/assert.ts

+5-8
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {
77
InvalidCallbackUrl,
88
MissingAdapterMethods,
99
} from "../errors"
10-
import parseUrl from "../../utils/parse-url"
1110
import { defaultCookies } from "./cookie"
1211

1312
import type { RequestInternal } from ".."
@@ -44,11 +43,11 @@ export function assertConfig(params: {
4443
req: RequestInternal
4544
}): ConfigError | WarningCode[] {
4645
const { options, req } = params
47-
46+
const { url } = req
4847
const warnings: WarningCode[] = []
4948

5049
if (!warned) {
51-
if (!req.host) warnings.push("NEXTAUTH_URL")
50+
if (!url.origin) warnings.push("NEXTAUTH_URL")
5251

5352
// TODO: Make this throw an error in next major. This will also get rid of `NODE_ENV`
5453
if (!options.secret && process.env.NODE_ENV !== "production")
@@ -70,21 +69,19 @@ export function assertConfig(params: {
7069

7170
const callbackUrlParam = req.query?.callbackUrl as string | undefined
7271

73-
const url = parseUrl(req.host)
74-
75-
if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.base)) {
72+
if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.origin)) {
7673
return new InvalidCallbackUrl(
7774
`Invalid callback URL. Received: ${callbackUrlParam}`
7875
)
7976
}
8077

8178
const { callbackUrl: defaultCallbackUrl } = defaultCookies(
82-
options.useSecureCookies ?? url.base.startsWith("https://")
79+
options.useSecureCookies ?? url.protocol === "https://"
8380
)
8481
const callbackUrlCookie =
8582
req.cookies?.[options.cookies?.callbackUrl?.name ?? defaultCallbackUrl.name]
8683

87-
if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.base)) {
84+
if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.origin)) {
8885
return new InvalidCallbackUrl(
8986
`Invalid callback URL. Received: ${callbackUrlCookie}`
9087
)

‎packages/next-auth/src/core/lib/providers.ts

+1-2
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@ import type {
66
OAuthConfig,
77
Provider,
88
} from "../../providers"
9-
import type { InternalUrl } from "../../utils/parse-url"
109

1110
/**
1211
* Adds `signinUrl` and `callbackUrl` to each provider
1312
* and deep merge user-defined options.
1413
*/
1514
export default function parseProviders(params: {
1615
providers: Provider[]
17-
url: InternalUrl
16+
url: URL
1817
providerId?: string
1918
}): {
2019
providers: InternalProvider[]

‎packages/next-auth/src/core/lib/utils.ts

+4-8
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { createHash } from "crypto"
22

33
import type { AuthOptions } from "../.."
44
import type { InternalOptions } from "../types"
5-
import type { InternalUrl } from "../../utils/parse-url"
65

76
/**
87
* Takes a number in seconds and returns the date in the future.
@@ -28,17 +27,14 @@ export function hashToken(token: string, options: InternalOptions<"email">) {
2827
* If no secret option is specified then it creates one on the fly
2928
* based on options passed here. If options contains unique data, such as
3029
* OAuth provider secrets and database credentials it should be sufficent. If no secret provided in production, we throw an error. */
31-
export function createSecret(params: {
32-
userOptions: AuthOptions
33-
url: InternalUrl
34-
}) {
35-
const { userOptions, url } = params
30+
export function createSecret(params: { authOptions: AuthOptions; url: URL }) {
31+
const { authOptions, url } = params
3632

3733
return (
38-
userOptions.secret ??
34+
authOptions.secret ??
3935
// TODO: Remove falling back to default secret, and error in dev if one isn't provided
4036
createHash("sha256")
41-
.update(JSON.stringify({ ...url, ...userOptions }))
37+
.update(JSON.stringify({ ...url, ...authOptions }))
4238
.digest("hex")
4339
)
4440
}

‎packages/next-auth/src/core/pages/error.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import { Theme } from "../.."
2-
import { InternalUrl } from "../../utils/parse-url"
32

43
/**
54
* The following errors are passed as error query parameters to the default or overridden error page.
@@ -12,7 +11,7 @@ export type ErrorType =
1211
| "verification"
1312

1413
export interface ErrorProps {
15-
url?: InternalUrl
14+
url?: URL
1615
theme?: Theme
1716
error?: ErrorType
1817
}

‎packages/next-auth/src/core/pages/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default function renderPage(params: RenderPageParams) {
3131
return {
3232
cookies,
3333
status,
34-
headers: [{ key: "Content-Type", value: "text/html" }],
34+
headers: { "Content-Type": "text/html" },
3535
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-${
3636
theme?.colorScheme ?? "auto"
3737
}"><div class="page">${renderToString(html)}</div></body></html>`,

‎packages/next-auth/src/core/pages/signout.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { Theme } from "../.."
2-
import { InternalUrl } from "../../utils/parse-url"
32

43
export interface SignoutProps {
5-
url: InternalUrl
4+
url: URL
65
csrfToken: string
76
theme: Theme
87
}

‎packages/next-auth/src/core/routes/providers.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export default function providers(
1818
providers: InternalProvider[]
1919
): ResponseInternal<Record<string, PublicProvider>> {
2020
return {
21-
headers: [{ key: "Content-Type", value: "application/json" }],
21+
headers: { "Content-Type": "application/json" },
2222
body: providers.reduce<Record<string, PublicProvider>>(
2323
(acc, { id, name, type, signinUrl, callbackUrl }) => {
2424
acc[id] = { id, name, type, signinUrl, callbackUrl }

‎packages/next-auth/src/core/routes/session.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export default async function session(
3131

3232
const response: ResponseInternal<Session | {}> = {
3333
body: {},
34-
headers: [{ key: "Content-Type", value: "application/json" }],
34+
headers: { "Content-Type": "application/json" },
3535
cookies: [],
3636
}
3737

‎packages/next-auth/src/core/types.ts

+2-8
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,6 @@ import type { CookieSerializeOptions } from "cookie"
1414

1515
import type { NextApiRequest, NextApiResponse } from "next"
1616

17-
import type { InternalUrl } from "../utils/parse-url"
18-
1917
export type Awaitable<T> = T | PromiseLike<T>
2018

2119
export type { LoggerInstance }
@@ -210,7 +208,7 @@ export interface AuthOptions {
210208
* - ⚠ **This is an advanced option.** Advanced options are passed the same way as basic options,
211209
* but **may have complex implications** or side effects.
212210
* You should **try to avoid using advanced options** unless you are very comfortable using them.
213-
* @default Boolean(process.env.AUTH_TRUST_HOST ?? process.env.VERCEL)
211+
* @default Boolean(process.env.NEXTAUTH_URL ?? process.env.AUTH_TRUST_HOST ?? process.env.VERCEL)
214212
*/
215213
trustHost?: boolean
216214
}
@@ -528,11 +526,7 @@ export interface InternalOptions<
528526
WithVerificationToken = TProviderType extends "email" ? true : false
529527
> {
530528
providers: InternalProvider[]
531-
/**
532-
* Parsed from `NEXTAUTH_URL` or `x-forwarded-host` on Vercel.
533-
* @default "http://localhost:3000/api/auth"
534-
*/
535-
url: InternalUrl
529+
url: URL
536530
action: AuthAction
537531
provider: InternalProvider<TProviderType>
538532
csrfToken?: string

‎packages/next-auth/src/jwt/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ export async function getToken<R extends boolean = false>(
9494
const authorizationHeader =
9595
req.headers instanceof Headers
9696
? req.headers.get("authorization")
97-
: req.headers.authorization
97+
: req.headers?.authorization
9898

9999
if (!token && authorizationHeader?.split(" ")[0] === "Bearer") {
100100
const urlEncodedToken = authorizationHeader.split(" ")[1]

‎packages/next-auth/src/next/index.ts

+35-20
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AuthHandler } from "../core"
2-
import { getURL, getBody, setHeaders } from "../utils/node"
2+
import { getBody, getURL, setHeaders } from "../utils/node"
33

44
import type {
55
GetServerSidePropsContext,
@@ -18,23 +18,34 @@ async function NextAuthHandler(
1818
res: NextApiResponse,
1919
options: AuthOptions
2020
) {
21-
const url = getURL(
22-
req.url,
23-
options.trustHost,
24-
req.headers["x-forwarded-host"] ?? req.headers.host
25-
)
26-
27-
if (url instanceof Error) return res.status(400).end()
21+
const headers = new Headers(req.headers as any)
22+
const url = getURL(req.url, headers)
23+
if (url instanceof Error) {
24+
if (process.env.NODE_ENV !== "production") throw url
25+
const errorLogger = options.logger?.error ?? console.error
26+
errorLogger("INVALID_URL", url)
27+
res.status(400)
28+
return res.json({
29+
message:
30+
"There is a problem with the server configuration. Check the server logs for more information.",
31+
})
32+
}
2833

2934
const request = new Request(url, {
30-
headers: new Headers(req.headers as any),
35+
headers,
3136
method: req.method,
3237
...getBody(req),
3338
})
3439

3540
options.secret ??= options.jwt?.secret ?? process.env.NEXTAUTH_SECRET
36-
const response = await AuthHandler(request, options)
41+
options.trustHost ??= !!(
42+
process.env.NEXTAUTH_URL ??
43+
process.env.AUTH_TRUST_HOST ??
44+
process.env.VERCEL ??
45+
process.env.NODE_ENV !== "production"
46+
)
3747

48+
const response = await AuthHandler(request, options)
3849
res.status(response.status)
3950
setHeaders(response.headers, res)
4051

@@ -129,19 +140,23 @@ export async function unstable_getServerSession<
129140
options = Object.assign({}, args[2], { providers: [] })
130141
}
131142

132-
const urlOrError = getURL(
133-
"/api/auth/session",
134-
options.trustHost,
135-
req.headers["x-forwarded-host"] ?? req.headers.host
136-
)
143+
const url = getURL("/api/auth/session", new Headers(req.headers))
144+
if (url instanceof Error) {
145+
if (process.env.NODE_ENV !== "production") throw url
146+
const errorLogger = options.logger?.error ?? console.error
147+
errorLogger("INVALID_URL", url)
148+
res.status(400)
149+
return res.json({
150+
message:
151+
"There is a problem with the server configuration. Check the server logs for more information.",
152+
})
153+
}
137154

138-
if (urlOrError instanceof Error) throw urlOrError
155+
const request = new Request(url, { headers: new Headers(req.headers) })
139156

140157
options.secret ??= process.env.NEXTAUTH_SECRET
141-
const response = await AuthHandler(
142-
new Request(urlOrError, { headers: req.headers }),
143-
options
144-
)
158+
options.trustHost = true
159+
const response = await AuthHandler(request, options)
145160

146161
const { status = 200, headers } = response
147162

‎packages/next-auth/src/next/middleware.ts

+10-9
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { NextResponse, NextRequest } from "next/server"
66

77
import { getToken } from "../jwt"
88
import parseUrl from "../utils/parse-url"
9-
import { getURL } from "../utils/node"
9+
import { detectHost } from "../utils/web"
1010

1111
type AuthorizedCallback = (params: {
1212
token: JWT | null
@@ -113,18 +113,19 @@ async function handleMiddleware(
113113
const signInPage = options?.pages?.signIn ?? "/api/auth/signin"
114114
const errorPage = options?.pages?.error ?? "/api/auth/error"
115115

116-
options.trustHost = Boolean(
117-
options.trustHost ?? process.env.VERCEL ?? process.env.AUTH_TRUST_HOST
116+
options.trustHost ??= !!(
117+
process.env.NEXTAUTH_URL ??
118+
process.env.VERCEL ??
119+
process.env.AUTH_TRUST_HOST
118120
)
119121

120-
let authPath
121-
const url = getURL(
122-
null,
122+
const host = detectHost(
123123
options.trustHost,
124-
req.headers.get("x-forwarded-host") ?? req.headers.get("host")
124+
req.headers?.get("x-forwarded-host"),
125+
process.env.NEXTAUTH_URL ??
126+
(process.env.NODE_ENV !== "production" && "http://localhost:3000")
125127
)
126-
if (url instanceof URL) authPath = parseUrl(url).path
127-
else authPath = "/api/auth"
128+
const authPath = parseUrl(host).path
128129

129130
const publicPaths = ["/_next", "/favicon.ico"]
130131

‎packages/next-auth/src/utils/node.ts

+29-22
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,37 @@ export function getBody(
2525
return { body: JSON.stringify(req.body) }
2626
}
2727

28-
/** Extract the host from the environment */
29-
export function getURL(
30-
url: string | undefined | null,
31-
trusted: boolean | undefined = !!(
32-
process.env.AUTH_TRUST_HOST ?? process.env.VERCEL
33-
),
34-
forwardedValue: string | string[] | undefined | null
35-
): URL | Error {
28+
/**
29+
* Extract the full request URL from the environment.
30+
* NOTE: It does not verify if the host should be trusted.
31+
*/
32+
export function getURL(url: string | undefined, headers: Headers): URL | Error {
3633
try {
37-
let host =
38-
process.env.NEXTAUTH_URL ??
39-
(process.env.NODE_ENV !== "production" && "http://localhost:3000")
40-
41-
if (trusted && forwardedValue) {
42-
host = Array.isArray(forwardedValue) ? forwardedValue[0] : forwardedValue
43-
}
44-
45-
if (!host) throw new TypeError("Invalid host")
46-
if (!url) throw new TypeError("Invalid URL, cannot determine action")
47-
48-
if (host.startsWith("http://") || host.startsWith("https://")) {
49-
return new URL(`${host}${url}`)
34+
if (!url) throw new Error("Missing url")
35+
if (process.env.NEXTAUTH_URL) {
36+
const base = new URL(process.env.NEXTAUTH_URL)
37+
if (!["http:", "https:"].includes(base.protocol)) {
38+
throw new Error("Invalid protocol")
39+
}
40+
const hasCustomPath = base.pathname !== "/"
41+
42+
if (hasCustomPath) {
43+
const apiAuthRe = /\/api\/auth\/?$/
44+
const basePathname = base.pathname.match(apiAuthRe)
45+
? base.pathname.replace(apiAuthRe, "")
46+
: base.pathname
47+
return new URL(basePathname.replace(/\/$/, "") + url, base.origin)
48+
}
49+
return new URL(url, base)
5050
}
51-
return new URL(`https://${host}${url}`)
51+
const proto =
52+
headers.get("x-forwarded-proto") ??
53+
(process.env.NODE_ENV !== "production" ? "http" : "https")
54+
const host = headers.get("x-forwarded-host") ?? headers.get("host")
55+
if (!["http", "https"].includes(proto)) throw new Error("Invalid protocol")
56+
const origin = `${proto}://${host}`
57+
if (!host) throw new Error("Missing host")
58+
return new URL(url, origin)
5259
} catch (error) {
5360
return error as Error
5461
}

‎packages/next-auth/src/utils/parse-url.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ export interface InternalUrl {
1111
toString: () => string
1212
}
1313

14-
/** Returns an `URL` like object to make requests/redirects from server-side */
14+
/**
15+
* TODO: Can we remove this?
16+
* Returns an `URL` like object to make requests/redirects from server-side
17+
*/
1518
export default function parseUrl(url?: string | URL): InternalUrl {
1619
const defaultUrl = new URL("http://localhost:3000/api/auth")
1720

‎packages/next-auth/src/utils/web.ts

+59-28
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { serialize, parse as parseCookie } from "cookie"
2+
import { UnknownAction } from "../core/errors"
23
import type { ResponseInternal, RequestInternal } from "../core"
34
import type { AuthAction } from "../core/types"
45

@@ -41,40 +42,56 @@ async function readJSONBody(
4142
}
4243
}
4344

45+
// prettier-ignore
46+
const actions: AuthAction[] = [ "providers", "session", "csrf", "signin", "signout", "callback", "verify-request", "error", "_log" ]
47+
4448
export async function toInternalRequest(
4549
req: Request
46-
): Promise<RequestInternal> {
47-
const url = new URL(req.url)
48-
const nextauth = url.pathname.split("/").slice(3)
49-
const headers = Object.fromEntries(req.headers)
50-
const query: Record<string, any> = Object.fromEntries(url.searchParams)
51-
52-
const cookieHeader = req.headers.get("cookie") ?? ""
53-
const cookies =
54-
parseCookie(
55-
Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader
56-
) ?? {}
57-
58-
return {
59-
action: nextauth[0] as AuthAction,
60-
method: req.method,
61-
headers,
62-
body: req.body ? await readJSONBody(req.body) : undefined,
63-
cookies: cookies,
64-
providerId: nextauth[1],
65-
error: url.searchParams.get("error") ?? undefined,
66-
host: new URL(req.url).origin,
67-
query,
50+
): Promise<RequestInternal | Error> {
51+
try {
52+
// TODO: .toString() should not inclide action and providerId
53+
// see init.ts
54+
const url = new URL(req.url.replace(/\/$/, ""))
55+
const { pathname } = url
56+
57+
const action = actions.find((a) => pathname.includes(a))
58+
if (!action) {
59+
throw new UnknownAction("Cannot detect action.")
60+
}
61+
62+
const providerIdOrAction = pathname.split("/").pop()
63+
let providerId
64+
if (
65+
providerIdOrAction &&
66+
!action.includes(providerIdOrAction) &&
67+
["signin", "callback"].includes(action)
68+
) {
69+
providerId = providerIdOrAction
70+
}
71+
72+
const cookieHeader = req.headers.get("cookie") ?? ""
73+
74+
return {
75+
url,
76+
action,
77+
providerId,
78+
method: req.method ?? "GET",
79+
headers: Object.fromEntries(req.headers),
80+
body: req.body ? await readJSONBody(req.body) : undefined,
81+
cookies:
82+
parseCookie(
83+
Array.isArray(cookieHeader) ? cookieHeader.join(";") : cookieHeader
84+
) ?? {},
85+
error: url.searchParams.get("error") ?? undefined,
86+
query: Object.fromEntries(url.searchParams),
87+
}
88+
} catch (error) {
89+
return error
6890
}
6991
}
7092

7193
export function toResponse(res: ResponseInternal): Response {
72-
const headers = new Headers(
73-
res.headers?.reduce((acc, { key, value }) => {
74-
acc[key] = value
75-
return acc
76-
}, {})
77-
)
94+
const headers = new Headers(res.headers)
7895

7996
res.cookies?.forEach((cookie) => {
8097
const { name, value, options } = cookie
@@ -102,3 +119,17 @@ export function toResponse(res: ResponseInternal): Response {
102119

103120
return response
104121
}
122+
123+
// TODO: Remove
124+
/** Extract the host from the environment */
125+
export function detectHost(
126+
trusted: boolean,
127+
forwardedValue: string | string[] | undefined | null,
128+
defaultValue: string | false
129+
): string | undefined {
130+
if (trusted && forwardedValue) {
131+
return Array.isArray(forwardedValue) ? forwardedValue[0] : forwardedValue
132+
}
133+
134+
return defaultValue || undefined
135+
}

‎packages/next-auth/tests/assert.test.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import EmailProvider from "../src/providers/email"
99

1010
it("Show error page if secret is not defined", async () => {
1111
const { res, log } = await handler(
12-
{ providers: [], secret: undefined },
12+
{ providers: [], secret: undefined, trustHost: true },
1313
{ prod: true }
1414
)
1515

@@ -28,6 +28,7 @@ it("Show error page if adapter is missing functions when using with email", asyn
2828
adapter: missingFunctionAdapter,
2929
providers: [EmailProvider({ sendVerificationRequest })],
3030
secret: "secret",
31+
trustHost: true,
3132
},
3233
{ prod: true }
3334
)
@@ -48,6 +49,7 @@ it("Show error page if adapter is not configured when using with email", async (
4849
{
4950
providers: [EmailProvider({ sendVerificationRequest })],
5051
secret: "secret",
52+
trustHost: true,
5153
},
5254
{ prod: true }
5355
)
@@ -64,7 +66,7 @@ it("Show error page if adapter is not configured when using with email", async (
6466

6567
it("Should show configuration error page on invalid `callbackUrl`", async () => {
6668
const { res, log } = await handler(
67-
{ providers: [] },
69+
{ providers: [], trustHost: true },
6870
{ prod: true, params: { callbackUrl: "invalid-callback" } }
6971
)
7072

@@ -80,7 +82,7 @@ it("Should show configuration error page on invalid `callbackUrl`", async () =>
8082

8183
it("Allow relative `callbackUrl`", async () => {
8284
const { res, log } = await handler(
83-
{ providers: [] },
85+
{ providers: [], trustHost: true },
8486
{ prod: true, params: { callbackUrl: "/callback" } }
8587
)
8688

‎packages/next-auth/tests/email.test.ts

+4
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ it("Send e-mail to the only address correctly", async () => {
1414
providers: [EmailProvider({ sendVerificationRequest })],
1515
callbacks: { signIn },
1616
secret,
17+
trustHost: true,
1718
},
1819
{
1920
path: "signin/email",
@@ -54,6 +55,7 @@ it("Send e-mail to first address only", async () => {
5455
providers: [EmailProvider({ sendVerificationRequest })],
5556
callbacks: { signIn },
5657
secret,
58+
trustHost: true,
5759
},
5860
{
5961
path: "signin/email",
@@ -94,6 +96,7 @@ it("Send e-mail to address with first domain", async () => {
9496
providers: [EmailProvider({ sendVerificationRequest })],
9597
callbacks: { signIn },
9698
secret,
99+
trustHost: true,
97100
},
98101
{
99102
path: "signin/email",
@@ -140,6 +143,7 @@ it("Redirect to error page if multiple addresses aren't allowed", async () => {
140143
}),
141144
],
142145
secret,
146+
trustHost: true,
143147
},
144148
{
145149
path: "signin/email",
+138
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { getURL as getURLOriginal } from "../src/utils/node"
2+
3+
it("Should return error when missing url", () => {
4+
expect(getURL(undefined, {})).toEqual(new Error("Missing url"))
5+
})
6+
7+
it("Should return error when missing host", () => {
8+
expect(getURL("/", {})).toEqual(new Error("Missing host"))
9+
})
10+
11+
it("Should return error when invalid protocol", () => {
12+
expect(
13+
getURL("/", { host: "localhost", "x-forwarded-proto": "file" })
14+
).toEqual(new Error("Invalid protocol"))
15+
})
16+
17+
it("Should return error when invalid host", () => {
18+
expect(getURL("/", { host: "/" })).toEqual(
19+
new TypeError("Invalid base URL: http:///")
20+
)
21+
})
22+
23+
it("Should read host headers", () => {
24+
expect(getURL("/api/auth/session", { host: "localhost" })).toBeURL(
25+
"http://localhost/api/auth/session"
26+
)
27+
28+
expect(
29+
getURL("/custom/api/auth/session", { "x-forwarded-host": "localhost:3000" })
30+
).toBeURL("http://localhost:3000/custom/api/auth/session")
31+
32+
// Prefer x-forwarded-host over host
33+
expect(
34+
getURL("/", { host: "localhost", "x-forwarded-host": "localhost:3000" })
35+
).toBeURL("http://localhost:3000/")
36+
})
37+
38+
it("Should read protocol headers", () => {
39+
expect(
40+
getURL("/", { host: "localhost", "x-forwarded-proto": "http" })
41+
).toBeURL("http://localhost/")
42+
})
43+
44+
describe("process.env.NEXTAUTH_URL", () => {
45+
afterEach(() => delete process.env.NEXTAUTH_URL)
46+
47+
it("Should prefer over headers if present", () => {
48+
process.env.NEXTAUTH_URL = "http://localhost:3000"
49+
expect(getURL("/api/auth/session", { host: "localhost" })).toBeURL(
50+
"http://localhost:3000/api/auth/session"
51+
)
52+
})
53+
54+
it("catch errors", () => {
55+
process.env.NEXTAUTH_URL = "invald-url"
56+
expect(getURL("/api/auth/session", {})).toEqual(
57+
new TypeError("Invalid URL: invald-url")
58+
)
59+
60+
process.env.NEXTAUTH_URL = "file://localhost"
61+
expect(getURL("/api/auth/session", {})).toEqual(
62+
new TypeError("Invalid protocol")
63+
)
64+
})
65+
66+
it("Supports custom base path", () => {
67+
process.env.NEXTAUTH_URL = "http://localhost:3000/custom/api/auth"
68+
expect(getURL("/api/auth/session", {})).toBeURL(
69+
"http://localhost:3000/custom/api/auth/session"
70+
)
71+
72+
// With trailing slash
73+
process.env.NEXTAUTH_URL = "http://localhost:3000/custom/api/auth/"
74+
expect(getURL("/api/auth/session", {})).toBeURL(
75+
"http://localhost:3000/custom/api/auth/session"
76+
)
77+
78+
// Multiple custom segments
79+
process.env.NEXTAUTH_URL = "http://localhost:3000/custom/path/api/auth"
80+
expect(getURL("/api/auth/session", {})).toBeURL(
81+
"http://localhost:3000/custom/path/api/auth/session"
82+
)
83+
84+
process.env.NEXTAUTH_URL = "http://localhost:3000/custom/path/api/auth/"
85+
expect(getURL("/api/auth/session", {})).toBeURL(
86+
"http://localhost:3000/custom/path/api/auth/session"
87+
)
88+
89+
// No /api/auth
90+
process.env.NEXTAUTH_URL = "http://localhost:3000/custom/nextauth"
91+
expect(getURL("/session", {})).toBeURL(
92+
"http://localhost:3000/custom/nextauth/session"
93+
)
94+
95+
// No /api/auth, with trailing slash
96+
process.env.NEXTAUTH_URL = "http://localhost:3000/custom/nextauth/"
97+
expect(getURL("/session", {})).toBeURL(
98+
"http://localhost:3000/custom/nextauth/session"
99+
)
100+
})
101+
})
102+
103+
// Utils
104+
105+
function getURL(
106+
url: Parameters<typeof getURLOriginal>[0],
107+
headers: HeadersInit
108+
) {
109+
return getURLOriginal(url, new Headers(headers))
110+
}
111+
112+
expect.extend({
113+
toBeURL(rec, exp) {
114+
const r = rec.toString()
115+
const e = exp.toString()
116+
const printR = this.utils.printReceived
117+
const printE = this.utils.printExpected
118+
if (r === e) {
119+
return {
120+
message: () => `expected ${printE(e)} not to be ${printR(r)}`,
121+
pass: true,
122+
}
123+
}
124+
return {
125+
message: () => `expected ${printE(e)}, got ${printR(r)}`,
126+
pass: false,
127+
}
128+
},
129+
})
130+
131+
declare global {
132+
// eslint-disable-next-line @typescript-eslint/no-namespace
133+
namespace jest {
134+
interface Matchers<R> {
135+
toBeURL: (expected: string) => R
136+
}
137+
}
138+
}

‎packages/next-auth/tests/middleware.test.ts

+38-65
Original file line numberDiff line numberDiff line change
@@ -6,91 +6,64 @@ it("should not match pages as public paths", async () => {
66
pages: { signIn: "/", error: "/" },
77
secret: "secret",
88
}
9+
const handleMiddleware = withAuth(options) as NextMiddleware
910

10-
const req = new NextRequest("http://127.0.0.1/protected/pathA", {
11-
headers: { authorization: "" },
12-
})
11+
const response = await handleMiddleware(
12+
new NextRequest("http://127.0.0.1/protected/pathA"),
13+
null as any
14+
)
1315

14-
const handleMiddleware = withAuth(options) as NextMiddleware
15-
const res = await handleMiddleware(req, null as any)
16-
expect(res).toBeDefined()
17-
expect(res?.status).toBe(307)
16+
expect(response?.status).toBe(307)
17+
expect(response?.headers.get("location")).toBe(
18+
"http://localhost/?callbackUrl=%2Fprotected%2FpathA"
19+
)
1820
})
1921

2022
it("should not redirect on public paths", async () => {
2123
const options: NextAuthMiddlewareOptions = { secret: "secret" }
2224

23-
const req = new NextRequest("http://127.0.0.1/_next/foo", {
24-
headers: { authorization: "" },
25-
})
25+
const req = new NextRequest("http://127.0.0.1/_next/foo")
2626

2727
const handleMiddleware = withAuth(options) as NextMiddleware
2828
const res = await handleMiddleware(req, null as any)
2929
expect(res).toBeUndefined()
3030
})
3131

32-
it("should redirect according to nextUrl basePath", async () => {
33-
const options: NextAuthMiddlewareOptions = { secret: "secret" }
34-
35-
const req = {
36-
nextUrl: {
37-
pathname: "/protected/pathA",
38-
search: "",
39-
origin: "http://127.0.0.1",
40-
basePath: "/custom-base-path",
41-
},
42-
headers: new Headers({ authorization: "" }),
43-
}
44-
45-
const handleMiddleware = withAuth(options) as NextMiddleware
46-
const res = await handleMiddleware(req as NextRequest, null as any)
47-
expect(res).toBeDefined()
48-
expect(res?.status).toEqual(307)
49-
expect(res?.headers.get("location")).toContain(
50-
"http://127.0.0.1/custom-base-path/api/auth/signin?callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA"
51-
)
52-
})
53-
54-
it("should redirect according to nextUrl basePath", async () => {
55-
// given
32+
it("should respect NextURL#basePath when redirecting", async () => {
5633
const options: NextAuthMiddlewareOptions = { secret: "secret" }
57-
5834
const handleMiddleware = withAuth(options) as NextMiddleware
5935

60-
const req1 = {
61-
nextUrl: {
62-
pathname: "/protected/pathA",
63-
search: "",
64-
origin: "http://127.0.0.1",
65-
basePath: "/custom-base-path",
66-
},
67-
headers: new Headers({ authorization: "" }),
68-
}
69-
// when
70-
const res = await handleMiddleware(req1 as NextRequest, null as any)
71-
72-
// then
73-
expect(res).toBeDefined()
74-
expect(res?.status).toEqual(307)
75-
expect(res?.headers.get("location")).toContain(
36+
const response1 = await handleMiddleware(
37+
{
38+
nextUrl: {
39+
pathname: "/protected/pathA",
40+
search: "",
41+
origin: "http://127.0.0.1",
42+
basePath: "/custom-base-path",
43+
},
44+
} as unknown as NextRequest,
45+
null as any
46+
)
47+
expect(response1?.status).toEqual(307)
48+
expect(response1?.headers.get("location")).toBe(
7649
"http://127.0.0.1/custom-base-path/api/auth/signin?callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA"
7750
)
7851

79-
const req2 = {
80-
nextUrl: {
81-
pathname: "/api/auth/signin",
82-
search: "callbackUrl=%2Fcustom-base-path%2Fprotected%2FpathA",
83-
origin: "http://127.0.0.1",
84-
basePath: "/custom-base-path",
85-
},
86-
headers: new Headers({ authorization: "" }),
87-
}
88-
// and when follow redirect
89-
const resFromRedirectedUrl = await handleMiddleware(
90-
req2 as NextRequest,
52+
// Should not redirect when invoked on sign in page
53+
54+
const response2 = await handleMiddleware(
55+
{
56+
nextUrl: {
57+
pathname: "/api/auth/signin",
58+
searchParams: new URLSearchParams({
59+
callbackUrl: "/custom-base-path/protected/pathA",
60+
}),
61+
origin: "http://127.0.0.1",
62+
basePath: "/custom-base-path",
63+
},
64+
} as unknown as NextRequest,
9165
null as any
9266
)
9367

94-
// then return sign in page
95-
expect(resFromRedirectedUrl).toBeUndefined()
68+
expect(response2).toBeUndefined()
9669
})

‎packages/next-auth/tests/next.test.ts

+35-17
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,47 @@
1-
// import { MissingAPIRoute } from "../src/core/errors"
21
import { nodeHandler } from "./utils"
32

4-
it("Missing req.url throws MISSING_NEXTAUTH_API_ROUTE_ERROR", async () => {
5-
const { res } = await nodeHandler()
3+
it("Missing req.url throws in dev", async () => {
4+
await expect(nodeHandler).rejects.toThrow(new Error("Missing url"))
5+
})
6+
7+
const configErrorMessage =
8+
"There is a problem with the server configuration. Check the server logs for more information."
9+
10+
it("Missing req.url returns config error in prod", async () => {
11+
// @ts-expect-error
12+
process.env.NODE_ENV = "production"
13+
const { res, logger } = await nodeHandler()
14+
15+
expect(logger.error).toBeCalledTimes(1)
16+
const error = new Error("Missing url")
17+
expect(logger.error).toBeCalledWith("INVALID_URL", error)
618

719
expect(res.status).toBeCalledWith(400)
8-
// Moved to host detection in getUrl
9-
// expect(logger.error).toBeCalledTimes(1)
10-
// expect(logger.error).toBeCalledWith(
11-
// "MISSING_NEXTAUTH_API_ROUTE_ERROR",
12-
// expect.any(MissingAPIRoute)
13-
// )
14-
// expect(res.setHeader).toBeCalledWith("content-type", "application/json")
15-
// const body = res.send.mock.calls[0][0]
16-
// expect(JSON.parse(body)).toEqual({
17-
// message:
18-
// "There is a problem with the server configuration. Check the server logs for more information.",
19-
// })
20+
expect(res.json).toBeCalledWith({ message: configErrorMessage })
21+
22+
// @ts-expect-error
23+
process.env.NODE_ENV = "test"
24+
})
25+
26+
it("Missing host throws in dev", async () => {
27+
await expect(
28+
async () =>
29+
await nodeHandler({
30+
req: { query: { nextauth: ["session"] } },
31+
})
32+
).rejects.toThrow(Error)
2033
})
2134

22-
it("Missing host throws 400 in production", async () => {
35+
it("Missing host config error in prod", async () => {
2336
// @ts-expect-error
2437
process.env.NODE_ENV = "production"
25-
const { res } = await nodeHandler()
38+
const { res, logger } = await nodeHandler({
39+
req: { query: { nextauth: ["session"] } },
40+
})
2641
expect(res.status).toBeCalledWith(400)
42+
expect(res.json).toBeCalledWith({ message: configErrorMessage })
43+
44+
expect(logger.error).toBeCalledWith("INVALID_URL", new Error("Missing url"))
2745
// @ts-expect-error
2846
process.env.NODE_ENV = "test"
2947
})

0 commit comments

Comments
 (0)
Please sign in to comment.