From eb33c9db1df7e98a65e6555ce3f4bb00f8f27b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Wed, 27 Oct 2021 16:11:58 +0200 Subject: [PATCH] refactor: decouple Next.js from core (#2857) * refactor: decouple Next.js from core (WIP) * refactor: use `base` instead of `baseUrl`+`basePath` * fix: signout route * refactor(ts): convert files to TS * fix: imports * refactor: convert callback route * fix: add `next` files to package * chore(dev): alias npm email * refactor: do not merge req with user options * refactor: rename userOptions to options * refactor: use native `URL` in `parseUrl` * refactor: move Next.js specific code to `next` module * refactor(ts): return `OutgoingResponse` on all routes * fix: change `base` to `url` * feat: introduce `getServerSession` * refactor: move main logic to `handler` file * chore(dev): showcase `getServerSession` * feat: extract `sessionToken` from Authorization header * fix: pass headers to getServerSession * refactor: rename `server` to `core` * refactor: re-export `next-auth/next` in `next-auth` * fix: add `core` to npm package * fix: re-export default method * feat: return `body`+`header` instead of `json`,`text` * feat: pass `NEXTAUTH_URL` as a variable to core * refactor: simplify Next.js wrapper * feat: export `client/_utils` * fix(ts): suppress TS errors --- .gitignore | 3 +- app/package.json | 3 +- app/pages/_app.js | 7 +- app/pages/api/auth/[...nextauth].ts | 13 +- app/pages/protected-ssr.js | 5 +- config/babel.config.js | 2 +- package.json | 11 +- src/{lib/client.ts => client/_utils.ts} | 0 src/{server => core}/errors.ts | 0 src/core/index.ts | 210 +++++++++++++ src/core/init.ts | 151 ++++++++++ src/{server => core}/lib/callback-handler.ts | 11 +- src/core/lib/callback-url.ts | 42 +++ src/{server => core}/lib/cookie.ts | 5 + .../lib/csrf-token.ts} | 54 ++-- src/{server => core}/lib/default-callbacks.ts | 0 src/{server => core}/lib/email/signin.ts | 8 +- src/core/lib/oauth/authorization-url.ts | 73 +++++ src/{server => core}/lib/oauth/callback.ts | 80 +++-- .../lib/oauth/client-legacy.ts | 0 src/{server => core}/lib/oauth/client.ts | 16 +- .../lib/oauth/pkce-handler.ts} | 74 +++-- src/core/lib/oauth/state-handler.ts | 33 ++ src/{server => core}/lib/providers.ts | 11 +- src/{server => core}/lib/utils.ts | 20 +- src/{server => core}/pages/error.tsx | 71 +++-- src/core/pages/index.ts | 67 +++++ src/{server => core}/pages/signin.tsx | 37 ++- src/core/pages/signout.tsx | 35 +++ src/core/pages/verify-request.tsx | 35 +++ .../callback.js => core/routes/callback.ts} | 282 +++++++++++------- src/{server => core}/routes/index.ts | 0 src/core/routes/providers.ts | 30 ++ .../session.js => core/routes/session.ts} | 86 ++++-- .../signin.js => core/routes/signin.ts} | 66 ++-- src/core/routes/signout.ts | 49 +++ src/{server => core}/types.ts | 5 +- src/index.ts | 8 +- src/lib/logger.ts | 3 +- src/lib/parse-url.ts | 51 ++-- src/lib/types.ts | 38 +-- src/next/index.ts | 118 ++++++++ src/providers/credentials.ts | 6 +- src/providers/dropbox.js | 2 +- src/providers/oauth.ts | 1 + src/react/index.tsx | 10 +- src/server/index.ts | 265 ---------------- src/server/lib/callback-url-handler.ts | 46 --- src/server/lib/extend-res.ts | 13 - src/server/lib/oauth/authorization-url.js | 53 ---- src/server/lib/oauth/state-handler.js | 35 --- src/server/pages/index.ts | 55 ---- src/server/pages/signout.tsx | 22 -- src/server/pages/verify-request.tsx | 23 -- src/server/routes/providers.js | 20 -- src/server/routes/signout.js | 39 --- 56 files changed, 1429 insertions(+), 974 deletions(-) rename src/{lib/client.ts => client/_utils.ts} (100%) rename src/{server => core}/errors.ts (100%) create mode 100644 src/core/index.ts create mode 100644 src/core/init.ts rename src/{server => core}/lib/callback-handler.ts (97%) create mode 100644 src/core/lib/callback-url.ts rename src/{server => core}/lib/cookie.ts (98%) rename src/{server/lib/csrf-token-handler.ts => core/lib/csrf-token.ts} (56%) rename src/{server => core}/lib/default-callbacks.ts (100%) rename src/{server => core}/lib/email/signin.ts (84%) create mode 100644 src/core/lib/oauth/authorization-url.ts rename src/{server => core}/lib/oauth/callback.ts (68%) rename src/{server => core}/lib/oauth/client-legacy.ts (100%) rename src/{server => core}/lib/oauth/client.ts (61%) rename src/{server/lib/oauth/pkce-handler.js => core/lib/oauth/pkce-handler.ts} (54%) create mode 100644 src/core/lib/oauth/state-handler.ts rename src/{server => core}/lib/providers.ts (84%) rename src/{server => core}/lib/utils.ts (82%) rename src/{server => core}/pages/error.tsx (55%) create mode 100644 src/core/pages/index.ts rename src/{server => core}/pages/signin.tsx (90%) create mode 100644 src/core/pages/signout.tsx create mode 100644 src/core/pages/verify-request.tsx rename src/{server/routes/callback.js => core/routes/callback.ts} (59%) rename src/{server => core}/routes/index.ts (100%) create mode 100644 src/core/routes/providers.ts rename src/{server/routes/session.js => core/routes/session.ts} (66%) rename src/{server/routes/signin.js => core/routes/signin.ts} (51%) create mode 100644 src/core/routes/signout.ts rename src/{server => core}/types.ts (99%) create mode 100644 src/next/index.ts delete mode 100644 src/server/index.ts delete mode 100644 src/server/lib/callback-url-handler.ts delete mode 100644 src/server/lib/extend-res.ts delete mode 100644 src/server/lib/oauth/authorization-url.js delete mode 100644 src/server/lib/oauth/state-handler.js delete mode 100644 src/server/pages/index.ts delete mode 100644 src/server/pages/signout.tsx delete mode 100644 src/server/pages/verify-request.tsx delete mode 100644 src/server/routes/providers.js delete mode 100644 src/server/routes/signout.js diff --git a/.gitignore b/.gitignore index bc6e4301b3..3fbefcc2c3 100644 --- a/.gitignore +++ b/.gitignore @@ -29,12 +29,13 @@ node_modules /client /css /lib -/server +/core /jwt /react /adapters.d.ts /index.d.ts /index.js +/next # Development app app/src/css diff --git a/app/package.json b/app/package.json index f4bc29ffff..7e892f7a8b 100644 --- a/app/package.json +++ b/app/package.json @@ -10,7 +10,8 @@ "copy:css": "cpx \"../css/**/*\" src/css --watch", "watch:css": "cd .. && npm run watch:css", "start": "next start", - "start:email": "npx fake-smtp-server" + "email": "npx fake-smtp-server", + "start:email": "email" }, "license": "ISC", "dependencies": { diff --git a/app/pages/_app.js b/app/pages/_app.js index e073fafcbb..5a467b7a09 100644 --- a/app/pages/_app.js +++ b/app/pages/_app.js @@ -1,12 +1,9 @@ import { SessionProvider } from "next-auth/react" import "./styles.css" -export default function App({ - Component, - pageProps: { session, ...pageProps }, -}) { +export default function App({ Component, pageProps }) { return ( - + ) diff --git a/app/pages/api/auth/[...nextauth].ts b/app/pages/api/auth/[...nextauth].ts index ed9d6e7fb9..fa90c6d8fe 100644 --- a/app/pages/api/auth/[...nextauth].ts +++ b/app/pages/api/auth/[...nextauth].ts @@ -1,4 +1,4 @@ -import NextAuth from "next-auth" +import NextAuth, { NextAuthOptions } from "next-auth" import EmailProvider from "next-auth/providers/email" import GitHubProvider from "next-auth/providers/github" import Auth0Provider from "next-auth/providers/auth0" @@ -37,8 +37,7 @@ import AzureB2C from "next-auth/providers/azure-ad-b2c" // domain: process.env.FAUNA_DOMAIN, // }) // const adapter = FaunaAdapter(client) - -export default NextAuth({ +export const authOptions: NextAuthOptions = { // adapter, providers: [ // E-mail @@ -58,8 +57,8 @@ export default NextAuth({ credentials: { password: { label: "Password", type: "password" }, }, - async authorize(credentials, req) { - if (credentials.password === "password") { + async authorize(credentials) { + if (credentials.password === "pw") { return { name: "Fill Murray", email: "bill@fillmurray.com", @@ -179,4 +178,6 @@ export default NextAuth({ logo: "https://next-auth.js.org/img/logo/logo-sm.png", brandColor: "#1786fb", }, -}) +} + +export default NextAuth(authOptions) diff --git a/app/pages/protected-ssr.js b/app/pages/protected-ssr.js index b7dc39a2a7..3ae37daf0d 100644 --- a/app/pages/protected-ssr.js +++ b/app/pages/protected-ssr.js @@ -1,5 +1,6 @@ // This is an example of how to protect content using server rendering -import { getSession } from "next-auth/react" +import { getServerSession } from "next-auth/next" +import { authOptions } from "./api/auth/[...nextauth]" import Layout from "../components/layout" import AccessDenied from "../components/access-denied" @@ -25,7 +26,7 @@ export default function Page({ content, session }) { } export async function getServerSideProps(context) { - const session = await getSession(context) + const session = await getServerSession(context, authOptions) let content = null if (session) { diff --git a/config/babel.config.js b/config/babel.config.js index afba6b89b5..ab7851a895 100644 --- a/config/babel.config.js +++ b/config/babel.config.js @@ -38,7 +38,7 @@ module.exports = (api) => { ], }, { - test: ["../src/server/pages/*.tsx"], + test: ["../src/core/pages/*.tsx"], presets: ["preact"], plugins: [ [ diff --git a/package.json b/package.json index b0888963f9..8a88554740 100644 --- a/package.json +++ b/package.json @@ -24,11 +24,14 @@ ".": "./index.js", "./jwt": "./jwt/index.js", "./react": "./react/index.js", + "./core": "./core/index.js", + "./next": "./next/index.js", + "./client/_utils": "./client/_utils.js", "./providers/*": "./providers/*.js" }, "scripts": { "build": "npm run build:js && npm run build:css", - "clean": "rm -rf client css lib providers server jwt react index.d.ts index.js adapters.d.ts", + "clean": "rm -rf client css lib providers core jwt react next index.d.ts index.js adapters.d.ts", "build:js": "npm run clean && npm run generate-providers && tsc && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"", "build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js", "dev:setup": "npm i && npm run generate-providers && npm run build:css && cd app && npm i", @@ -47,8 +50,10 @@ "css", "jwt", "react", + "next", + "client", "providers", - "server", + "core", "index.d.ts", "index.js", "adapters.d.ts" @@ -137,7 +142,7 @@ "types", ".next", "dist", - "/server", + "/core", "/react.js" ], "globals": { 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/server/errors.ts b/src/core/errors.ts similarity index 100% rename from src/server/errors.ts rename to src/core/errors.ts diff --git a/src/core/index.ts b/src/core/index.ts new file mode 100644 index 0000000000..a16de951a3 --- /dev/null +++ b/src/core/index.ts @@ -0,0 +1,210 @@ +import logger 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 { NextAuthAction } from "../lib/types" + +export interface IncomingRequest { + /** @default "http://localhost:3000" */ + host?: string + method: string + cookies?: Record + headers?: Record + query?: Record + body?: Record + action: NextAuthAction + providerId?: string + error?: string +} + +export interface NextAuthHeader { + key: string + value: string +} + +export interface OutgoingResponse< + Body extends string | Record | any[] = any +> { + status?: number + headers?: NextAuthHeader[] + body?: Body + redirect?: string + cookies?: Cookie[] +} + +interface NextAuthHandlerParams { + req: IncomingRequest + options: NextAuthOptions +} + +export async function NextAuthHandler< + Body extends string | Record | any[] +>(params: NextAuthHandlerParams): Promise> { + const { options: userOptions, req } = params + const { action, providerId, error } = req + + 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, + isPost: req.method === "POST", + }) + + const sessionToken = + req.cookies?.[options.cookies.sessionToken.name] || + req.headers?.Authorization?.replace("Bearer ", "") + + const codeVerifier = req.cookies?.[options.cookies.pkceCodeVerifier.name] + + if (req.method === "GET") { + const render = renderPage({ options, query: req.query, cookies }) + const { pages } = options + switch (action) { + case "providers": + return (await routes.providers(options.providers)) as any + case "session": + return (await routes.session({ options, sessionToken })) as any + case "csrf": + return { + headers: [{ key: "Content-Type", value: "application/json" }], + body: { csrfToken: options.csrfToken } as any, + cookies, + } + case "signin": + if (pages.signIn) { + let signinUrl = `${pages.signIn}${ + pages.signIn.includes("?") ? "&" : "?" + }callbackUrl=${options.callbackUrl}` + if (error) signinUrl = `${signinUrl}&error=${error}` + return { redirect: signinUrl, cookies } + } + + return render.signin() + case "signout": + if (pages.signOut) return { redirect: pages.signOut, cookies } + + return render.signout() + case "callback": + if (options.provider) { + const callback = await routes.callback({ + body: req.body, + query: req.query, + method: req.method, + headers: req.headers, + options, + sessionToken, + codeVerifier, + }) + if (callback.cookies) cookies.push(...callback.cookies) + return { ...callback, cookies } + } + break + case "verify-request": + if (pages.verifyRequest) { + return { redirect: pages.verifyRequest, cookies } + } + return render.verifyRequest() + case "error": + if (pages.error) { + return { + redirect: `${pages.error}${ + pages.error.includes("?") ? "&" : "?" + }error=${error}`, + cookies, + } + } + + // These error messages are displayed in line on the sign in page + if ( + [ + "Signin", + "OAuthSignin", + "OAuthCallback", + "OAuthCreateAccount", + "EmailCreateAccount", + "Callback", + "OAuthAccountNotLinked", + "EmailSignin", + "CredentialsSignin", + "SessionRequired", + ].includes(error as string) + ) { + return { redirect: `${options.url}/signin?error=${error}`, cookies } + } + + return render.error({ error }) + default: + } + } else if (req.method === "POST") { + switch (action) { + case "signin": + // Verified CSRF Token required for all sign in routes + 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 { redirect: `${options.url}/signin?csrf=true`, cookies } + case "signout": + // Verified CSRF Token required for signout + if (options.csrfTokenVerified) { + const signout = await routes.signout({ options, sessionToken }) + if (signout.cookies) cookies.push(...signout.cookies) + return { ...signout, cookies } + } + return { redirect: `${options.url}/signout?csrf=true`, cookies } + case "callback": + if (options.provider) { + // Verified CSRF Token required for credentials providers only + if ( + options.provider.type === "credentials" && + !options.csrfTokenVerified + ) { + return { redirect: `${options.url}/signin?csrf=true`, cookies } + } + + const callback = await routes.callback({ + body: req.body, + query: req.query, + method: req.method, + headers: req.headers, + options, + sessionToken, + codeVerifier, + }) + if (callback.cookies) cookies.push(...callback.cookies) + return { ...callback, cookies } + } + break + case "_log": + if (userOptions.logger) { + try { + const { code, level, ...metadata } = req.body ?? {} + logger[level](code, metadata) + } catch (error) { + // If logging itself failed... + logger.error("LOGGER_ERROR", error) + } + } + return {} + default: + } + } + + return { + status: 400, + body: `Error: Action ${action} with HTTP ${req.method} is not supported by NextAuth.js` as any, + } +} diff --git a/src/core/init.ts b/src/core/init.ts new file mode 100644 index 0000000000..3c0756567c --- /dev/null +++ b/src/core/init.ts @@ -0,0 +1,151 @@ +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" +import { IncomingRequest } from "." + +interface InitParams { + host?: string + 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: IncomingRequest["cookies"] +} + +/** Initialize all internal options and cookies. */ +export async function init({ + userOptions, + providerId, + action, + host, + 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 url = parseUrl(host) + + const secret = createSecret({ userOptions, url }) + + const { providers, provider } = parseProviders({ + providers: userOptions.providers, + url, + 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. + url, + action, + provider, + cookies: { + ...cookie.defaultCookies( + userOptions.useSecureCookies ?? url.base.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-handler.ts b/src/core/lib/callback-handler.ts similarity index 97% rename from src/server/lib/callback-handler.ts rename to src/core/lib/callback-handler.ts index c4610e9c79..89d71362e2 100644 --- a/src/server/lib/callback-handler.ts +++ b/src/core/lib/callback-handler.ts @@ -19,12 +19,13 @@ import { SessionToken } from "./cookie" * done prior to this handler being called to avoid additonal complexity in this * handler. */ -export default async function callbackHandler( - sessionToken: SessionToken, - profile: User, - account: Account, +export default async function callbackHandler(params: { + sessionToken?: SessionToken + profile: User + account: Account options: InternalOptions -) { +}) { + const { sessionToken, profile, account, options } = params // Input validation if (!account?.providerAccountId || !account.type) throw new Error("Missing or invalid provider account") diff --git a/src/core/lib/callback-url.ts b/src/core/lib/callback-url.ts new file mode 100644 index 0000000000..85b8b6174e --- /dev/null +++ b/src/core/lib/callback-url.ts @@ -0,0 +1,42 @@ +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 { url, callbacks } = options + + let callbackUrl = url.origin + + if (paramValue) { + // If callbackUrl form field or query parameter is passed try to use it if allowed + callbackUrl = await callbacks.redirect({ + url: paramValue, + baseUrl: url.origin, + }) + } else if (cookieValue) { + // If no callbackUrl specified, try using the value from the cookie if allowed + callbackUrl = await callbacks.redirect({ + url: cookieValue, + baseUrl: url.origin, + }) + } + + 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/core/lib/cookie.ts similarity index 98% rename from src/server/lib/cookie.ts rename to src/core/lib/cookie.ts index 2e0a7543ef..c787228a0f 100644 --- a/src/server/lib/cookie.ts +++ b/src/core/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/core/lib/csrf-token.ts similarity index 56% rename from src/server/lib/csrf-token-handler.ts rename to src/core/lib/csrf-token.ts index e4379176c7..04a54d3372 100644 --- a/src/server/lib/csrf-token-handler.ts +++ b/src/core/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/default-callbacks.ts b/src/core/lib/default-callbacks.ts similarity index 100% rename from src/server/lib/default-callbacks.ts rename to src/core/lib/default-callbacks.ts diff --git a/src/server/lib/email/signin.ts b/src/core/lib/email/signin.ts similarity index 84% rename from src/server/lib/email/signin.ts rename to src/core/lib/email/signin.ts index 4ac966b872..c073c8df70 100644 --- a/src/server/lib/email/signin.ts +++ b/src/core/lib/email/signin.ts @@ -1,5 +1,5 @@ import { randomBytes } from "crypto" -import { InternalOptions } from "src/lib/types" +import { InternalOptions } from "../../../lib/types" import { hashToken } from "../utils" /** @@ -10,7 +10,7 @@ export default async function email( identifier: string, options: InternalOptions<"email"> ) { - const { baseUrl, basePath, adapter, provider, logger, callbackUrl } = options + const { url, 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 = `${url}/callback/${provider.id}?${params}` try { // Send to user @@ -40,7 +40,7 @@ export default async function email( identifier, token, expires, - url, + url: _url, provider, }) } catch (error) { diff --git a/src/core/lib/oauth/authorization-url.ts b/src/core/lib/oauth/authorization-url.ts new file mode 100644 index 0000000000..f4016d91d9 --- /dev/null +++ b/src/core/lib/oauth/authorization-url.ts @@ -0,0 +1,73 @@ +import { openidClient } from "./client" +import { oAuth1Client } from "./client-legacy" +import { createState } from "./state-handler" +import { createPKCE } from "./pkce-handler" +import { InternalOptions } from "../../../lib/types" +import { IncomingRequest } from "../.." +import { Cookie } from "../cookie" + +/** + * + * 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) + */ +export default async function getAuthorizationUrl(params: { + options: InternalOptions<"oauth"> + query: IncomingRequest["query"] +}) { + const { options, query } = params + const { logger, provider } = options + try { + let params: any = {} + + if (typeof provider.authorization === "string") { + const parsedUrl = new URL(provider.authorization) + const parsedParams = Object.fromEntries(parsedUrl.searchParams.entries()) + params = { ...params, ...parsedParams } + } else { + params = { ...params, ...provider.authorization?.params } + } + + params = { ...params, ...query } + + // Handle OAuth v1.x + if (provider.version?.startsWith("1.")) { + const client = oAuth1Client(options) + const tokens = (await client.getOAuthRequestToken(params)) as any + const url = `${ + // @ts-expect-error + provider.authorization?.url ?? provider.authorization + }?${new URLSearchParams({ + oauth_token: tokens.oauth_token, + oauth_token_secret: tokens.oauth_token_secret, + ...tokens.params, + })}` + + logger.debug("GET_AUTHORIZATION_URL", { url }) + return { redirect: url } + } + + const cookies: Cookie[] = [] + const client = await openidClient(options) + const pkce = await createPKCE(options) + if (pkce?.cookie) { + cookies.push(pkce.cookie) + } + + const url = client.authorizationUrl({ + ...params, + ...pkce, + state: createState(options), + }) + + logger.debug("GET_AUTHORIZATION_URL", { url }) + return { + redirect: url, + cookies, + } + } catch (error) { + logger.error("GET_AUTHORIZATION_URL_ERROR", error) + throw error + } +} diff --git a/src/server/lib/oauth/callback.ts b/src/core/lib/oauth/callback.ts similarity index 68% rename from src/server/lib/oauth/callback.ts rename to src/core/lib/oauth/callback.ts index 5cf0009645..ebd019c530 100644 --- a/src/server/lib/oauth/callback.ts +++ b/src/core/lib/oauth/callback.ts @@ -1,28 +1,31 @@ +import { TokenSet } from "openid-client" import { openidClient } from "./client" import { oAuth1Client } from "./client-legacy" import { getState } from "./state-handler" import { usePKCECodeVerifier } from "./pkce-handler" import { OAuthCallbackError } from "../../errors" -import { TokenSet } from "openid-client" -import { Account, LoggerInstance, Profile } from "src" -import { OAuthChecks, OAuthConfig } from "src/providers" - -export default async function oAuthCallback( - req, - res -): Promise { - const { logger } = req.options - - /** @type {import("src/providers").OAuthConfig} */ - const provider = req.options.provider - - const errorMessage = req.body.error ?? req.query.error +import { Account, LoggerInstance, Profile } from "../../.." +import { OAuthChecks, OAuthConfig } from "../../../providers" +import { InternalOptions } from "../../../lib/types" +import { IncomingRequest, OutgoingResponse } from "../.." + +export default async function oAuthCallback(params: { + options: InternalOptions<"oauth"> + query: IncomingRequest["query"] + body: IncomingRequest["body"] + method: IncomingRequest["method"] + codeVerifier?: string +}): Promise { + const { options, query, body, method, codeVerifier } = params + const { logger, provider } = options + + const errorMessage = body?.error ?? query?.error if (errorMessage) { const error = new Error(errorMessage) logger.error("OAUTH_CALLBACK_HANDLER_ERROR", { error, - error_description: req.query?.error_description, - body: req.body, + error_description: query?.error_description, + body, providerId: provider.id, }) throw error @@ -30,9 +33,9 @@ export default async function oAuthCallback( if (provider.version?.startsWith("1.")) { try { - const client = await oAuth1Client(req.options) + const client = await oAuth1Client(options) // Handle OAuth v1.x - const { oauth_token, oauth_verifier } = req.query + const { oauth_token, oauth_verifier } = query ?? {} // @ts-expect-error const tokens: TokenSet = await client.getOAuthAccessToken( oauth_token, @@ -59,18 +62,35 @@ export default async function oAuthCallback( } try { - const client = await openidClient(req.options) + const client = await openidClient(options) /** @type {import("openid-client").TokenSet} */ let tokens + const pkce = await usePKCECodeVerifier({ + options, + codeVerifier, + }) const checks: OAuthChecks = { - code_verifier: await usePKCECodeVerifier(req, res), - state: getState(req), + code_verifier: pkce?.codeVerifier, + state: getState(options), + } + const params = { + ...client.callbackParams({ + url: `http://n?${new URLSearchParams(query)}`, + // TODO: Ask to allow object to be passed upstream: + // https://github.com/panva/node-openid-client/blob/3ae206dfc78c02134aa87a07f693052c637cab84/types/index.d.ts#L439 + // @ts-expect-error + body, + method, + }), + // @ts-expect-error + ...provider.token?.params, } - const params = { ...client.callbackParams(req), ...provider.token?.params } + // @ts-expect-error if (provider.token?.request) { + // @ts-expect-error const response = await provider.token.request({ provider, params, @@ -90,7 +110,9 @@ export default async function oAuthCallback( } let profile: Profile + // @ts-expect-error if (provider.userinfo?.request) { + // @ts-expect-error profile = await provider.userinfo.request({ provider, tokens, @@ -100,15 +122,25 @@ export default async function oAuthCallback( profile = tokens.claims() } else { profile = await client.userinfo(tokens, { + // @ts-expect-error params: provider.userinfo?.params, }) } // If a user object is supplied (e.g. Apple provider) add it to the profile object // TODO: Remove/extract to Apple provider? - profile.user = JSON.parse(req.body.user ?? req.query.user ?? null) + profile.user = JSON.parse(body?.user ?? query?.user ?? null) - return await getProfile({ profile, provider, tokens, logger }) + const profileResult = await getProfile({ + profile, + provider, + tokens, + logger, + }) + return { + ...profileResult, + cookies: pkce?.cookie ? [pkce.cookie] : undefined, + } } catch (error) { logger.error("OAUTH_CALLBACK_ERROR", { error, providerId: provider.id }) throw new OAuthCallbackError(error) diff --git a/src/server/lib/oauth/client-legacy.ts b/src/core/lib/oauth/client-legacy.ts similarity index 100% rename from src/server/lib/oauth/client-legacy.ts rename to src/core/lib/oauth/client-legacy.ts diff --git a/src/server/lib/oauth/client.ts b/src/core/lib/oauth/client.ts similarity index 61% rename from src/server/lib/oauth/client.ts rename to src/core/lib/oauth/client.ts index d343fda2bb..55d5cc6b37 100644 --- a/src/server/lib/oauth/client.ts +++ b/src/core/lib/oauth/client.ts @@ -1,4 +1,4 @@ -import { Issuer } from "openid-client" +import { Issuer, Client } from "openid-client" import { InternalOptions } from "src/lib/types" /** @@ -8,7 +8,9 @@ import { InternalOptions } from "src/lib/types" * * Client supporting OAuth 2.x and OIDC */ -export async function openidClient(options: InternalOptions<"oauth">) { +export async function openidClient( + options: InternalOptions<"oauth"> +): Promise { const provider = options.provider let issuer @@ -17,9 +19,13 @@ export async function openidClient(options: InternalOptions<"oauth">) { } 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, }) } diff --git a/src/server/lib/oauth/pkce-handler.js b/src/core/lib/oauth/pkce-handler.ts similarity index 54% rename from src/server/lib/oauth/pkce-handler.js rename to src/core/lib/oauth/pkce-handler.ts index 66ee5f4ec2..76dddf3c7d 100644 --- a/src/server/lib/oauth/pkce-handler.js +++ b/src/core/lib/oauth/pkce-handler.ts @@ -1,6 +1,7 @@ import * as cookie from "../cookie" import * as jwt from "../../../jwt" import { generators } from "openid-client" +import { InternalOptions } from "src/lib/types" const PKCE_LENGTH = 64 const PKCE_CODE_CHALLENGE_METHOD = "S256" @@ -9,13 +10,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 +31,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 +48,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, } @@ -55,32 +64,43 @@ export async function createPKCE(req, res) { /** * Returns code_verifier if provider uses PKCE, * and clears the cookie afterwards. - * @param {import("src/lib/types").NextAuthRequest} req - * @return {Promise} */ -export async function usePKCECodeVerifier(req, res) { - /** @type {import("src/providers").OAuthConfig} */ - const provider = req.options.provider - const { cookies } = req.options - if ( - !provider?.checks.includes("pkce") || - !(cookies.pkceCodeVerifier.name in req.cookies) - ) { +export async function usePKCECodeVerifier(params: { + options: InternalOptions<"oauth"> + codeVerifier?: string +}): Promise< + | { + codeVerifier?: string + cookie?: cookie.Cookie + } + | undefined +> { + const { options, codeVerifier } = params + const { cookies, provider } = options + + if (!provider?.checks?.includes("pkce") || !codeVerifier) { return } const pkce = await jwt.decode({ - ...req.options.jwt, - token: req.cookies[cookies.pkceCodeVerifier.name], + ...options.jwt, + token: codeVerifier, maxAge: PKCE_MAX_AGE, encryption: true, }) // remove PKCE cookie after it has been used up - cookie.set(res, cookies.pkceCodeVerifier.name, "", { - ...cookies.pkceCodeVerifier.options, - maxAge: 0, - }) + const cookie: cookie.Cookie = { + name: cookies.pkceCodeVerifier.name, + value: "", + options: { + ...cookies.pkceCodeVerifier.options, + maxAge: 0, + }, + } - return pkce?.code_verifier ?? undefined + return { + codeVerifier: (pkce?.code_verifier as any) ?? undefined, + cookie, + } } diff --git a/src/core/lib/oauth/state-handler.ts b/src/core/lib/oauth/state-handler.ts new file mode 100644 index 0000000000..a8271e97cf --- /dev/null +++ b/src/core/lib/oauth/state-handler.ts @@ -0,0 +1,33 @@ +import { createHash } from "crypto" +import { InternalOptions } from "src/lib/types" + +/** Returns state if provider supports it */ +export function createState(options: InternalOptions<"oauth">) { + const { csrfToken, logger, provider } = options + + if (!provider.checks?.includes("state")) { + // Provider does not support state, return nothing + return + } + + if (!csrfToken) { + logger.warn("NO_CSRF_TOKEN") + return + } + + // A hash of the NextAuth.js CSRF token is used as the state + const state = createHash("sha256").update(csrfToken).digest("hex") + + logger.debug("OAUTH_CALLBACK_PROTECTION", { state, csrfToken }) + return state +} + +/** + * Consistently recreate state from the csrfToken + * if `provider.checks` supports `"state"`. + */ +export function getState({ provider, csrfToken }: InternalOptions<"oauth">) { + if (provider?.checks?.includes("state") && csrfToken) { + return createHash("sha256").update(csrfToken).digest("hex") + } +} diff --git a/src/server/lib/providers.ts b/src/core/lib/providers.ts similarity index 84% rename from src/server/lib/providers.ts rename to src/core/lib/providers.ts index e6ae497d1a..35b5240ed1 100644 --- a/src/server/lib/providers.ts +++ b/src/core/lib/providers.ts @@ -1,6 +1,7 @@ -import { InternalProvider } from "src/lib/types" +import { InternalProvider } from "../../lib/types" import { Provider } from "../../providers" import { merge } from "../../lib/merge" +import { InternalUrl } from "../../lib/parse-url" /** * Adds `signinUrl` and `callbackUrl` to each provider @@ -8,13 +9,13 @@ import { merge } from "../../lib/merge" */ export default function parseProviders(params: { providers: Provider[] - base: string + url: InternalUrl providerId?: string }): { providers: InternalProvider[] provider?: InternalProvider } { - const { base, providerId } = params + const { url, providerId } = params const providers = params.providers.map(({ options, ...rest }) => { const defaultOptions = normalizeProvider(rest as Provider) @@ -22,8 +23,8 @@ export default function parseProviders(params: { return merge(defaultOptions, { ...userOptions, - signinUrl: `${base}/signin/${userOptions?.id ?? rest.id}`, - callbackUrl: `${base}/callback/${userOptions?.id ?? rest.id}`, + signinUrl: `${url}/signin/${userOptions?.id ?? rest.id}`, + callbackUrl: `${url}/callback/${userOptions?.id ?? rest.id}`, }) }) diff --git a/src/server/lib/utils.ts b/src/core/lib/utils.ts similarity index 82% rename from src/server/lib/utils.ts rename to src/core/lib/utils.ts index dc132f1922..511d5aa76f 100644 --- a/src/server/lib/utils.ts +++ b/src/core/lib/utils.ts @@ -1,6 +1,7 @@ import { createHash } from "crypto" import { NextAuthOptions } from "../.." import { InternalOptions } from "../../lib/types" +import { InternalUrl } from "../../lib/parse-url" /** * Takes a number in seconds and returns the date in the future. @@ -27,25 +28,16 @@ export function hashToken(token: string, options: InternalOptions<"email">) { * based on options passed here. A options contains unique data, such as * OAuth provider secrets and database credentials it should be sufficent. */ -export default function createSecret({ - userOptions, - basePath, - baseUrl, -}: { +export default function createSecret(params: { userOptions: NextAuthOptions - basePath: string - baseUrl: string + url: InternalUrl }) { + const { userOptions, url } = params + return ( userOptions.secret ?? createHash("sha256") - .update( - JSON.stringify({ - baseUrl, - basePath, - ...userOptions, - }) - ) + .update(JSON.stringify({ ...url, ...userOptions })) .digest("hex") ) } diff --git a/src/server/pages/error.tsx b/src/core/pages/error.tsx similarity index 55% rename from src/server/pages/error.tsx rename to src/core/pages/error.tsx index 0d6c25d34b..a4992862c0 100644 --- a/src/server/pages/error.tsx +++ b/src/core/pages/error.tsx @@ -1,29 +1,31 @@ -/** - * Renders an error page. - * @param {{ - * baseUrl: string - * basePath: string - * error?: string - * res: import("src/lib/types").NextAuthResponse - * }} params - */ -export default function Error({ baseUrl, basePath, error = "default", theme, res }) { - const signinPageUrl = `${baseUrl}${basePath}/signin` +import { Theme } from "../.." +import { InternalUrl } from "../../lib/parse-url" + +export interface ErrorProps { + url: InternalUrl + theme: Theme + error?: string +} + +/** Renders an error page. */ +export default function ErrorPage(props: ErrorProps) { + const { url, error = "default", theme } = props + const signinPageUrl = `${url}/signin` const errors = { default: { - statusCode: 200, + status: 200, heading: "Error", message: (

- - {baseUrl.replace(/^https?:\/\//, "")} + + {url.host}

), }, configuration: { - statusCode: 500, + status: 500, heading: "Server error", message: (
@@ -33,7 +35,7 @@ export default function Error({ baseUrl, basePath, error = "default", theme, res ), }, accessdenied: { - statusCode: 403, + status: 403, heading: "Access Denied", message: (
@@ -47,7 +49,7 @@ export default function Error({ baseUrl, basePath, error = "default", theme, res ), }, verification: { - statusCode: 403, + status: 403, heading: "Unable to sign in", message: (
@@ -65,26 +67,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)}
`, + } + } + + return { + signin(props?: any) { + return send({ + html: SigninPage({ + csrfToken, + providers, + callbackUrl, + theme, + ...query, + ...props, + }), + title: "Sign In", + }) + }, + signout(props?: any) { + return send({ + html: SignoutPage({ csrfToken, url, theme, ...props }), + title: "Sign Out", + }) + }, + verifyRequest(props?: any) { + return send({ + html: VerifyRequestPage({ url, theme, ...props }), + title: "Verify Request", + }) + }, + error(props) { + return send({ + ...ErrorPage({ url, theme, ...props }), + title: "Error", + }) + }, + } +} diff --git a/src/server/pages/signin.tsx b/src/core/pages/signin.tsx similarity index 90% rename from src/server/pages/signin.tsx rename to src/core/pages/signin.tsx index 6a1ef4542f..81d6c26f86 100644 --- a/src/server/pages/signin.tsx +++ b/src/core/pages/signin.tsx @@ -1,11 +1,12 @@ -export default function Signin({ - csrfToken, - providers, - callbackUrl, - theme, - email, - error: errorType, -}) { +export default function SigninPage(props) { + const { + csrfToken, + providers, + callbackUrl, + theme, + email, + error: errorType, + } = props // We only want to render providers const providersToRender = providers.filter((provider) => { if (provider.type === "oauth" || provider.type === "email") { @@ -20,7 +21,10 @@ export default function Signin({ }) if (typeof document !== "undefined") { - document.documentElement.style.setProperty("--brand-color", theme.brandColor) + document.documentElement.style.setProperty( + "--brand-color", + theme.brandColor + ) } const errors = { @@ -42,14 +46,16 @@ export default function Signin({ return (
- ${title}
${renderToString( - html - )}
` - ) - } - - return { - signin(props?: any) { - send({ - html: signin({ - csrfToken, - providers, - callbackUrl, - theme, - ...req.query, - ...props, - }), - title: "Sign In", - }) - }, - signout(props?: any) { - send({ - html: signout({ csrfToken, baseUrl, basePath, theme, ...props }), - title: "Sign Out", - }) - }, - verifyRequest(props?: any) { - send({ - html: verifyRequest({ baseUrl, theme, ...props }), - title: "Verify Request", - }) - }, - error(props) { - send({ - html: error({ basePath, baseUrl, theme, res, ...props }), - title: "Error", - }) - }, - } -} diff --git a/src/server/pages/signout.tsx b/src/server/pages/signout.tsx deleted file mode 100644 index 3d7eaad6bf..0000000000 --- a/src/server/pages/signout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -export default function Signout({ baseUrl, basePath, csrfToken, theme }) { - return ( -
-