From d6522be2d61444603f9bc77a642699a69a7b2d0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 29 Sep 2021 02:06:02 +0200 Subject: [PATCH 01/28] refactor: decouple Next.js from core (WIP) --- src/lib/types.ts | 5 + src/next.ts | 63 +++++ src/server/index.ts | 235 +++++++----------- src/server/init.ts | 155 ++++++++++++ src/server/lib/callback-url-handler.ts | 46 ---- src/server/lib/callback-url.ts | 36 +++ src/server/lib/cookie.ts | 5 + .../{csrf-token-handler.ts => csrf-token.ts} | 54 ++-- src/server/lib/oauth/authorization-url.js | 35 ++- src/server/lib/oauth/pkce-handler.js | 28 ++- src/server/lib/oauth/state-handler.js | 9 +- src/server/pages/error.tsx | 44 ++-- src/server/pages/index.ts | 43 ++-- src/server/routes/providers.js | 20 -- src/server/routes/providers.ts | 13 + src/server/routes/{session.js => session.ts} | 89 ++++--- src/server/routes/signin.js | 45 ++-- src/server/types.ts | 3 +- 18 files changed, 573 insertions(+), 355 deletions(-) create mode 100644 src/next.ts create mode 100644 src/server/init.ts delete mode 100644 src/server/lib/callback-url-handler.ts create mode 100644 src/server/lib/callback-url.ts rename src/server/lib/{csrf-token-handler.ts => csrf-token.ts} (56%) delete mode 100644 src/server/routes/providers.js create mode 100644 src/server/routes/providers.ts rename src/server/routes/{session.js => session.ts} (67%) diff --git a/src/lib/types.ts b/src/lib/types.ts index 6c85eef3c9..a600e55722 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -46,6 +46,11 @@ export interface InternalOptions { providers: InternalProvider[] baseUrl: string basePath: string + /** + * `baseUrl` and `basePath` combined. + * REVIEW: Can we remove those? + */ + base: string action: | "providers" | "session" diff --git a/src/next.ts b/src/next.ts new file mode 100644 index 0000000000..7c0792585c --- /dev/null +++ b/src/next.ts @@ -0,0 +1,63 @@ +import { NextApiRequest, NextApiResponse } from "next" +import { NextAuthOptions } from "." +import { IncomingRequest, NextAuthHandler } from "./server" +import extendRes from "./server/lib/extend-res" +import { set as setCookie } from "./server/lib/cookie" + +async function NextAuthNextHandler(req, res, userOptions) { + extendRes(req, res) + + const request: IncomingRequest = { + body: req.body, + query: req.query, + cookies: req.cookies, + headers: req.headers, + method: req.method, + } + const { + json, + redirect, + text, + cookies, + headers, + status = 200, + } = await NextAuthHandler({ ...request, userOptions }) + + res.status(status) + + cookies?.forEach((cookie) => { + setCookie(res, cookie.name, cookie.value, cookie.options) + }) + headers?.forEach((header) => { + res.setHeader(header.key, header.value) + }) + + if (redirect) { + return res.redirect(redirect) + } else if (json) { + return res.json(json) + } else if (text) { + return res.send(text) + } + return res + .status(400) + .send(`Error: HTTP ${req.method} is not supported for ${req.url}`) +} + +function NextAuth(options: NextAuthOptions): any +function NextAuth( + req: NextApiRequest, + res: NextApiResponse, + options: NextAuthOptions +): any + +/** Tha main entry point to next-auth */ +function NextAuth(...args) { + if (args.length === 1) { + return async (req, res) => await NextAuthNextHandler(req, res, args[0]) + } + + return NextAuthNextHandler(args[0], args[1], args[2]) +} + +export default NextAuth diff --git a/src/server/index.ts b/src/server/index.ts index 4170bc8a74..f4d7bec90d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,53 +1,55 @@ -/* eslint-disable @typescript-eslint/restrict-template-expressions */ -import * as jwt from "../jwt" -import parseUrl from "../lib/parse-url" import logger, { setLogger } from "../lib/logger" -import * as cookie from "./lib/cookie" -import { defaultCallbacks } from "./lib/default-callbacks" -import parseProviders from "./lib/providers" import * as routes from "./routes" import renderPage from "./pages" -import callbackUrlHandler from "./lib/callback-url-handler" -import extendRes from "./lib/extend-res" -import csrfTokenHandler from "./lib/csrf-token-handler" -import { eventsErrorHandler, adapterErrorHandler } from "./errors" -import createSecret from "./lib/utils" - -import type { NextApiRequest, NextApiResponse } from "next" import type { NextAuthOptions } from "./types" -import type { - InternalOptions, - NextAuthRequest, - NextAuthResponse, -} from "../lib/types" +import { init } from "./init" +import { Cookie } from "./lib/cookie" + +import NextAuth from "../next" + +export interface IncomingRequest { + method?: string + headers: Record + cookies: Record + query: Record + body: Record +} -// To work properly in production with OAuth providers the NEXTAUTH_URL -// environment variable must be set. -if (!process.env.NEXTAUTH_URL) { - logger.warn("NEXTAUTH_URL") +export interface OutgoingResponse { + status?: number + headers?: any[] + json?: any + text?: any + redirect?: string + cookies?: Cookie[] } -async function NextAuthHandler( - req: NextAuthRequest, - res: NextAuthResponse, - userOptions: NextAuthOptions -) { +export async function NextAuthHandler( + params: IncomingRequest & { + userOptions: NextAuthOptions + } +): Promise { + const { userOptions, ...req } = params + if (userOptions.logger) { setLogger(userOptions.logger) } - // If debug enabled, set ENV VAR so that logger logs debug messages - if (userOptions.debug) { - ;(process.env._NEXTAUTH_DEBUG as any) = true - } - extendRes(req, res) + // To work properly in production with OAuth providers the NEXTAUTH_URL + // environment variable must be set. + if (!process.env.NEXTAUTH_URL) { + logger.warn("NEXTAUTH_URL") + } if (!req.query.nextauth) { const message = "Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly." logger.error("MISSING_NEXTAUTH_API_ROUTE_ERROR", new Error(message)) - return res.status(500).end(`Error: ${message}`) + return { + status: 500, + text: `Error: ${message}`, + } } const { @@ -59,125 +61,70 @@ async function NextAuthHandler( delete req.query.nextauth - const { basePath, baseUrl } = parseUrl( - process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL - ) - - const cookies = { - ...cookie.defaultCookies( - userOptions.useSecureCookies ?? baseUrl.startsWith("https://") - ), - // Allow user cookie options to override any cookie settings above - ...userOptions.cookies, - } - - const secret = createSecret({ userOptions, basePath, baseUrl }) - - const { providers, provider } = parseProviders({ - providers: userOptions.providers, - base: `${baseUrl}${basePath}`, - providerId: providerId as string | undefined, + const { options, cookies } = await init({ + userOptions, + action, + providerId, + callbackUrl: req.body.callbackUrl ?? req.query.callbackUrl, + csrfToken: req.body.csrfToken, + cookies: req.cookies, + isPost: req.method === "POST", }) - const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default - - // User provided options are overriden by other options, - // except for the options with special handling above - const options: InternalOptions = { - debug: false, - pages: {}, - theme: { - colorScheme: "auto", - logo: "", - brandColor: "", - }, - // Custom options override defaults - ...userOptions, - // These computed settings can have values in userOptions but we override them - // and are request-specific. - baseUrl, - basePath, - action: action as InternalOptions["action"], - provider, - cookies, - secret, - providers, - // Session options - session: { - jwt: !userOptions.adapter, // If no adapter specified, force use of JSON Web Tokens (stateless) - maxAge, - updateAge: 24 * 60 * 60, - ...userOptions.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, - }, - // Event messages - events: eventsErrorHandler(userOptions.events ?? {}, logger), - adapter: adapterErrorHandler(userOptions.adapter, logger), - // Callback functions - callbacks: { - ...defaultCallbacks, - ...userOptions.callbacks, - }, - logger, - callbackUrl: process.env.NEXTAUTH_URL ?? "http://localhost:3000", - } - - req.options = options - - csrfTokenHandler(req, res) - await callbackUrlHandler(req, res) + const render = renderPage({ options, query: req.query, cookies }) - const render = renderPage(req, res) - const { pages } = req.options + const { pages } = options if (req.method === "GET") { switch (action) { case "providers": - return routes.providers(req, res) - case "session": - return await routes.session(req, res) + return { json: routes.providers(options.providers), cookies } + case "session": { + const session = await routes.session({ + options, + sessionToken: req.cookies[options.cookies.sessionToken.name], + }) + if (session.cookie) { + cookies.push(session.cookie) + } + return { json: session.json, cookies } + } case "csrf": - return res.json({ csrfToken: req.options.csrfToken }) + return { json: { csrfToken: options.csrfToken }, cookies } case "signin": if (pages.signIn) { let signinUrl = `${pages.signIn}${ pages.signIn.includes("?") ? "&" : "?" - }callbackUrl=${req.options.callbackUrl}` + }callbackUrl=${options.callbackUrl}` if (error) { signinUrl = `${signinUrl}&error=${error}` } - return res.redirect(signinUrl) + return { redirect: signinUrl, cookies } } return render.signin() case "signout": - if (pages.signOut) return res.redirect(pages.signOut) + if (pages.signOut) return { redirect: pages.signOut, cookies } return render.signout() case "callback": - if (provider) { + if (options.provider) { return await routes.callback(req, res) } break case "verify-request": if (pages.verifyRequest) { - return res.redirect(pages.verifyRequest) + return { redirect: pages.verifyRequest, cookies } } return render.verifyRequest() case "error": if (pages.error) { - return res.redirect( - `${pages.error}${ + return { + redirect: `${pages.error}${ pages.error.includes("?") ? "&" : "?" - }error=${error}` - ) + }error=${error}`, + cookies, + } } // These error messages are displayed in line on the sign in page @@ -195,7 +142,7 @@ async function NextAuthHandler( "SessionRequired", ].includes(error as string) ) { - return res.redirect(`${baseUrl}${basePath}/signin?error=${error}`) + return { redirect: `${options.base}/signin?error=${error}`, cookies } } return render.error({ error }) @@ -205,25 +152,33 @@ async function NextAuthHandler( switch (action) { case "signin": // Verified CSRF Token required for all sign in routes - if (req.options.csrfTokenVerified && provider) { - return await routes.signin(req, res) + if (options.csrfTokenVerified && options.provider) { + const signin = await routes.signin({ + query: req.query, + body: req.body, + options, + }) + if (signin.cookies) { + cookies.push(...signin.cookies) + } + return { ...signin, cookies } } - return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`) + return { redirect: `${options.base}/signin?csrf=true`, cookies } case "signout": // Verified CSRF Token required for signout - if (req.options.csrfTokenVerified) { + if (options.csrfTokenVerified) { return await routes.signout(req, res) } - return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`) + return { redirect: `${options.base}/signout?csrf=true`, cookies } case "callback": - if (provider) { + if (options.provider) { // Verified CSRF Token required for credentials providers only if ( - provider.type === "credentials" && - !req.options.csrfTokenVerified + options.provider.type === "credentials" && + !options.csrfTokenVerified ) { - return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`) + return { redirect: `${options.base}/signin?csrf=true`, cookies } } return await routes.callback(req, res) @@ -239,27 +194,11 @@ async function NextAuthHandler( logger.error("LOGGER_ERROR", error) } } - return res.end() + return { cookies } default: } } - return res - .status(400) - .end(`Error: HTTP ${req.method} is not supported for ${req.url}`) -} - -function NextAuth(options: NextAuthOptions): any -function NextAuth( - req: NextApiRequest, - res: NextApiResponse, - options: NextAuthOptions -): any -/** Tha main entry point to next-auth */ -function NextAuth(...args) { - if (args.length === 1) { - return async (req, res) => await NextAuthHandler(req, res, args[0]) - } - return NextAuthHandler(args[0], args[1], args[2]) + return { status: 400, cookies: [] } } export default NextAuth diff --git a/src/server/init.ts b/src/server/init.ts new file mode 100644 index 0000000000..e4bafceab5 --- /dev/null +++ b/src/server/init.ts @@ -0,0 +1,155 @@ +import { NextAuthOptions } from ".." +import logger from "../lib/logger" +import parseUrl from "../lib/parse-url" +import { InternalOptions } from "../lib/types" +import { adapterErrorHandler, eventsErrorHandler } from "./errors" +import parseProviders from "./lib/providers" +import createSecret from "./lib/utils" +import * as cookie from "./lib/cookie" +import * as jwt from "../jwt" +import { defaultCallbacks } from "./lib/default-callbacks" +import { createCSRFToken } from "./lib/csrf-token" +import { createCallbackUrl } from "./lib/callback-url" + +interface InitParams { + userOptions: NextAuthOptions + providerId?: string + action: InternalOptions["action"] + /** Callback URL value extracted from the incoming request. */ + callbackUrl?: string + /** CSRF token value extracted from the incoming request. From body if POST, from query if GET */ + csrfToken?: string + /** Is the incoming request a POST request? */ + isPost: boolean + cookies: Record +} + +/** + * Initialize all internal options and + * cookies that need to be created. + */ +export async function init({ + userOptions, + providerId, + action, + cookies: reqCookies, + callbackUrl: reqCallbackUrl, + csrfToken: reqCsrfToken, + isPost, +}: InitParams): Promise<{ + 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 { basePath, baseUrl } = parseUrl( + process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL + ) + + const secret = createSecret({ userOptions, basePath, baseUrl }) + + const { providers, provider } = parseProviders({ + providers: userOptions.providers, + base: `${baseUrl}${basePath}`, + providerId, + }) + + const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default + + // User provided options are overriden by other options, + // except for the options with special handling above + const options: InternalOptions = { + debug: false, + pages: {}, + theme: { + colorScheme: "auto", + logo: "", + brandColor: "", + }, + // Custom options override defaults + ...userOptions, + // These computed settings can have values in userOptions but we override them + // and are request-specific. + baseUrl, + basePath, + base: `${baseUrl}${basePath}`, + action, + provider, + cookies: { + ...cookie.defaultCookies( + userOptions.useSecureCookies ?? baseUrl.startsWith("https://") + ), + // Allow user cookie options to override any cookie settings above + ...userOptions.cookies, + }, + secret, + providers, + // Session options + session: { + jwt: !userOptions.adapter, // If no adapter specified, force use of JSON Web Tokens (stateless) + maxAge, + updateAge: 24 * 60 * 60, + ...userOptions.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, + }, + // Event messages + events: eventsErrorHandler(userOptions.events ?? {}, logger), + adapter: adapterErrorHandler(userOptions.adapter, logger), + // Callback functions + callbacks: { ...defaultCallbacks, ...userOptions.callbacks }, + logger, + callbackUrl: process.env.NEXTAUTH_URL ?? "http://localhost:3000", + } + + // Init cookies + + const cookies: cookie.Cookie[] = [] + + const { + csrfToken, + cookie: csrfCookie, + csrfTokenVerified, + } = createCSRFToken({ + options, + cookieValue: reqCookies[options.cookies.csrfToken.name], + isPost, + bodyValue: reqCsrfToken, + }) + + options.csrfToken = csrfToken + options.csrfTokenVerified = csrfTokenVerified + + if (csrfCookie) { + cookies.push({ + name: options.cookies.csrfToken.name, + value: csrfCookie, + options: options.cookies.csrfToken.options, + }) + } + + const { callbackUrl, callbackUrlCookie } = await createCallbackUrl({ + options, + cookieValue: reqCookies[options.cookies.callbackUrl.name], + paramValue: reqCallbackUrl, + }) + options.callbackUrl = callbackUrl + if (callbackUrlCookie) { + cookies.push({ + name: options.cookies.callbackUrl.name, + value: callbackUrlCookie, + options: options.cookies.callbackUrl.options, + }) + } + + return { options, cookies } +} diff --git a/src/server/lib/callback-url-handler.ts b/src/server/lib/callback-url-handler.ts deleted file mode 100644 index ceaba67c06..0000000000 --- a/src/server/lib/callback-url-handler.ts +++ /dev/null @@ -1,46 +0,0 @@ -// @ts-check -import { NextAuthRequest, NextAuthResponse } from "../../lib/types" -import * as cookie from "../lib/cookie" - -/** - * Get callback URL based on query param / cookie + validation, - * and add it to `req.options.callbackUrl`. - */ -export default async function callbackUrlHandler( - req: NextAuthRequest, - res: NextAuthResponse -) { - const { query } = req - const { body } = req - const { cookies, baseUrl, callbacks } = req.options - - let callbackUrl = baseUrl - // Try reading callbackUrlParamValue from request body (form submission) then from query param (get request) - const callbackUrlParamValue = body.callbackUrl || query.callbackUrl || null - const callbackUrlCookieValue = req.cookies[cookies.callbackUrl.name] || null - if (callbackUrlParamValue) { - // If callbackUrl form field or query parameter is passed try to use it if allowed - callbackUrl = await callbacks.redirect({ - url: callbackUrlParamValue, - baseUrl, - }) - } else if (callbackUrlCookieValue) { - // If no callbackUrl specified, try using the value from the cookie if allowed - callbackUrl = await callbacks.redirect({ - url: callbackUrlCookieValue, - baseUrl, - }) - } - - // Save callback URL in a cookie so that it can be used for subsequent requests in signin/signout/callback flow - if (callbackUrl && callbackUrl !== callbackUrlCookieValue) { - cookie.set( - res, - cookies.callbackUrl.name, - callbackUrl, - cookies.callbackUrl.options - ) - } - - req.options.callbackUrl = callbackUrl -} diff --git a/src/server/lib/callback-url.ts b/src/server/lib/callback-url.ts new file mode 100644 index 0000000000..f39e4a07a6 --- /dev/null +++ b/src/server/lib/callback-url.ts @@ -0,0 +1,36 @@ +import { InternalOptions } from "../../lib/types" + +interface CreateCallbackUrlParams { + options: InternalOptions + /** Try reading value from request body (POST) then from query param (GET) */ + paramValue?: string + cookieValue?: string +} + +/** + * Get callback URL based on query param / cookie + validation, + * and add it to `req.options.callbackUrl`. + */ +export async function createCallbackUrl({ + options, + paramValue, + cookieValue, +}: CreateCallbackUrlParams) { + const { baseUrl, callbacks } = options + + let callbackUrl = baseUrl + + if (paramValue) { + // If callbackUrl form field or query parameter is passed try to use it if allowed + callbackUrl = await callbacks.redirect({ url: paramValue, baseUrl }) + } else if (cookieValue) { + // If no callbackUrl specified, try using the value from the cookie if allowed + callbackUrl = await callbacks.redirect({ url: cookieValue, baseUrl }) + } + + return { + callbackUrl, + // Save callback URL in a cookie so that it can be used for subsequent requests in signin/signout/callback flow + callbackUrlCookie: callbackUrl !== cookieValue ? callbackUrl : undefined, + } +} diff --git a/src/server/lib/cookie.ts b/src/server/lib/cookie.ts index 2e0a7543ef..c787228a0f 100644 --- a/src/server/lib/cookie.ts +++ b/src/server/lib/cookie.ts @@ -1,6 +1,7 @@ // REVIEW: Is there any way to defer two types of strings? import { CookiesOptions } from "../.." +import { CookieOption } from "../types" /** Stringified form of `JWT`. Extract the content with `jwt.decode` */ export type JWTString = string @@ -196,3 +197,7 @@ export function defaultCookies(useSecureCookies): CookiesOptions { }, } } + +export interface Cookie extends CookieOption { + value: string +} diff --git a/src/server/lib/csrf-token-handler.ts b/src/server/lib/csrf-token.ts similarity index 56% rename from src/server/lib/csrf-token-handler.ts rename to src/server/lib/csrf-token.ts index e4379176c7..04a54d3372 100644 --- a/src/server/lib/csrf-token-handler.ts +++ b/src/server/lib/csrf-token.ts @@ -1,6 +1,12 @@ import { createHash, randomBytes } from "crypto" -import { NextAuthRequest, NextAuthResponse } from "../../lib/types" -import * as cookie from "./cookie" +import { InternalOptions } from "../../lib/types" + +interface CreateCSRFTokenParams { + options: InternalOptions + cookieValue?: string + isPost: boolean + bodyValue?: string +} /** * Ensure CSRF Token cookie is set for any subsequent requests. @@ -16,41 +22,33 @@ import * as cookie from "./cookie" * https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie * https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf */ -export default function csrfTokenHandler( - req: NextAuthRequest, - res: NextAuthResponse -) { - const { cookies, secret } = req.options - if (cookies.csrfToken.name in req.cookies) { - const [csrfToken, csrfTokenHash] = - req.cookies[cookies.csrfToken.name].split("|") +export function createCSRFToken({ + options, + cookieValue, + isPost, + bodyValue, +}: CreateCSRFTokenParams) { + if (cookieValue) { + const [csrfToken, csrfTokenHash] = cookieValue.split("|") const expectedCsrfTokenHash = createHash("sha256") - .update(`${csrfToken}${secret}`) + .update(`${csrfToken}${options.secret}`) .digest("hex") if (csrfTokenHash === expectedCsrfTokenHash) { // If hash matches then we trust the CSRF token value // If this is a POST request and the CSRF Token in the POST request matches // the cookie we have already verified is the one we have set, then the token is verified! - const csrfTokenVerified = - req.method === "POST" && csrfToken === req.body.csrfToken - req.options.csrfToken = csrfToken - req.options.csrfTokenVerified = csrfTokenVerified - return + const csrfTokenVerified = isPost && csrfToken === bodyValue + + return { csrfTokenVerified, csrfToken } } } - // If no csrfToken from cookie - because it's not been set yet, - // or because the hash doesn't match (e.g. because it's been modifed or because the secret has changed) - // create a new token. + + // New CSRF token const csrfToken = randomBytes(32).toString("hex") const csrfTokenHash = createHash("sha256") - .update(`${csrfToken}${secret}`) + .update(`${csrfToken}${options.secret}`) .digest("hex") - const csrfTokenCookie = `${csrfToken}|${csrfTokenHash}` - cookie.set( - res, - cookies.csrfToken.name, - csrfTokenCookie, - cookies.csrfToken.options - ) - req.options.csrfToken = csrfToken + const cookie = `${csrfToken}|${csrfTokenHash}` + + return { cookie, csrfToken } } diff --git a/src/server/lib/oauth/authorization-url.js b/src/server/lib/oauth/authorization-url.js index 6084df3219..e24318fe15 100644 --- a/src/server/lib/oauth/authorization-url.js +++ b/src/server/lib/oauth/authorization-url.js @@ -8,14 +8,17 @@ import { createPKCE } from "../oauth/pkce-handler" * Generates an authorization/request token URL. * * [OAuth 2](https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/) | [OAuth 1](https://oauth.net/core/1.0a/#auth_step2) - * @type {import("src/lib/types").NextAuthApiHandler} - * @returns {string} + * @param {{ + * options: import("src/lib/types").InternalOptions + * query: import("src/server").IncomingRequest["query"] + * }} + * @returns {import("src/server").OutgoingResponse} */ -export default async function getAuthorizationUrl(req, res) { - const { logger } = req.options +export default async function getAuthorizationUrl({ options, query }) { + const { logger } = options try { /** @type {import("src/providers").OAuthConfig} */ - const provider = req.options.provider + const provider = options.provider let params = {} @@ -27,11 +30,11 @@ export default async function getAuthorizationUrl(req, res) { params = { ...params, ...provider.authorization?.params } } - params = { ...params, ...req.query } + params = { ...params, ...query } // Handle OAuth v1.x if (provider.version?.startsWith("1.")) { - const client = oAuth1Client(req.options) + const client = oAuth1Client(options) const tokens = await client.getOAuthRequestToken(params) const url = `${provider.authorization}?${new URLSearchParams({ oauth_token: tokens.oauth_token, @@ -40,19 +43,27 @@ export default async function getAuthorizationUrl(req, res) { })}` logger.debug("GET_AUTHORIZATION_URL", { url }) - return url + return { redirect: url } + } + + const cookies = [] + const client = await openidClient(options) + const pkce = await createPKCE(options) + if (pkce?.cookie) { + cookies.push(pkce.cookie) } - const client = await openidClient(req.options) - const pkce = await createPKCE(req, res) const url = client.authorizationUrl({ ...params, ...pkce, - state: createState(req), + state: createState(options), }) logger.debug("GET_AUTHORIZATION_URL", { url }) - return url + return { + redirect: url, + cookies, + } } catch (error) { logger.error("GET_AUTHORIZATION_URL_ERROR", error) throw error diff --git a/src/server/lib/oauth/pkce-handler.js b/src/server/lib/oauth/pkce-handler.js index 66ee5f4ec2..5cfa85e289 100644 --- a/src/server/lib/oauth/pkce-handler.js +++ b/src/server/lib/oauth/pkce-handler.js @@ -9,13 +9,17 @@ const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds /** * Returns `code_challenge` and `code_challenge_method` * and saves them in a cookie. - * @type {import("src/lib/types").NextAuthApiHandler} - * @returns {Promise + * @type {import("src/lib/types").InternalOptions} + * @returns {Promise */ -export async function createPKCE(req, res) { - const { cookies, logger } = req.options +export async function createPKCE(options) { + const { cookies, logger } = options /** @type {import("src/providers").OAuthConfig} */ - const provider = req.options.provider + const provider = options.provider if (!provider.checks?.includes("pkce")) { // Provider does not support PKCE, return nothing. return @@ -26,17 +30,13 @@ export async function createPKCE(req, res) { // Encrypt code_verifier and save it to an encrypted cookie const encryptedCodeVerifier = await jwt.encode({ maxAge: PKCE_MAX_AGE, - ...req.options.jwt, + ...options.jwt, token: { code_verifier: codeVerifier }, encryption: true, }) const cookieExpires = new Date() cookieExpires.setTime(cookieExpires.getTime() + PKCE_MAX_AGE * 1000) - cookie.set(res, cookies.pkceCodeVerifier.name, encryptedCodeVerifier, { - expires: cookieExpires.toISOString(), - ...cookies.pkceCodeVerifier.options, - }) logger.debug("CREATE_PKCE_CHALLENGE_VERIFIER", { pkce: { @@ -47,6 +47,14 @@ export async function createPKCE(req, res) { method: PKCE_CODE_CHALLENGE_METHOD, }) return { + cookie: { + name: cookies.pkceCodeVerifier.name, + value: encryptedCodeVerifier, + options: { + expires: cookieExpires.toISOString(), + ...cookies.pkceCodeVerifier.options, + }, + }, code_challenge: codeChallenge, code_challenge_method: PKCE_CODE_CHALLENGE_METHOD, } diff --git a/src/server/lib/oauth/state-handler.js b/src/server/lib/oauth/state-handler.js index 3e043c7e60..7762d5e239 100644 --- a/src/server/lib/oauth/state-handler.js +++ b/src/server/lib/oauth/state-handler.js @@ -2,13 +2,12 @@ import { createHash } from "crypto" /** * Returns state if provider supports it - * @param {import("src/lib/types").NextAuthRequest} req - * @param {import("src/lib/types").NextAuthResponse} res + * @param {import("src/lib/types").InternalOptions} options */ -export function createState(req) { - const { csrfToken, logger } = req.options +export function createState(options) { + const { csrfToken, logger } = options /** @type {import("src/providers").OAuthConfig} */ - const provider = req.options.provider + const provider = options.provider if (!provider.checks?.includes("state")) { // Provider does not support state, return nothing return diff --git a/src/server/pages/error.tsx b/src/server/pages/error.tsx index cebd6a17be..7e34071ef4 100644 --- a/src/server/pages/error.tsx +++ b/src/server/pages/error.tsx @@ -4,15 +4,14 @@ * baseUrl: string * basePath: string * error?: string - * res: import("src/lib/types").NextAuthResponse * }} params */ -export default function Error({ baseUrl, basePath, error = "default", theme, res }) { +export default function Error({ baseUrl, basePath, error = "default", theme }) { const signinPageUrl = `${baseUrl}${basePath}/signin` const errors = { default: { - statusCode: 200, + status: 200, heading: "Error", message: (

@@ -23,7 +22,7 @@ export default function Error({ baseUrl, basePath, error = "default", theme, res ), }, configuration: { - statusCode: 500, + status: 500, heading: "Server error", message: (

@@ -33,7 +32,7 @@ export default function Error({ baseUrl, basePath, error = "default", theme, res ), }, accessdenied: { - statusCode: 403, + status: 403, heading: "Access Denied", message: (
@@ -47,7 +46,7 @@ export default function Error({ baseUrl, basePath, error = "default", theme, res ), }, verification: { - statusCode: 403, + status: 403, heading: "Unable to sign in", message: (
@@ -65,24 +64,29 @@ export default function Error({ baseUrl, basePath, error = "default", theme, res }, } - const { statusCode, heading, message, signin } = + const { status, heading, message, signin } = errors[error.toLowerCase()] ?? errors.default - res.status(statusCode) - - return ( -
- ${title}
${renderToString( - html - )}
` - ) + function send({ html, title, status }: any): OutgoingResponse { + return { + cookies, + status, + headers: [{ key: "Content-Type", value: "text/html" }], + text: `${title}
${renderToString(html)}
`, + } } return { signin(props?: any) { - send({ + return send({ html: signin({ csrfToken, providers, callbackUrl, theme, - ...req.query, + ...query, ...props, }), title: "Sign In", }) }, signout(props?: any) { - send({ + return send({ html: signout({ csrfToken, baseUrl, basePath, theme, ...props }), title: "Sign Out", }) }, verifyRequest(props?: any) { - send({ + return send({ html: verifyRequest({ baseUrl, theme, ...props }), title: "Verify Request", }) }, error(props) { - send({ - html: error({ basePath, baseUrl, theme, res, ...props }), + return send({ + ...error({ basePath, baseUrl, theme, ...props }), title: "Error", }) }, diff --git a/src/server/routes/providers.js b/src/server/routes/providers.js deleted file mode 100644 index da3f8af517..0000000000 --- a/src/server/routes/providers.js +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Return a JSON object with a list of all OAuth providers currently configured - * and their signin and callback URLs. This makes it possible to automatically - * generate buttons for all providers when rendering client side. - * @param {import("src/lib/types").NextAuthRequest} req - * @param {import("src/lib/types").NextAuthResponse} res - */ -export default function providers(req, res) { - const { providers } = req.options - - const result = providers.reduce( - (acc, { id, name, type, signinUrl, callbackUrl }) => { - acc[id] = { id, name, type, signinUrl, callbackUrl } - return acc - }, - {} - ) - - res.json(result) -} diff --git a/src/server/routes/providers.ts b/src/server/routes/providers.ts new file mode 100644 index 0000000000..38d9fe925f --- /dev/null +++ b/src/server/routes/providers.ts @@ -0,0 +1,13 @@ +import { InternalProvider } from "../../lib/types" + +/** + * Return a JSON object with a list of all OAuth providers currently configured + * and their signin and callback URLs. This makes it possible to automatically + * generate buttons for all providers when rendering client side. + */ +export default function providers(providers: InternalProvider[]) { + return providers.reduce((acc, { id, name, type, signinUrl, callbackUrl }) => { + acc[id] = { id, name, type, signinUrl, callbackUrl } + return acc + }, {}) +} diff --git a/src/server/routes/session.js b/src/server/routes/session.ts similarity index 67% rename from src/server/routes/session.js rename to src/server/routes/session.ts index 01190bd659..43d65ff65c 100644 --- a/src/server/routes/session.js +++ b/src/server/routes/session.ts @@ -1,23 +1,36 @@ +import { Adapter } from "../../adapters" +import { InternalOptions } from "../../lib/types" +import { OutgoingResponse } from ".." import * as cookie from "../lib/cookie" import { fromDate } from "../lib/utils" +interface SessionParams { + options: InternalOptions + sessionToken?: string +} + +type SessionResult = Pick & { cookie?: cookie.Cookie } + /** * Return a session object (without any private fields) * for Single Page App clients - * @param {import("src/lib/types").NextAuthRequest} req - * @param {import("src/lib/types").NextAuthResponse} res */ -export default async function session(req, res) { - const { cookies, adapter, jwt, events, callbacks, logger } = req.options - const useJwtSession = req.options.session.jwt - const sessionMaxAge = req.options.session.maxAge - const sessionToken = req.cookies[cookies.sessionToken.name] + +export default async function session( + params: SessionParams +): Promise { + const { options, sessionToken } = params + const { adapter, jwt, events, callbacks, logger } = options + const useJwtSession = options.session.jwt + const sessionMaxAge = options.session.maxAge if (!sessionToken) { - return res.json({}) + return { json: {} } } - let response = {} + let sessionCookie: cookie.Cookie | undefined + + let json = {} if (useJwtSession) { try { // Decrypt and verify token @@ -38,36 +51,45 @@ export default async function session(req, res) { } // Pass Session and JSON Web Token through to the session callback + // @ts-expect-error const token = await callbacks.jwt({ token: decodedToken }) + // @ts-expect-error const session = await callbacks.session({ session: defaultSession, token, }) // Return session payload as response - response = session + json = session // Refresh JWT expiry by re-signing it, with an updated expiry date const newToken = await jwt.encode({ ...jwt, token }) // Set cookie, to also update expiry date on cookie - cookie.set(res, cookies.sessionToken.name, newToken, { - expires: newExpires, - ...cookies.sessionToken.options, - }) + sessionCookie = { + name: options.cookies.sessionToken.name, + value: newToken, + options: { + expires: newExpires, + ...options.cookies.sessionToken.options, + }, + } await events.session?.({ session, token }) } catch (error) { // If JWT not verifiable, make sure the cookie for it is removed and return empty object logger.error("JWT_SESSION_ERROR", error) - cookie.set(res, cookies.sessionToken.name, "", { - ...cookies.sessionToken.options, - maxAge: 0, - }) + + sessionCookie = { + name: options.cookies.sessionToken.name, + value: "", + options: { ...options.cookies.sessionToken.options, maxAge: 0 }, + } } } else { try { - const { getSessionAndUser, deleteSession, updateSession } = adapter + const { getSessionAndUser, deleteSession, updateSession } = + adapter as Adapter let userAndSession = await getSessionAndUser(sessionToken) // If session has expired, clean up the database @@ -82,7 +104,7 @@ export default async function session(req, res) { if (userAndSession) { const { user, session } = userAndSession - const sessionUpdateAge = req.options.session.updateAge + const sessionUpdateAge = options.session.updateAge // Calculate last updated date to throttle write updates to database // Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge // e.g. ({expiry date} - 30 days) + 1 hour @@ -99,6 +121,7 @@ export default async function session(req, res) { } // Pass Session through to the session callback + // @ts-expect-error const sessionPayload = await callbacks.session({ // By default, only exposes a limited subset of information to the client // as needed for presentation purposes (e.g. "you are logged in as..."). @@ -114,27 +137,33 @@ export default async function session(req, res) { }) // Return session payload as response - response = sessionPayload + json = sessionPayload // Set cookie again to update expiry - cookie.set(res, cookies.sessionToken.name, sessionToken, { - expires: newExpires, - ...cookies.sessionToken.options, - }) + sessionCookie = { + name: options.cookies.sessionToken.name, + value: sessionToken, + options: { + expires: newExpires, + ...options.cookies.sessionToken.options, + }, + } + // @ts-expect-error await events.session?.({ session: sessionPayload }) } else if (sessionToken) { // If sessionToken was found set but it's not valid for a session then // remove the sessionToken cookie from browser. - cookie.set(res, cookies.sessionToken.name, "", { - ...cookies.sessionToken.options, - maxAge: 0, - }) + sessionCookie = { + name: options.cookies.sessionToken.name, + value: "", + options: { ...options.cookies.sessionToken.options, maxAge: 0 }, + } } } catch (error) { logger.error("SESSION_ERROR", error) } } - res.json(response) + return { json, cookie: sessionCookie } } diff --git a/src/server/routes/signin.js b/src/server/routes/signin.js index df927a9dc4..701d18b1b0 100644 --- a/src/server/routes/signin.js +++ b/src/server/routes/signin.js @@ -3,25 +3,32 @@ import emailSignin from "../lib/email/signin" /** * Handle requests to /api/auth/signin - * @type {import("src/lib/types").NextAuthApiHandler} + * @param {{ + * options: import("src/lib/types").InternalOptions + * query: import("src/server").IncomingRequest["query"] + * body: import("src/server").IncomingRequest["body"] + * }} + * @return {import("src/server").OutgoingResponse} */ -export default async function signin(req, res) { - const { baseUrl, basePath, adapter, callbacks, logger } = req.options +export default async function signin({ options, query, body }) { + const { base, adapter, callbacks, logger } = options /** @type {import("src/providers").OAuthConfig | import("src/providers").EmailConfig} */ - const provider = req.options.provider + const provider = options.provider if (!provider.type) { - return res.status(500).end(`Error: Type not specified for ${provider.name}`) + return { + status: 500, + text: `Error: Type not specified for ${provider.name}`, + } } if (provider.type === "oauth") { try { - const authorizationUrl = await getAuthorizationUrl(req, res) - return res.redirect(authorizationUrl) + return await getAuthorizationUrl({ options, query }) } catch (error) { logger.error("SIGNIN_OAUTH_ERROR", { error, provider }) - return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`) + return { redirect: `${base}/error?error=OAuthSignin` } } } else if (provider.type === "email") { if (!adapter) { @@ -29,7 +36,7 @@ export default async function signin(req, res) { "EMAIL_REQUIRES_ADAPTER_ERROR", new Error("E-mail login requires an adapter but it was undefined") ) - return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`) + return { redirect: `${base}/error?error=Configuration` } } // Note: Technically the part of the email address local mailbox element @@ -37,7 +44,7 @@ export default async function signin(req, res) { // according to RFC 2821, but in practice this causes more problems than // it solves. We treat email addresses as all lower case. If anyone // complains about this we can make strict RFC 2821 compliance an option. - const email = req.body.email?.toLowerCase() ?? null + const email = body.email?.toLowerCase() ?? null const { getUserByEmail } = adapter // If is an existing user return a user object (otherwise use placeholder) @@ -58,29 +65,27 @@ export default async function signin(req, res) { email: { verificationRequest: true }, }) if (!signInCallbackResponse) { - return res.redirect(`${baseUrl}${basePath}/error?error=AccessDenied`) + return { redirect: `${base}/error?error=AccessDenied` } } else if (typeof signInCallbackResponse === "string") { - return res.redirect(signInCallbackResponse) + return { redirect: signInCallbackResponse } } } catch (error) { - return res.redirect( - `${baseUrl}${basePath}/error?${new URLSearchParams({ error })}}` - ) + return { redirect: `${base}/error?${new URLSearchParams({ error })}}` } } try { - await emailSignin(email, req.options) + await emailSignin(email, options) } catch (error) { logger.error("SIGNIN_EMAIL_ERROR", error) - return res.redirect(`${baseUrl}${basePath}/error?error=EmailSignin`) + return { redirect: `${base}/error?error=EmailSignin` } } const params = new URLSearchParams({ provider: provider.id, type: provider.type, }) - const url = `${baseUrl}${basePath}/verify-request?${params}` - return res.redirect(url) + const url = `${base}/verify-request?${params}` + return { redirect: url } } - return res.redirect(`${baseUrl}${basePath}/signin`) + return { redirect: `${base}/signin` } } diff --git a/src/server/types.ts b/src/server/types.ts index dc2135934f..ad828960d4 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -201,7 +201,7 @@ export interface NextAuthOptions { * [Documentation](https://next-auth.js.org/configuration/options#theme) | * [Pages](https://next-auth.js.org/configuration/pages) */ -export type Theme = { +export interface Theme { colorScheme: "auto" | "dark" | "light" logo?: string brandColor?: string @@ -345,6 +345,7 @@ export interface CookieOption { secure: boolean maxAge?: number domain?: string + expires?: Date } } From ae0417884f405e05f2b7db7999efae026c0c5071 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 29 Sep 2021 13:18:18 +0200 Subject: [PATCH 02/28] refactor: use `base` instead of `baseUrl`+`basePath` --- src/server/init.ts | 5 +++-- src/server/lib/email/signin.ts | 4 ++-- src/server/pages/error.tsx | 4 ++-- src/server/pages/index.ts | 7 +++---- src/server/pages/signout.tsx | 12 ++++++++---- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/server/init.ts b/src/server/init.ts index e4bafceab5..25fb40ea9b 100644 --- a/src/server/init.ts +++ b/src/server/init.ts @@ -48,12 +48,13 @@ export async function init({ const { basePath, baseUrl } = parseUrl( process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL ) + const base = `${baseUrl}${basePath}` const secret = createSecret({ userOptions, basePath, baseUrl }) const { providers, provider } = parseProviders({ providers: userOptions.providers, - base: `${baseUrl}${basePath}`, + base, providerId, }) @@ -75,7 +76,7 @@ export async function init({ // and are request-specific. baseUrl, basePath, - base: `${baseUrl}${basePath}`, + base, action, provider, cookies: { diff --git a/src/server/lib/email/signin.ts b/src/server/lib/email/signin.ts index 4ac966b872..294d6f1e6b 100644 --- a/src/server/lib/email/signin.ts +++ b/src/server/lib/email/signin.ts @@ -10,7 +10,7 @@ export default async function email( identifier: string, options: InternalOptions<"email"> ) { - const { baseUrl, basePath, adapter, provider, logger, callbackUrl } = options + const { base, adapter, provider, logger, callbackUrl } = options // Generate token const token = @@ -32,7 +32,7 @@ export default async function email( // Generate a link with email, unhashed token and callback url const params = new URLSearchParams({ callbackUrl, token, email: identifier }) - const url = `${baseUrl}${basePath}/callback/${provider.id}?${params}` + const url = `${base}/callback/${provider.id}?${params}` try { // Send to user diff --git a/src/server/pages/error.tsx b/src/server/pages/error.tsx index 7e34071ef4..69487e8a21 100644 --- a/src/server/pages/error.tsx +++ b/src/server/pages/error.tsx @@ -6,8 +6,8 @@ * error?: string * }} params */ -export default function Error({ baseUrl, basePath, error = "default", theme }) { - const signinPageUrl = `${baseUrl}${basePath}/signin` +export default function Error({ baseUrl, base, error = "default", theme }) { + const signinPageUrl = `${base}/signin` const errors = { default: { diff --git a/src/server/pages/index.ts b/src/server/pages/index.ts index d0818b35c4..8c93626afe 100644 --- a/src/server/pages/index.ts +++ b/src/server/pages/index.ts @@ -18,8 +18,7 @@ export default function renderPage({ query: Record cookies: Cookie[] }) { - const { baseUrl, basePath, callbackUrl, csrfToken, providers, theme } = - options + const { base, baseUrl, callbackUrl, csrfToken, providers, theme } = options function send({ html, title, status }: any): OutgoingResponse { return { @@ -48,7 +47,7 @@ export default function renderPage({ }, signout(props?: any) { return send({ - html: signout({ csrfToken, baseUrl, basePath, theme, ...props }), + html: signout({ csrfToken, base, theme, ...props }), title: "Sign Out", }) }, @@ -60,7 +59,7 @@ export default function renderPage({ }, error(props) { return send({ - ...error({ basePath, baseUrl, theme, ...props }), + ...error({ base, baseUrl, theme, ...props }), title: "Error", }) }, diff --git a/src/server/pages/signout.tsx b/src/server/pages/signout.tsx index 287bf2bc54..d313bf6afd 100644 --- a/src/server/pages/signout.tsx +++ b/src/server/pages/signout.tsx @@ -1,16 +1,20 @@ -export default function Signout({ baseUrl, basePath, csrfToken, theme }) { +export default function Signout({ base, csrfToken, theme }) { return (
- ${title}${title}
${renderToString(html)}
`, } diff --git a/src/core/routes/callback.ts b/src/core/routes/callback.ts index 1d89fd490e..6bd4e90434 100644 --- a/src/core/routes/callback.ts +++ b/src/core/routes/callback.ts @@ -457,7 +457,7 @@ export default async function callback(params: { } return { status: 500, - text: `Error: Callback for provider type ${provider.type} not supported`, + body: `Error: Callback for provider type ${provider.type} not supported`, cookies, } } diff --git a/src/core/routes/providers.ts b/src/core/routes/providers.ts index aa3701ebbf..400ddad52d 100644 --- a/src/core/routes/providers.ts +++ b/src/core/routes/providers.ts @@ -1,6 +1,14 @@ import { OutgoingResponse } from ".." import { InternalProvider } from "../../lib/types" +export interface PublicProvider { + id: string + name: string + type: string + signinUrl: string + callbackUrl: string +} + /** * Return a JSON object with a list of all OAuth providers currently configured * and their signin and callback URLs. This makes it possible to automatically @@ -8,9 +16,10 @@ import { InternalProvider } from "../../lib/types" */ export default function providers( providers: InternalProvider[] -): OutgoingResponse { +): OutgoingResponse> { return { - json: providers.reduce( + headers: [{ key: "Content-Type", value: "application/json" }], + body: providers.reduce( (acc, { id, name, type, signinUrl, callbackUrl }) => { acc[id] = { id, name, type, signinUrl, callbackUrl } return acc diff --git a/src/core/routes/session.ts b/src/core/routes/session.ts index 53ca488f53..7aa073a654 100644 --- a/src/core/routes/session.ts +++ b/src/core/routes/session.ts @@ -1,7 +1,7 @@ import { Adapter } from "../../adapters" import { InternalOptions } from "../../lib/types" import { OutgoingResponse } from ".." -import * as cookie from "../lib/cookie" +import { Session } from "../.." import { fromDate } from "../lib/utils" interface SessionParams { @@ -16,19 +16,20 @@ interface SessionParams { export default async function session( params: SessionParams -): Promise { +): Promise> { const { options, sessionToken } = params const { adapter, jwt, events, callbacks, logger } = options const useJwtSession = options.session.jwt const sessionMaxAge = options.session.maxAge - if (!sessionToken) { - return { json: {} } + const response: OutgoingResponse = { + body: {}, + headers: [{ key: "Content-Type", value: "application/json" }], + cookies: [], } - let sessionCookie: cookie.Cookie | undefined + if (!sessionToken) return response - let json = {} if (useJwtSession) { try { // Decrypt and verify token @@ -58,31 +59,31 @@ export default async function session( }) // Return session payload as response - json = session + response.body = session // Refresh JWT expiry by re-signing it, with an updated expiry date const newToken = await jwt.encode({ ...jwt, token }) // Set cookie, to also update expiry date on cookie - sessionCookie = { + response.cookies?.push({ name: options.cookies.sessionToken.name, value: newToken, options: { expires: newExpires, ...options.cookies.sessionToken.options, }, - } + }) await events.session?.({ session, token }) } catch (error) { // If JWT not verifiable, make sure the cookie for it is removed and return empty object logger.error("JWT_SESSION_ERROR", error) - sessionCookie = { + response.cookies?.push({ name: options.cookies.sessionToken.name, value: "", options: { ...options.cookies.sessionToken.options, maxAge: 0 }, - } + }) } } else { try { @@ -135,33 +136,33 @@ export default async function session( }) // Return session payload as response - json = sessionPayload + response.body = sessionPayload // Set cookie again to update expiry - sessionCookie = { + response.cookies?.push({ name: options.cookies.sessionToken.name, value: sessionToken, options: { expires: newExpires, ...options.cookies.sessionToken.options, }, - } + }) // @ts-expect-error await events.session?.({ session: sessionPayload }) } else if (sessionToken) { // If sessionToken was found set but it's not valid for a session then // remove the sessionToken cookie from browser. - sessionCookie = { + response.cookies?.push({ name: options.cookies.sessionToken.name, value: "", options: { ...options.cookies.sessionToken.options, maxAge: 0 }, - } + }) } } catch (error) { logger.error("SESSION_ERROR", error) } } - return { json, cookies: sessionCookie ? [sessionCookie] : undefined } + return response } diff --git a/src/next/index.ts b/src/next/index.ts index 2fb7d58072..485ce6dba1 100644 --- a/src/next/index.ts +++ b/src/next/index.ts @@ -4,14 +4,16 @@ import { NextApiResponse, } from "next" import { NextAuthOptions, Session } from ".." -import { IncomingRequest, NextAuthHandler } from "../core" -import extendRes from "../core/lib/extend-res" +import { NextAuthHandler } from "../core" +import { NextAuthAction } from "../lib/types" import { set as setCookie } from "../core/lib/cookie" import logger, { setLogger } from "../lib/logger" -async function NextAuthNextHandler(req, res, options) { - extendRes(req, res) - +async function NextAuthNextHandler( + req: NextApiRequest, + res: NextApiResponse, + options: NextAuthOptions +) { const { nextauth, action = nextauth[0], @@ -34,24 +36,25 @@ async function NextAuthNextHandler(req, res, options) { } } - const request: IncomingRequest = { - body: req.body, - query: req.query, - cookies: req.cookies, - headers: req.headers, - method: req.method, - action, - providerId, - error, - } const { - json, + body, redirect, - text, cookies, headers, status = 200, - } = await NextAuthHandler({ req: request, options }) + } = await NextAuthHandler({ + req: { + body: req.body, + query: req.query, + cookies: req.cookies, + headers: req.headers, + method: req.method ?? "GET", + action: action as NextAuthAction, + providerId: providerId as string | undefined, + error: error as string | undefined, + }, + options, + }) res.status(status) @@ -63,15 +66,18 @@ async function NextAuthNextHandler(req, res, options) { }) if (redirect) { - return res.redirect(redirect) - } else if (json) { - return res.json(json) - } else if (text) { - return res.send(text) + // 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) + return res.end() + } + return res.json({ url: redirect }) } - return res - .status(400) - .send(`Error: HTTP ${req.method} is not supported for ${req.url}`) + + return res.send(body) } function NextAuth(options: NextAuthOptions): any @@ -98,7 +104,7 @@ export async function getServerSession( | { req: NextApiRequest; res: NextApiResponse }, options: NextAuthOptions ): Promise { - const session = await NextAuthHandler({ + const session = await NextAuthHandler({ options, req: { action: "session", @@ -108,12 +114,12 @@ export async function getServerSession( }, }) - const { json, cookies } = session + const { body, cookies } = session cookies?.forEach((cookie) => { setCookie(context.res, cookie.name, cookie.value, cookie.options) }) - if (!json) return null - return Object.keys(json) ? json : null + if (body && Object.keys(body).length) return body as Session + return null } From 0b8b5dd401bbb47a7da06c5c9e156b1e4eb9598f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 3 Oct 2021 11:32:11 +0200 Subject: [PATCH 25/28] feat: pass `NEXTAUTH_URL` as a variable to core --- src/core/index.ts | 9 +++------ src/core/init.ts | 4 +++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/core/index.ts b/src/core/index.ts index 92f75fa5bb..a16de951a3 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -8,6 +8,8 @@ import { Cookie } from "./lib/cookie" import { NextAuthAction } from "../lib/types" export interface IncomingRequest { + /** @default "http://localhost:3000" */ + host?: string method: string cookies?: Record headers?: Record @@ -44,16 +46,11 @@ export async function NextAuthHandler< const { options: userOptions, req } = params const { action, providerId, error } = req - // To work properly in production with OAuth providers the NEXTAUTH_URL - // environment variable must be set. - if (!process.env.NEXTAUTH_URL) { - logger.warn("NEXTAUTH_URL") - } - const { options, cookies } = await init({ userOptions, action, providerId, + host: req.host, callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl, csrfToken: req.body?.csrfToken, cookies: req.cookies, diff --git a/src/core/init.ts b/src/core/init.ts index f03c206e85..3c0756567c 100644 --- a/src/core/init.ts +++ b/src/core/init.ts @@ -13,6 +13,7 @@ import { createCallbackUrl } from "./lib/callback-url" import { IncomingRequest } from "." interface InitParams { + host?: string userOptions: NextAuthOptions providerId?: string action: InternalOptions["action"] @@ -30,6 +31,7 @@ export async function init({ userOptions, providerId, action, + host, cookies: reqCookies, callbackUrl: reqCallbackUrl, csrfToken: reqCsrfToken, @@ -43,7 +45,7 @@ export async function init({ ;(process.env._NEXTAUTH_DEBUG as any) = true } - const url = parseUrl(process.env.NEXTAUTH_URL ?? process.env.VERCEL_URL) + const url = parseUrl(host) const secret = createSecret({ userOptions, url }) From 8844846c99052b8d592580ac48d7f7c02159c92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Sun, 3 Oct 2021 11:32:33 +0200 Subject: [PATCH 26/28] refactor: simplify Next.js wrapper --- src/next/index.ts | 31 ++++++++++++------------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/src/next/index.ts b/src/next/index.ts index 485ce6dba1..58df318f23 100644 --- a/src/next/index.ts +++ b/src/next/index.ts @@ -14,28 +14,20 @@ async function NextAuthNextHandler( res: NextApiResponse, options: NextAuthOptions ) { - const { - nextauth, - action = nextauth[0], - providerId = nextauth[1], - error = nextauth[1], - } = req.query - - if (options.logger) { - setLogger(options.logger) - } + setLogger(options.logger) if (!req.query.nextauth) { - const message = + 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", new Error(message)) - return { - status: 500, - text: `Error: ${message}`, - } + 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 + if (!host) logger.warn("NEXTAUTH_URL") + const { body, redirect, @@ -44,14 +36,15 @@ async function NextAuthNextHandler( status = 200, } = await NextAuthHandler({ req: { + host, body: req.body, query: req.query, cookies: req.cookies, headers: req.headers, method: req.method ?? "GET", - action: action as NextAuthAction, - providerId: providerId as string | undefined, - error: error as string | undefined, + action: req.query.nextauth[0] as NextAuthAction, + providerId: req.query.nextauth[1], + error: req.query.nextauth[1], }, options, }) From fc5ba18d709fe7d50c57dafb8976990620b06bf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 13 Oct 2021 11:47:19 +0200 Subject: [PATCH 27/28] feat: export `client/_utils` --- package.json | 2 ++ src/{lib/client.ts => client/_utils.ts} | 0 src/react/index.tsx | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) rename src/{lib/client.ts => client/_utils.ts} (100%) diff --git a/package.json b/package.json index dcaa76fe2b..6a0eca7969 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "./react": "./react/index.js", "./core": "./core/index.js", "./next": "./next/index.js", + "./client/_utils": "./client/_utils.js", "./providers/*": "./providers/*.js" }, "scripts": { @@ -50,6 +51,7 @@ "jwt", "react", "next", + "client", "providers", "core", "index.d.ts", diff --git a/src/lib/client.ts b/src/client/_utils.ts similarity index 100% rename from src/lib/client.ts rename to src/client/_utils.ts diff --git a/src/react/index.tsx b/src/react/index.tsx index 5667f66777..9d405dbfa8 100644 --- a/src/react/index.tsx +++ b/src/react/index.tsx @@ -19,7 +19,7 @@ import { fetchData, now, NextAuthClientConfig, -} from "../lib/client" +} from "../client/_utils" import type { ClientSafeProvider, From f71bfe0b4f964b48c72c46e83c27d776f78b0dbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 13 Oct 2021 12:13:21 +0200 Subject: [PATCH 28/28] fix(ts): suppress TS errors --- src/core/lib/oauth/callback.ts | 6 ++++++ src/core/lib/oauth/client.ts | 10 +++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/core/lib/oauth/callback.ts b/src/core/lib/oauth/callback.ts index f03c66673e..ebd019c530 100644 --- a/src/core/lib/oauth/callback.ts +++ b/src/core/lib/oauth/callback.ts @@ -84,10 +84,13 @@ export default async function oAuthCallback(params: { body, method, }), + // @ts-expect-error ...provider.token?.params, } + // @ts-expect-error if (provider.token?.request) { + // @ts-expect-error const response = await provider.token.request({ provider, params, @@ -107,7 +110,9 @@ export default async function oAuthCallback(params: { } let profile: Profile + // @ts-expect-error if (provider.userinfo?.request) { + // @ts-expect-error profile = await provider.userinfo.request({ provider, tokens, @@ -117,6 +122,7 @@ export default async function oAuthCallback(params: { profile = tokens.claims() } else { profile = await client.userinfo(tokens, { + // @ts-expect-error params: provider.userinfo?.params, }) } diff --git a/src/core/lib/oauth/client.ts b/src/core/lib/oauth/client.ts index b55ac43260..55d5cc6b37 100644 --- a/src/core/lib/oauth/client.ts +++ b/src/core/lib/oauth/client.ts @@ -19,9 +19,13 @@ export async function openidClient( } else { issuer = new Issuer({ issuer: provider.issuer as string, - authorization_endpoint: provider.authorization.url, - token_endpoint: provider.token.url, - userinfo_endpoint: provider.userinfo.url, + authorization_endpoint: + // @ts-expect-error + provider.authorization?.url ?? provider.authorization, + // @ts-expect-error + token_endpoint: provider.token?.url ?? provider.token, + // @ts-expect-error + userinfo_endpoint: provider.userinfo?.url ?? provider.userinfo, }) }