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/auth/[...nextauth].ts b/app/pages/api/auth/[...nextauth].ts index c979e1635f..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", @@ -168,9 +168,7 @@ export const authOptions: NextAuthOptions = { primaryUserFlow: process.env.AZURE_B2C_PRIMARY_USER_FLOW, }), ], - jwt: { - secret: process.env.SECRET, - }, + secret: process.env.SECRET, debug: true, theme: { colorScheme: "auto", 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)) } diff --git a/src/core/errors.ts b/src/core/errors.ts index 93ab69b73c..84a45255fd 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 } @@ -36,6 +38,31 @@ export class AccountNotLinkedError extends UnknownError { name = "AccountNotLinkedError" } +export class MissingAPIRoute extends UnknownError { + name = "MissingAPIRouteError" + code = "MISSING_NEXTAUTH_API_ROUTE_ERROR" +} + +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 e9b36e158e..b26f0d59bc 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,11 +1,14 @@ -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, SessionStore } from "./lib/cookie" +import { assertConfig } from "./lib/assert" +import { SessionStore } from "./lib/cookie" -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" */ @@ -35,7 +38,7 @@ export interface OutgoingResponse< cookies?: Cookie[] } -interface NextAuthHandlerParams { +export interface NextAuthHandlerParams { req: IncomingRequest options: NextAuthOptions } @@ -44,6 +47,26 @@ export async function NextAuthHandler< Body extends string | Record | any[] >(params: NextAuthHandlerParams): Promise> { const { options: userOptions, req } = params + + setLogger(userOptions.logger, userOptions.debug) + + 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: `${pages.error}?error=Configuration`, + } + } + const render = renderPage({ theme }) + return render.error({ error: "configuration" }) + } + const { action, providerId, error } = req const { options, cookies } = await init({ @@ -64,7 +87,7 @@ export async function NextAuthHandler< ) 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": @@ -139,7 +162,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/init.ts b/src/core/init.ts index 63e6a5dc62..7f9a9289f6 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/core/lib/assert.ts b/src/core/lib/assert.ts new file mode 100644 index 0000000000..6662915efc --- /dev/null +++ b/src/core/lib/assert.ts @@ -0,0 +1,77 @@ +import { + MissingAdapter, + MissingAPIRoute, + MissingAuthorize, + MissingSecret, + 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. + * + * REVIEW: Make some of these and corresponding docs less Next.js specific? + */ +export function assertConfig( + params: NextAuthHandlerParams +): ConfigError | 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" + + let hasCredentials, hasEmail + + options.providers.forEach(({ type }) => { + if (type === "credentials") hasCredentials = true + else if (type === "email") hasEmail = true + }) + + if (hasCredentials) { + 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" + ) + } + + 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 (hasEmail && !options.adapter) { + return new MissingAdapter("E-mail login requires an adapter.") + } +} 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/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..b188215e47 100644 --- a/src/core/pages/index.ts +++ b/src/core/pages/index.ts @@ -4,21 +4,28 @@ 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" -/** 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 +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" + > +> + +/** + * 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 function send({ html, title, status }: any): OutgoingResponse { return { @@ -26,7 +33,7 @@ export default function renderPage({ status, headers: [{ key: "Content-Type", value: "text/html" }], body: `${title}
${renderToString(html)}
`, } } @@ -35,9 +42,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 +54,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 +69,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/core/routes/callback.ts b/src/core/routes/callback.ts index 1ae7950cb9..c80c644736 100644 --- a/src/core/routes/callback.ts +++ b/src/core/routes/callback.ts @@ -198,15 +198,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 @@ -333,34 +327,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 ab03d536e5..9ee75e1f2b 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -19,19 +19,15 @@ function hasErrorProperty( return !!(x as any)?.error } +export type WarningCode = "NEXTAUTH_URL" | "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: - | "JWT_AUTO_GENERATED_SIGNING_KEY" - | "JWT_AUTO_GENERATED_ENCRYPTION_KEY" - | "NEXTAUTH_URL" - | "NO_CSRF_TOKEN" - ) => void + warn: (code: WarningCode) => void error: ( code: string, /** @@ -61,16 +57,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 diff --git a/src/next/index.ts b/src/next/index.ts index 52fbbe5b45..831b9ed4a0 100644 --- a/src/next/index.ts +++ b/src/next/index.ts @@ -1,5 +1,5 @@ -import logger, { setLogger } from "../lib/logger" import { NextAuthHandler } from "../core" +import { setCookie } from "./cookie" import type { GetServerSidePropsContext, @@ -12,36 +12,15 @@ import type { NextAuthRequest, NextAuthResponse, } from "../lib/types" -import { setCookie } from "./cookie" async function NextAuthNextHandler( req: NextApiRequest, res: NextApiResponse, options: NextAuthOptions ) { - setLogger(options.logger) - - if (!req.query.nextauth) { - const error = new Error( - "Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly." - ) - - logger.error("MISSING_NEXTAUTH_API_ROUTE_ERROR", error) - return res.status(500).send(error.message) - } - - const host = (process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL) as string - if (!host) logger.warn("NEXTAUTH_URL") - - const { - body, - redirect, - cookies, - headers, - status = 200, - } = await NextAuthHandler({ + 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, @@ -54,27 +33,25 @@ async function NextAuthNextHandler( options, }) - res.status(status) + res.status(handler.status ?? 200) - cookies?.forEach((cookie) => setCookie(res, cookie)) + handler.cookies?.forEach((cookie) => setCookie(res, cookie)) - headers?.forEach((header) => { - res.setHeader(header.key, header.value) - }) + 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