Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(core): host detection/NEXTAUTH_URL #6007

Merged
merged 28 commits into from
Dec 11, 2022
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f2cfbd1
rename `host` to `origin` internally
balazsorban44 Dec 9, 2022
d43015e
rename `userOptions` to `authOptions` internally
balazsorban44 Dec 9, 2022
8cbfb2b
use object for `headers` internally
balazsorban44 Dec 9, 2022
f9d26ac
default `method` to GET
balazsorban44 Dec 9, 2022
cf32136
simplify `unstable_getServerSession`
balazsorban44 Dec 9, 2022
07bccf6
allow optional headers
balazsorban44 Dec 9, 2022
b649ca5
revert middleware
balazsorban44 Dec 9, 2022
d9c7b5b
wip getURL
balazsorban44 Dec 9, 2022
2f7dada
revert host detection
balazsorban44 Dec 9, 2022
d4d271d
use old `detectHost`
balazsorban44 Dec 9, 2022
8734abe
fix/add some tests wip
balazsorban44 Dec 9, 2022
1188b10
move more to core, refactor getURL
balazsorban44 Dec 10, 2022
254f03a
better type auth actions
balazsorban44 Dec 10, 2022
ba782de
fix custom path support (w/ api/auth)
balazsorban44 Dec 11, 2022
3323774
add `getURL` tests
balazsorban44 Dec 11, 2022
67502cd
fix email tests
balazsorban44 Dec 11, 2022
c51c266
fix assert tests
balazsorban44 Dec 11, 2022
5b5d97b
custom base without api/auth, with trailing slash
balazsorban44 Dec 11, 2022
d2c2273
remove parseUrl from assert.ts
balazsorban44 Dec 11, 2022
09d28b0
return 400 when wrong url
balazsorban44 Dec 11, 2022
0af22a5
fix tests
balazsorban44 Dec 11, 2022
84069d1
Merge branch 'main' into fix/host-detection
balazsorban44 Dec 11, 2022
d67e0e2
refactor
balazsorban44 Dec 11, 2022
2a8c586
Merge branch 'fix/host-detection' of github.com:nextauthjs/next-auth …
balazsorban44 Dec 11, 2022
d350132
fix protocol in dev
balazsorban44 Dec 11, 2022
64832a0
fix tests
balazsorban44 Dec 11, 2022
6017bdd
fix custom url handling
balazsorban44 Dec 11, 2022
35848e6
add todo comments
balazsorban44 Dec 11, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/next-auth/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
69 changes: 44 additions & 25 deletions packages/next-auth/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -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: {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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,
origin: req.url.origin,
callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl,
csrfToken: req.body?.csrfToken,
cookies: req.cookies,
Expand All @@ -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,
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -269,7 +267,28 @@ 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 })

const response = await toResponse(internalResponse)
Expand Down
32 changes: 16 additions & 16 deletions packages/next-auth/src/core/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { RequestInternal } from "."
import type { InternalOptions } from "./types"

interface InitParams {
host?: string
userOptions: AuthOptions
origin?: string
authOptions: AuthOptions
providerId?: string
action: InternalOptions["action"]
/** Callback URL value extracted from the incoming request. */
Expand All @@ -30,10 +30,10 @@ interface InitParams {

/** Initialize all internal options and cookies. */
export async function init({
userOptions,
authOptions,
providerId,
action,
host,
origin,
cookies: reqCookies,
callbackUrl: reqCallbackUrl,
csrfToken: reqCsrfToken,
Expand All @@ -42,12 +42,12 @@ export async function init({
options: InternalOptions
cookies: cookie.Cookie[]
}> {
const url = parseUrl(host)
const url = parseUrl(origin)

const secret = createSecret({ userOptions, url })
const secret = createSecret({ authOptions, url })

const { providers, provider } = parseProviders({
providers: userOptions.providers,
providers: authOptions.providers,
url,
providerId,
})
Expand All @@ -66,7 +66,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,
Expand All @@ -75,38 +75,38 @@ export async function init({
provider,
cookies: {
...cookie.defaultCookies(
userOptions.useSecureCookies ?? url.base.startsWith("https://")
authOptions.useSecureCookies ?? url.base.startsWith("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: {
secret, // Use application secret if no keys specified
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,
}
Expand Down
13 changes: 5 additions & 8 deletions packages/next-auth/src/core/lib/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
InvalidCallbackUrl,
MissingAdapterMethods,
} from "../errors"
import parseUrl from "../../utils/parse-url"
import { defaultCookies } from "./cookie"

import type { RequestInternal } from ".."
Expand Down Expand Up @@ -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")
Expand All @@ -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}`
)
Expand Down
8 changes: 4 additions & 4 deletions packages/next-auth/src/core/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,16 @@ export function hashToken(token: string, options: InternalOptions<"email">) {
* 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
authOptions: AuthOptions
url: InternalUrl
}) {
const { userOptions, url } = params
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")
)
}
2 changes: 1 addition & 1 deletion packages/next-auth/src/core/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>`,
Expand Down
2 changes: 1 addition & 1 deletion packages/next-auth/src/core/routes/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 1 addition & 1 deletion packages/next-auth/src/core/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
}

Expand Down
2 changes: 1 addition & 1 deletion packages/next-auth/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,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
}
Expand Down
2 changes: 1 addition & 1 deletion packages/next-auth/src/jwt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down