From 495f0139b15b77b6dfa1995174a87287b9bf463f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 7 Nov 2021 14:28:08 +0100 Subject: [PATCH 01/14] feat: add user config assertions --- src/core/errors.ts | 2 ++ src/core/lib/utils.ts | 5 ++- src/next/errors.ts | 11 +++++++ src/next/index.ts | 73 +++++++++++++++++++++++++------------------ 4 files changed, 58 insertions(+), 33 deletions(-) create mode 100644 src/next/errors.ts diff --git a/src/core/errors.ts b/src/core/errors.ts index 93ab69b73c..af26404ca4 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -6,10 +6,12 @@ import type { Adapter } from "../adapters" * @source https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af */ export class UnknownError extends Error { + code: string constructor(error: Error | string) { // Support passing error or string super((error as Error)?.message ?? error) this.name = "UnknownError" + this.code = (error as any).code if (error instanceof Error) { this.stack = error.stack } diff --git a/src/core/lib/utils.ts b/src/core/lib/utils.ts index 8449faa62a..6f497d35f3 100644 --- a/src/core/lib/utils.ts +++ b/src/core/lib/utils.ts @@ -25,9 +25,8 @@ export function hashToken(token: string, options: InternalOptions<"email">) { /** * Secret used salt cookies and tokens (e.g. for CSRF protection). * If no secret option is specified then it creates one on the fly - * based on options passed here. A options contains unique data, such as - * OAuth provider secrets and database credentials it should be sufficent. - */ + * 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 default function createSecret(params: { userOptions: NextAuthOptions url: InternalUrl diff --git a/src/next/errors.ts b/src/next/errors.ts new file mode 100644 index 0000000000..86008d6a7c --- /dev/null +++ b/src/next/errors.ts @@ -0,0 +1,11 @@ +import { UnknownError } from "../core/errors" + +export class MissingRouteError extends UnknownError { + name = "MissingRouteError" + code = "MISSING_ROUTE" +} + +export class MissingSecretError extends UnknownError { + name = "MissingSecretError" + code = "MISSING_SECRET" +} diff --git a/src/next/index.ts b/src/next/index.ts index c0f44a930e..3d332de84f 100644 --- a/src/next/index.ts +++ b/src/next/index.ts @@ -8,33 +8,51 @@ import { NextAuthHandler } from "../core" import { NextAuthAction, NextAuthRequest, NextAuthResponse } from "../lib/types" import { set as setCookie } from "../core/lib/cookie" import logger, { setLogger } from "../lib/logger" +import { MissingRouteError, MissingSecretError } from "./errors" + +/** + * Verify that the user configured `next-auth` correctly. + * Good place to mention deprecations as well. + */ +function assertConfig(options: { + query?: NextApiRequest["query"] + secret?: string + host?: string +}) { + if (!options.query?.nextauth) { + return new MissingRouteError( + "Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly." + ) + } + + if (!options.secret) { + if (process.env.NODE_ENV === "production") { + return new MissingSecretError("Please define a `secret` for production.") + } else { + logger.warn("NO_SECRET") + } + } + + if (!options.host) logger.warn("NEXTAUTH_URL") +} async function NextAuthNextHandler( req: NextApiRequest, res: NextApiResponse, options: NextAuthOptions ) { - setLogger(options.logger) + setLogger(options.logger, options.debug) - if (!req.query.nextauth) { - const error = new Error( - "Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly." - ) + const host = process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL + + const error = assertConfig({ query: req.query, secret: options.secret, host }) - logger.error("MISSING_NEXTAUTH_API_ROUTE_ERROR", error) + if (error) { + logger.error(error.code, error) return res.status(500).send(error.message) } - const host = process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL - if (!host) logger.warn("NEXTAUTH_URL") - - const { - body, - redirect, - cookies, - headers, - status = 200, - } = await NextAuthHandler({ + const handler = await NextAuthHandler({ req: { host, body: req.body, @@ -49,28 +67,25 @@ async function NextAuthNextHandler( options, }) - res.status(status) + res.status(handler.status ?? 200) - cookies?.forEach((cookie) => { - setCookie(res, cookie.name, cookie.value, cookie.options) - }) - headers?.forEach((header) => { - res.setHeader(header.key, header.value) - }) + handler.cookies?.forEach((c) => setCookie(res, c.name, c.value, c.options)) + + handler.headers?.forEach((h) => res.setHeader(h.key, h.value)) - if (redirect) { + if (handler.redirect) { // If the request expects a return URL, send it as JSON // instead of doing an actual redirect. if (req.body?.json !== "true") { // Could chain. .end() when lowest target is Node 14 // https://github.com/nodejs/node/issues/33148 - res.status(302).setHeader("Location", redirect) + res.status(302).setHeader("Location", handler.redirect) return res.end() } - return res.json({ url: redirect }) + return res.json({ url: handler.redirect }) } - return res.send(body) + return res.send(handler.body) } function NextAuth(options: NextAuthOptions): any @@ -114,9 +129,7 @@ export async function getServerSession( const { body, cookies } = session - cookies?.forEach((cookie) => { - setCookie(context.res, cookie.name, cookie.value, cookie.options) - }) + cookies?.forEach((c) => setCookie(context.res, c.name, c.value, c.options)) if (body && Object.keys(body).length) return body as Session return null From 44530ec9a5b734c73ec48b3782befdd67baa6a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 7 Nov 2021 14:28:41 +0100 Subject: [PATCH 02/14] refactor: eliminate `_NEXTAUTH_DEBUG` env variable --- src/core/init.ts | 5 ----- src/lib/logger.ts | 19 +++++++++---------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/core/init.ts b/src/core/init.ts index 3c0756567c..30ec9c1fac 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -40,11 +40,6 @@ export async function init({ options: InternalOptions cookies: cookie.Cookie[] }> { - // If debug enabled, set ENV VAR so that logger logs debug messages - if (userOptions.debug) { - ;(process.env._NEXTAUTH_DEBUG as any) = true - } - const url = parseUrl(host) const secret = createSecret({ userOptions, url }) diff --git a/src/lib/logger.ts b/src/lib/logger.ts index ab03d536e5..7c56c08155 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -25,13 +25,7 @@ function hasErrorProperty( * [Documentation](https://next-auth.js.org/configuration/options#logger) */ export interface LoggerInstance extends Record { - warn: ( - code: - | "JWT_AUTO_GENERATED_SIGNING_KEY" - | "JWT_AUTO_GENERATED_ENCRYPTION_KEY" - | "NEXTAUTH_URL" - | "NO_CSRF_TOKEN" - ) => void + warn: (code: "NEXTAUTH_URL" | "NO_CSRF_TOKEN" | "NO_SECRET") => void error: ( code: string, /** @@ -61,16 +55,21 @@ const _logger: LoggerInstance = { ) }, debug(code, metadata) { - if (!process?.env?._NEXTAUTH_DEBUG) return console.log(`[next-auth][debug][${code}]`, metadata) }, } /** - * Override the built-in logger. + * Override the built-in logger with user's implementation. * Any `undefined` level will use the default logger. */ -export function setLogger(newLogger: Partial = {}) { +export function setLogger( + newLogger: Partial = {}, + debug?: boolean +) { + // Turn off debug logging if `debug` isn't set to `true` + if (!debug) _logger.debug = () => {} + if (newLogger.error) _logger.error = newLogger.error if (newLogger.warn) _logger.warn = newLogger.warn if (newLogger.debug) _logger.debug = newLogger.debug From 4307f6ca17427aebd762b1ce90c74572dc131d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 7 Nov 2021 14:29:15 +0100 Subject: [PATCH 03/14] chore: remove `jwt` config from dev app --- app/pages/api/auth/[...nextauth].ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/app/pages/api/auth/[...nextauth].ts b/app/pages/api/auth/[...nextauth].ts index fa90c6d8fe..894927428e 100644 --- a/app/pages/api/auth/[...nextauth].ts +++ b/app/pages/api/auth/[...nextauth].ts @@ -168,10 +168,7 @@ export const authOptions: NextAuthOptions = { primaryUserFlow: process.env.AZURE_B2C_PRIMARY_USER_FLOW, }), ], - jwt: { - encryption: true, - secret: process.env.SECRET, - }, + secret: process.env.SECRET, debug: true, theme: { colorScheme: "auto", From 2996778d8d2dcaccf17cb5392f5e5a15b0955c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 7 Nov 2021 14:45:30 +0100 Subject: [PATCH 04/14] chore: rename errors/warnings --- src/next/errors.ts | 12 ++++++------ src/next/index.ts | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/next/errors.ts b/src/next/errors.ts index 86008d6a7c..d0224d05f4 100644 --- a/src/next/errors.ts +++ b/src/next/errors.ts @@ -1,11 +1,11 @@ import { UnknownError } from "../core/errors" -export class MissingRouteError extends UnknownError { - name = "MissingRouteError" - code = "MISSING_ROUTE" +export class NoAPIRouteError extends UnknownError { + name = "NoAPIRouteError" + code = "MISSING_NEXTAUTH_API_ROUTE_ERROR" } -export class MissingSecretError extends UnknownError { - name = "MissingSecretError" - code = "MISSING_SECRET" +export class NoSecretError extends UnknownError { + name = "NoSecretError" + code = "NO_SECRET" } diff --git a/src/next/index.ts b/src/next/index.ts index 3d332de84f..baeefc7e58 100644 --- a/src/next/index.ts +++ b/src/next/index.ts @@ -8,7 +8,7 @@ import { NextAuthHandler } from "../core" import { NextAuthAction, NextAuthRequest, NextAuthResponse } from "../lib/types" import { set as setCookie } from "../core/lib/cookie" import logger, { setLogger } from "../lib/logger" -import { MissingRouteError, MissingSecretError } from "./errors" +import { NoAPIRouteError, NoSecretError } from "./errors" /** * Verify that the user configured `next-auth` correctly. @@ -20,14 +20,14 @@ function assertConfig(options: { host?: string }) { if (!options.query?.nextauth) { - return new MissingRouteError( + return new NoAPIRouteError( "Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly." ) } if (!options.secret) { if (process.env.NODE_ENV === "production") { - return new MissingSecretError("Please define a `secret` for production.") + return new NoSecretError("Please define a `secret` in production.") } else { logger.warn("NO_SECRET") } From 43afac66f9a4deb4aadfa165cbbdce0e181cb60c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 14 Nov 2021 00:17:33 +0100 Subject: [PATCH 05/14] refactor: assert config in core --- src/core/errors.ts | 10 +++++++ src/core/index.ts | 53 ++++++++++++++++++++++++++++++++----- src/core/pages/error.tsx | 20 +++++++++----- src/core/pages/index.ts | 47 +++++++++++++++++++-------------- src/next/errors.ts | 11 -------- src/next/index.ts | 56 ++++++++-------------------------------- 6 files changed, 109 insertions(+), 88 deletions(-) delete mode 100644 src/next/errors.ts diff --git a/src/core/errors.ts b/src/core/errors.ts index af26404ca4..e5967dd2d6 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -38,6 +38,16 @@ export class AccountNotLinkedError extends UnknownError { name = "AccountNotLinkedError" } +export class NoAPIRouteError extends UnknownError { + name = "NoAPIRouteError" + code = "MISSING_NEXTAUTH_API_ROUTE_ERROR" +} + +export class NoSecretError extends UnknownError { + name = "NoSecretError" + code = "NO_SECRET" +} + type Method = (...args: any[]) => Promise export function upperSnake(s: string) { diff --git a/src/core/index.ts b/src/core/index.ts index 17deb74a46..1725e4cf9f 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,11 +1,13 @@ -import logger from "../lib/logger" +import logger, { setLogger } from "../lib/logger" import * as routes from "./routes" import renderPage from "./pages" -import type { NextAuthOptions } from "./types" import { init } from "./init" -import { Cookie } from "./lib/cookie" +import { NoAPIRouteError, NoSecretError } from "./errors" -import { NextAuthAction } from "../lib/types" +import type { NextAuthOptions } from "./types" +import type { NextAuthAction } from "../lib/types" +import type { Cookie } from "./lib/cookie" +import type { ErrorType } from "./pages/error" export interface IncomingRequest { /** @default "http://localhost:3000" */ @@ -40,10 +42,49 @@ interface NextAuthHandlerParams { options: NextAuthOptions } +/** + * Verify that the user configured `next-auth` correctly. + * Good place to mention deprecations as well. + * + * TODO: Make these less Next.js specific + */ +function assertConfig(params: NextAuthHandlerParams) { + if (!params.req.query?.nextauth) { + return new NoAPIRouteError( + "Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly." + ) + } + + if (!params.options.secret) { + if (process.env.NODE_ENV === "production") { + return new NoSecretError("Please define a `secret` in production.") + } else { + logger.warn("NO_SECRET") + } + } + + if (!params.req.host) logger.warn("NEXTAUTH_URL") +} + export async function NextAuthHandler< Body extends string | Record | any[] >(params: NextAuthHandlerParams): Promise> { const { options: userOptions, req } = params + setLogger(userOptions.logger, userOptions.debug) + + const configError = assertConfig(params) + // Bail out early if there's an error in the user config + if (configError) { + logger.error(configError.code, configError) + if (userOptions.pages?.error) { + return { + redirect: `${userOptions.pages.error}?$error=Configuration`, + } + } + const render = renderPage({ theme: params.options.theme }) + return render.error({ error: "configuration" }) + } + const { action, providerId, error } = req const { options, cookies } = await init({ @@ -62,7 +103,7 @@ export async function NextAuthHandler< req.headers?.Authorization?.replace("Bearer ", "") if (req.method === "GET") { - const render = renderPage({ options, query: req.query, cookies }) + const render = renderPage({ ...options, query: req.query, cookies }) const { pages } = options switch (action) { case "providers": @@ -137,7 +178,7 @@ export async function NextAuthHandler< return { redirect: `${options.url}/signin?error=${error}`, cookies } } - return render.error({ error }) + return render.error({ error: error as ErrorType }) default: } } else if (req.method === "POST") { diff --git a/src/core/pages/error.tsx b/src/core/pages/error.tsx index 35e764bf23..b6c6920d6a 100644 --- a/src/core/pages/error.tsx +++ b/src/core/pages/error.tsx @@ -2,8 +2,8 @@ import { Theme } from "../.." import { InternalUrl } from "../../lib/parse-url" export interface ErrorProps { - url: InternalUrl - theme: Theme + url?: InternalUrl + theme?: Theme error?: string } @@ -14,19 +14,25 @@ interface ErrorView { signin?: JSX.Element } +export type ErrorType = + | "default" + | "configuration" + | "accessdenied" + | "verification" + /** Renders an error page. */ export default function ErrorPage(props: ErrorProps) { const { url, error = "default", theme } = props const signinPageUrl = `${url}/signin` - const errors: Record = { + const errors: Record = { default: { status: 200, heading: "Error", message: (

- - {url.host} + + {url?.host}

), @@ -85,12 +91,12 @@ export default function ErrorPage(props: ErrorProps) { dangerouslySetInnerHTML={{ __html: ` :root { - --brand-color: ${theme.brandColor} + --brand-color: ${theme?.brandColor}; } `, }} /> - {theme.logo && Logo} + {theme?.logo && Logo}

{heading}

{message}
diff --git a/src/core/pages/index.ts b/src/core/pages/index.ts index 87047e4771..65dcb621c9 100644 --- a/src/core/pages/index.ts +++ b/src/core/pages/index.ts @@ -4,21 +4,25 @@ import SignoutPage from "./signout" import VerifyRequestPage from "./verify-request" import ErrorPage from "./error" import css from "../../css" -import { InternalOptions } from "../../lib/types" -import { IncomingRequest, OutgoingResponse } from ".." -import { Cookie } from "../lib/cookie" + +import type { InternalOptions } from "../../lib/types" +import type { IncomingRequest, OutgoingResponse } from ".." +import type { Cookie } from "../lib/cookie" +import type { ErrorType } from "./error" + +type RenderPageParams = { + query?: IncomingRequest["query"] + cookies?: Cookie[] +} & Partial< + Pick< + InternalOptions, + "url" | "callbackUrl" | "csrfToken" | "providers" | "theme" + > +> /** Takes a request and response, and gives renderable pages */ -export default function renderPage({ - options, - query, - cookies, -}: { - options: InternalOptions - query: IncomingRequest["query"] - cookies: Cookie[] -}) { - const { url, callbackUrl, csrfToken, providers, theme } = options +export default function renderPage(params: RenderPageParams) { + const { url, theme, query, cookies } = params function send({ html, title, status }: any): OutgoingResponse { return { @@ -26,7 +30,7 @@ export default function renderPage({ status, headers: [{ key: "Content-Type", value: "text/html" }], body: `${title}
${renderToString(html)}
`, } } @@ -35,9 +39,9 @@ export default function renderPage({ signin(props?: any) { return send({ html: SigninPage({ - csrfToken, - providers, - callbackUrl, + csrfToken: params.csrfToken, + providers: params.providers, + callbackUrl: params.callbackUrl, theme, ...query, ...props, @@ -47,7 +51,12 @@ export default function renderPage({ }, signout(props?: any) { return send({ - html: SignoutPage({ csrfToken, url, theme, ...props }), + html: SignoutPage({ + csrfToken: params.csrfToken, + url, + theme, + ...props, + }), title: "Sign Out", }) }, @@ -57,7 +66,7 @@ export default function renderPage({ title: "Verify Request", }) }, - error(props?: any) { + error(props?: { error?: ErrorType }) { return send({ ...ErrorPage({ url, theme, ...props }), title: "Error", diff --git a/src/next/errors.ts b/src/next/errors.ts deleted file mode 100644 index d0224d05f4..0000000000 --- a/src/next/errors.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { UnknownError } from "../core/errors" - -export class NoAPIRouteError extends UnknownError { - name = "NoAPIRouteError" - code = "MISSING_NEXTAUTH_API_ROUTE_ERROR" -} - -export class NoSecretError extends UnknownError { - name = "NoSecretError" - code = "NO_SECRET" -} diff --git a/src/next/index.ts b/src/next/index.ts index a03d53ee44..3a1a4deace 100644 --- a/src/next/index.ts +++ b/src/next/index.ts @@ -1,60 +1,26 @@ -import { +import { NextAuthHandler } from "../core" +import { set as setCookie } from "../core/lib/cookie" + +import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse, } from "next" -import { NextAuthOptions, Session } from ".." -import { NextAuthHandler } from "../core" -import { NextAuthAction, NextAuthRequest, NextAuthResponse } from "../lib/types" -import { set as setCookie } from "../core/lib/cookie" -import logger, { setLogger } from "../lib/logger" -import { NoAPIRouteError, NoSecretError } from "./errors" - -/** - * Verify that the user configured `next-auth` correctly. - * Good place to mention deprecations as well. - */ -function assertConfig(options: { - query?: NextApiRequest["query"] - secret?: string - host?: string -}) { - if (!options.query?.nextauth) { - return new NoAPIRouteError( - "Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly." - ) - } - - if (!options.secret) { - if (process.env.NODE_ENV === "production") { - return new NoSecretError("Please define a `secret` in production.") - } else { - logger.warn("NO_SECRET") - } - } - - if (!options.host) logger.warn("NEXTAUTH_URL") -} +import type { NextAuthOptions, Session } from ".." +import type { + NextAuthAction, + NextAuthRequest, + NextAuthResponse, +} from "../lib/types" async function NextAuthNextHandler( req: NextApiRequest, res: NextApiResponse, options: NextAuthOptions ) { - setLogger(options.logger, options.debug) - - const host = process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL - - const error = assertConfig({ query: req.query, secret: options.secret, host }) - - if (error) { - logger.error(error.code, error) - return res.status(500).send(error.message) - } - const handler = await NextAuthHandler({ req: { - host, + host: (process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL) as string, body: req.body, query: req.query, cookies: req.cookies, From 40dc25c593b04858dc3c25fd4a98300f4a9cd74f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 14 Nov 2021 00:19:56 +0100 Subject: [PATCH 06/14] chore: add `renderPage` comment --- src/core/pages/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/pages/index.ts b/src/core/pages/index.ts index 65dcb621c9..b188215e47 100644 --- a/src/core/pages/index.ts +++ b/src/core/pages/index.ts @@ -20,7 +20,10 @@ type RenderPageParams = { > > -/** Takes a request and response, and gives renderable pages */ +/** + * Unless the user defines their [own pages](https://next-auth.js.org/configuration/pages), + * we render a set of default ones, using Preact SSR. + */ export default function renderPage(params: RenderPageParams) { const { url, theme, query, cookies } = params From 9b88dbcb3fc6eebd241fceac0ca664e46247c158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 14 Nov 2021 00:26:44 +0100 Subject: [PATCH 07/14] chore: dev app improvements --- app/next.config.js | 3 +++ app/package.json | 1 + app/pages/api/examples/jwt.js | 6 ++---- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/next.config.js b/app/next.config.js index 2d35b316c3..8696543e6f 100644 --- a/app/next.config.js +++ b/app/next.config.js @@ -18,6 +18,9 @@ module.exports = { return config }, + typescript: { + ignoreBuildErrors: true, + }, experimental: { externalDir: true, }, diff --git a/app/package.json b/app/package.json index 7e892f7a8b..9e4bca56e6 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "clean": "rm -rf .next", "dev": "npm-run-all --parallel dev:next watch:css copy:css ", "dev:next": "next dev", + "build": "next build", "copy:css": "cpx \"../css/**/*\" src/css --watch", "watch:css": "cd .. && npm run watch:css", "start": "next start", diff --git a/app/pages/api/examples/jwt.js b/app/pages/api/examples/jwt.js index 9c5f11c915..3ff2c9cb2c 100644 --- a/app/pages/api/examples/jwt.js +++ b/app/pages/api/examples/jwt.js @@ -1,9 +1,7 @@ // This is an example of how to read a JSON Web Token from an API route -import jwt from "next-auth/jwt" - -const secret = process.env.SECRET +import { getToken } from "next-auth/jwt" export default async (req, res) => { - const token = await jwt.getToken({ req, secret, encryption: true }) + const token = await getToken({ req, secret: process.env.SECRET }) res.send(JSON.stringify(token, null, 2)) } From e390e2f1e92ddb08ecbbe7ee60223b8e1eb6ca46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 14 Nov 2021 00:26:55 +0100 Subject: [PATCH 08/14] fix: remove `$` from url --- src/core/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/index.ts b/src/core/index.ts index 1725e4cf9f..7079519f27 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -78,7 +78,7 @@ export async function NextAuthHandler< logger.error(configError.code, configError) if (userOptions.pages?.error) { return { - redirect: `${userOptions.pages.error}?$error=Configuration`, + redirect: `${userOptions.pages.error}?error=Configuration`, } } const render = renderPage({ theme: params.options.theme }) From 05ed36254c9484906301374a826f6d1545e463dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 14 Nov 2021 01:22:09 +0100 Subject: [PATCH 09/14] refactor: move all config assertions together --- src/core/errors.ts | 23 ++++++++++++--- src/core/index.ts | 47 +++++++++--------------------- src/core/lib/assert.ts | 58 +++++++++++++++++++++++++++++++++++++ src/core/routes/callback.ts | 40 ++----------------------- src/core/routes/signin.ts | 14 +++------ src/lib/logger.ts | 4 ++- 6 files changed, 101 insertions(+), 85 deletions(-) create mode 100644 src/core/lib/assert.ts diff --git a/src/core/errors.ts b/src/core/errors.ts index e5967dd2d6..84a45255fd 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -38,16 +38,31 @@ export class AccountNotLinkedError extends UnknownError { name = "AccountNotLinkedError" } -export class NoAPIRouteError extends UnknownError { - name = "NoAPIRouteError" +export class MissingAPIRoute extends UnknownError { + name = "MissingAPIRouteError" code = "MISSING_NEXTAUTH_API_ROUTE_ERROR" } -export class NoSecretError extends UnknownError { - name = "NoSecretError" +export class MissingSecret extends UnknownError { + name = "MissingSecretError" code = "NO_SECRET" } +export class MissingAuthorize extends UnknownError { + name = "MissingAuthorizeError" + code = "CALLBACK_CREDENTIALS_HANDLER_ERROR" +} + +export class MissingAdapter extends UnknownError { + name = "MissingAdapterError" + code = "EMAIL_REQUIRES_ADAPTER_ERROR" +} + +export class UnsupportedStrategy extends UnknownError { + name = "UnsupportedStrategyError" + code = "CALLBACK_CREDENTIALS_JWT_ERROR" +} + type Method = (...args: any[]) => Promise export function upperSnake(s: string) { diff --git a/src/core/index.ts b/src/core/index.ts index 7079519f27..8bb5f3eaa2 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -2,7 +2,7 @@ import logger, { setLogger } from "../lib/logger" import * as routes from "./routes" import renderPage from "./pages" import { init } from "./init" -import { NoAPIRouteError, NoSecretError } from "./errors" +import { assertConfig } from "./lib/assert" import type { NextAuthOptions } from "./types" import type { NextAuthAction } from "../lib/types" @@ -37,51 +37,32 @@ export interface OutgoingResponse< cookies?: Cookie[] } -interface NextAuthHandlerParams { +export interface NextAuthHandlerParams { req: IncomingRequest options: NextAuthOptions } -/** - * Verify that the user configured `next-auth` correctly. - * Good place to mention deprecations as well. - * - * TODO: Make these less Next.js specific - */ -function assertConfig(params: NextAuthHandlerParams) { - if (!params.req.query?.nextauth) { - return new NoAPIRouteError( - "Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly." - ) - } - - if (!params.options.secret) { - if (process.env.NODE_ENV === "production") { - return new NoSecretError("Please define a `secret` in production.") - } else { - logger.warn("NO_SECRET") - } - } - - if (!params.req.host) logger.warn("NEXTAUTH_URL") -} - export async function NextAuthHandler< Body extends string | Record | any[] >(params: NextAuthHandlerParams): Promise> { const { options: userOptions, req } = params + setLogger(userOptions.logger, userOptions.debug) - const configError = assertConfig(params) - // Bail out early if there's an error in the user config - if (configError) { - logger.error(configError.code, configError) - if (userOptions.pages?.error) { + const assertionResult = assertConfig(params) + + if (typeof assertionResult === "string") { + logger.warn(assertionResult) + } else if (assertionResult instanceof Error) { + // Bail out early if there's an error in the user config + const { pages, theme } = userOptions + logger.error(assertionResult.code, assertionResult) + if (pages?.error) { return { - redirect: `${userOptions.pages.error}?error=Configuration`, + redirect: `${pages.error}?error=Configuration`, } } - const render = renderPage({ theme: params.options.theme }) + const render = renderPage({ theme }) return render.error({ error: "configuration" }) } diff --git a/src/core/lib/assert.ts b/src/core/lib/assert.ts new file mode 100644 index 0000000000..4372d1765d --- /dev/null +++ b/src/core/lib/assert.ts @@ -0,0 +1,58 @@ +import { + MissingAdapter, + MissingAPIRoute, + MissingAuthorize, + MissingSecret, + UnknownError, + UnsupportedStrategy, +} from "../errors" + +import type { NextAuthHandlerParams } from ".." +import type { WarningCode } from "../../lib/logger" + +/** + * Verify that the user configured `next-auth` correctly. + * Good place to mention deprecations as well. + * + * REVIEW: Make some of these and corresponding docs less Next.js specific? + */ +export function assertConfig( + params: NextAuthHandlerParams +): UnknownError | WarningCode | undefined { + const { options, req } = params + + if (!req.query?.nextauth) { + return new MissingAPIRoute( + "Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly." + ) + } + + if (!options.secret) { + if (process.env.NODE_ENV === "production") { + return new MissingSecret("Please define a `secret` in production.") + } else { + return "NO_SECRET" + } + } + + if (!req.host) return "NEXTAUTH_URL" + + if (options.providers.some((p) => p.type === "credentials")) { + if (options.session?.strategy === "database" || options.adapter) { + return new UnsupportedStrategy( + "Signin in with credentials only supported if JWT strategy is enabled" + ) + } + if ( + options.providers.every((p) => p.type !== "credentials" || p.authorize) + ) { + return new MissingAuthorize( + "Must define an authorize() handler to use credentials authentication provider" + ) + } + } + + if (!options.adapter && options.providers.some((p) => p.type === "email")) { + return new MissingAdapter("E-mail login requires an adapter.") + } +} diff --git a/src/core/routes/callback.ts b/src/core/routes/callback.ts index adbe874f63..699e47980d 100644 --- a/src/core/routes/callback.ts +++ b/src/core/routes/callback.ts @@ -201,15 +201,9 @@ export default async function callback(params: { } } else if (provider.type === "email") { try { - if (!adapter) { - logger.error( - "EMAIL_REQUIRES_ADAPTER_ERROR", - new Error("E-mail login requires an adapter but it was undefined") - ) - return { redirect: `${url}/error?error=Configuration`, cookies } - } - - const { useVerificationToken, getUserByEmail } = adapter + // Verified in `assertConfig` + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { useVerificationToken, getUserByEmail } = adapter! const token = query?.token const identifier = query?.email @@ -340,34 +334,6 @@ export default async function callback(params: { return { redirect: `${url}/error?error=Callback`, cookies } } } else if (provider.type === "credentials" && method === "POST") { - if (!useJwtSession) { - logger.error( - "CALLBACK_CREDENTIALS_JWT_ERROR", - new Error( - "Signin in with credentials is only supported if JSON Web Tokens are enabled" - ) - ) - return { - status: 500, - redirect: `${url}/error?error=Configuration`, - cookies, - } - } - - if (!provider.authorize) { - logger.error( - "CALLBACK_CREDENTIALS_HANDLER_ERROR", - new Error( - "Must define an authorize() handler to use credentials authentication provider" - ) - ) - return { - status: 500, - redirect: `${url}/error?error=Configuration`, - cookies, - } - } - const credentials = body let user: User diff --git a/src/core/routes/signin.ts b/src/core/routes/signin.ts index 4c5977075f..833b5a9fbe 100644 --- a/src/core/routes/signin.ts +++ b/src/core/routes/signin.ts @@ -30,14 +30,6 @@ export default async function signin(params: { return { redirect: `${url}/error?error=OAuthSignin` } } } else if (provider.type === "email") { - if (!adapter) { - logger.error( - "EMAIL_REQUIRES_ADAPTER_ERROR", - new Error("E-mail login requires an adapter but it was undefined") - ) - return { redirect: `${url}/error?error=Configuration` } - } - // Note: Technically the part of the email address local mailbox element // (everything before the @ symbol) should be treated as 'case sensitive' // according to RFC 2821, but in practice this causes more problems than @@ -45,7 +37,9 @@ export default async function signin(params: { // complains about this we can make strict RFC 2821 compliance an option. const email = body?.email?.toLowerCase() ?? null - const { getUserByEmail } = adapter + // Verified in `assertConfig` + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const { getUserByEmail } = adapter! // If is an existing user return a user object (otherwise use placeholder) const user: User = (email ? await getUserByEmail(email) : null) ?? { email, @@ -83,7 +77,7 @@ export default async function signin(params: { try { await emailSignin(email, options) } catch (error) { - logger.error("SIGNIN_EMAIL_ERROR", (error as Error)) + logger.error("SIGNIN_EMAIL_ERROR", error as Error) return { redirect: `${url}/error?error=EmailSignin` } } diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 7c56c08155..aa6899e571 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -19,13 +19,15 @@ function hasErrorProperty( return !!(x as any)?.error } +export type WarningCode = "NEXTAUTH_URL" | "NO_CSRF_TOKEN" | "NO_SECRET" + /** * Override any of the methods, and the rest will use the default logger. * * [Documentation](https://next-auth.js.org/configuration/options#logger) */ export interface LoggerInstance extends Record { - warn: (code: "NEXTAUTH_URL" | "NO_CSRF_TOKEN" | "NO_SECRET") => void + warn: (code: WarningCode) => void error: ( code: string, /** From 82a5d8000dca4043170e2c11d933b51d537d8348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 14 Nov 2021 01:28:52 +0100 Subject: [PATCH 10/14] chore: extract type --- src/core/lib/assert.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/core/lib/assert.ts b/src/core/lib/assert.ts index 4372d1765d..50d8134eb5 100644 --- a/src/core/lib/assert.ts +++ b/src/core/lib/assert.ts @@ -3,13 +3,19 @@ import { MissingAPIRoute, MissingAuthorize, MissingSecret, - UnknownError, UnsupportedStrategy, } from "../errors" import type { NextAuthHandlerParams } from ".." import type { WarningCode } from "../../lib/logger" +type ConfigError = + | MissingAPIRoute + | MissingSecret + | UnsupportedStrategy + | MissingAuthorize + | MissingAdapter + /** * Verify that the user configured `next-auth` correctly. * Good place to mention deprecations as well. @@ -18,7 +24,7 @@ import type { WarningCode } from "../../lib/logger" */ export function assertConfig( params: NextAuthHandlerParams -): UnknownError | WarningCode | undefined { +): ConfigError | WarningCode | undefined { const { options, req } = params if (!req.query?.nextauth) { From a5910d36692554fa109fa817aef29db27e9d1bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 14 Nov 2021 01:36:50 +0100 Subject: [PATCH 11/14] chore: reduce provider loops --- src/core/lib/assert.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/core/lib/assert.ts b/src/core/lib/assert.ts index 50d8134eb5..075d72d695 100644 --- a/src/core/lib/assert.ts +++ b/src/core/lib/assert.ts @@ -43,7 +43,14 @@ export function assertConfig( if (!req.host) return "NEXTAUTH_URL" - if (options.providers.some((p) => p.type === "credentials")) { + let hasCredentials, hasEmail + + options.providers.forEach(({ type }) => { + if (type === "credentials") hasCredentials = true + else if (type === "email") hasEmail = true + }) + + if (hasCredentials) { if (options.session?.strategy === "database" || options.adapter) { return new UnsupportedStrategy( "Signin in with credentials only supported if JWT strategy is enabled" @@ -58,7 +65,7 @@ export function assertConfig( } } - if (!options.adapter && options.providers.some((p) => p.type === "email")) { + if (!options.adapter && hasEmail) { return new MissingAdapter("E-mail login requires an adapter.") } } From d7d7a4c19a7b5b9a853c0c80f2f39e6ecff92558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 14 Nov 2021 01:39:49 +0100 Subject: [PATCH 12/14] chore: remove unused warning code --- src/lib/logger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/logger.ts b/src/lib/logger.ts index aa6899e571..9ee75e1f2b 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -19,7 +19,7 @@ function hasErrorProperty( return !!(x as any)?.error } -export type WarningCode = "NEXTAUTH_URL" | "NO_CSRF_TOKEN" | "NO_SECRET" +export type WarningCode = "NEXTAUTH_URL" | "NO_SECRET" /** * Override any of the methods, and the rest will use the default logger. From 898c6843a8145c9bfb19a92a9dd809eb0f2d73ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Mon, 15 Nov 2021 18:39:25 +0100 Subject: [PATCH 13/14] refactor: optimize `assertConfig` --- src/core/lib/assert.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/core/lib/assert.ts b/src/core/lib/assert.ts index 075d72d695..6662915efc 100644 --- a/src/core/lib/assert.ts +++ b/src/core/lib/assert.ts @@ -51,21 +51,27 @@ export function assertConfig( }) if (hasCredentials) { - if (options.session?.strategy === "database" || options.adapter) { + const dbStrategy = options.session?.strategy === "database" + const onlyCredentials = !options.providers.some( + (p) => p.type !== "credentials" + ) + if (dbStrategy || onlyCredentials) { return new UnsupportedStrategy( "Signin in with credentials only supported if JWT strategy is enabled" ) } - if ( - options.providers.every((p) => p.type !== "credentials" || p.authorize) - ) { + + const credentialsNoAuthorize = options.providers.some( + (p) => p.type === "credentials" && !p.authorize + ) + if (credentialsNoAuthorize) { return new MissingAuthorize( "Must define an authorize() handler to use credentials authentication provider" ) } } - if (!options.adapter && hasEmail) { + if (hasEmail && !options.adapter) { return new MissingAdapter("E-mail login requires an adapter.") } } From 1d3f5a99a3ac6e22eb902eaa603f81c5f699e74c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Mon, 15 Nov 2021 18:41:15 +0100 Subject: [PATCH 14/14] chore: comment out EmailAdapter in dev app when no adapter --- app/pages/api/auth/[...nextauth].ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/app/pages/api/auth/[...nextauth].ts b/app/pages/api/auth/[...nextauth].ts index 894927428e..d2c146e794 100644 --- a/app/pages/api/auth/[...nextauth].ts +++ b/app/pages/api/auth/[...nextauth].ts @@ -42,15 +42,15 @@ export const authOptions: NextAuthOptions = { providers: [ // E-mail // Start fake e-mail server with `npm run start:email` - EmailProvider({ - server: { - host: "127.0.0.1", - auth: null, - secure: false, - port: 1025, - tls: { rejectUnauthorized: false }, - }, - }), + // EmailProvider({ + // server: { + // host: "127.0.0.1", + // auth: null, + // secure: false, + // port: 1025, + // tls: { rejectUnauthorized: false }, + // }, + // }), // Credentials CredentialsProvider({ name: "Credentials",