diff --git a/src/lib/jwt.js b/src/lib/jwt.js index af414a3f9d..5b6993d351 100644 --- a/src/lib/jwt.js +++ b/src/lib/jwt.js @@ -13,7 +13,7 @@ const DEFAULT_ENCRYPTION_ENABLED = false const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days -const encode = async ({ +async function encode ({ token = {}, maxAge = DEFAULT_MAX_AGE, secret, @@ -28,9 +28,9 @@ const encode = async ({ zip: 'DEF' }, encryption = DEFAULT_ENCRYPTION_ENABLED -} = {}) => { +} = {}) { // Signing Key - const _signingKey = (signingKey) + const _signingKey = signingKey ? jose.JWK.asKey(JSON.parse(signingKey)) : getDerivedSigningKey(secret) @@ -39,18 +39,17 @@ const encode = async ({ if (encryption) { // Encryption Key - const _encryptionKey = (encryptionKey) + const _encryptionKey = encryptionKey ? jose.JWK.asKey(JSON.parse(encryptionKey)) : getDerivedEncryptionKey(secret) // Encrypt token return jose.JWE.encrypt(signedToken, _encryptionKey, encryptionOptions) - } else { - return signedToken } + return signedToken } -const decode = async ({ +async function decode ({ secret, token, maxAge = DEFAULT_MAX_AGE, @@ -66,14 +65,14 @@ const decode = async ({ algorithms: [DEFAULT_ENCRYPTION_ALGORITHM] }, encryption = DEFAULT_ENCRYPTION_ENABLED -} = {}) => { +} = {}) { if (!token) return null let tokenToVerify = token if (encryption) { // Encryption Key - const _encryptionKey = (decryptionKey) + const _encryptionKey = decryptionKey ? jose.JWK.asKey(JSON.parse(decryptionKey)) : getDerivedEncryptionKey(secret) @@ -83,7 +82,7 @@ const decode = async ({ } // Signing Key - const _signingKey = (verificationKey) + const _signingKey = verificationKey ? jose.JWK.asKey(JSON.parse(verificationKey)) : getDerivedSigningKey(secret) @@ -91,7 +90,16 @@ const decode = async ({ return jose.JWT.verify(tokenToVerify, _signingKey, verificationOptions) } -const getToken = async (args) => { +/** + * Server-side method to retrieve the JWT from `req`. + * @param {{ + * req: NextApiRequest + * secureCookie?: boolean + * cookieName?: string + * raw?: boolean + * }} params + */ +async function getToken (params) { const { req, // Use secure prefix for cookie name, unless URL is NEXTAUTH_URL is http:// @@ -99,7 +107,7 @@ const getToken = async (args) => { secureCookie = !(!process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL.startsWith('http://')), cookieName = (secureCookie) ? '__Secure-next-auth.session-token' : 'next-auth.session-token', raw = false - } = args + } = params if (!req) throw new Error('Must pass `req` to JWT getToken()') // Try to get token from cookie @@ -108,7 +116,7 @@ const getToken = async (args) => { // If cookie not found in cookie look for bearer token in authorization header. // This allows clients that pass through tokens in headers rather than as // cookies to use this helper function. - if (!token && req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') { + if (!token && req.headers.authorization?.split(' ')[0] === 'Bearer') { const urlEncodedToken = req.headers.authorization.split(' ')[1] token = decodeURIComponent(urlEncodedToken) } @@ -118,8 +126,8 @@ const getToken = async (args) => { } try { - return await decode({ token, ...args }) - } catch (error) { + return decode({ token, ...params }) + } catch { return null } } @@ -128,7 +136,7 @@ const getToken = async (args) => { let DERIVED_SIGNING_KEY_WARNING = false let DERIVED_ENCRYPTION_KEY_WARNING = false -const getDerivedSigningKey = (secret) => { +function getDerivedSigningKey (secret) { if (!DERIVED_SIGNING_KEY_WARNING) { logger.warn('JWT_AUTO_GENERATED_SIGNING_KEY') DERIVED_SIGNING_KEY_WARNING = true @@ -139,7 +147,7 @@ const getDerivedSigningKey = (secret) => { return key } -const getDerivedEncryptionKey = (secret) => { +function getDerivedEncryptionKey (secret) { if (!DERIVED_ENCRYPTION_KEY_WARNING) { logger.warn('JWT_AUTO_GENERATED_ENCRYPTION_KEY') DERIVED_ENCRYPTION_KEY_WARNING = true diff --git a/src/lib/logger.js b/src/lib/logger.js index 9e1ab8906c..f61d5b88ad 100644 --- a/src/lib/logger.js +++ b/src/lib/logger.js @@ -1,31 +1,24 @@ const logger = { - error: (errorCode, ...text) => { - if (!console) { return } - if (text && text.length <= 1) { text = text[0] || '' } + error (code, ...text) { console.error( - `[next-auth][error][${errorCode.toLowerCase()}]`, - text, - `\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}` + `[next-auth][error][${code.toLowerCase()}]`, + JSON.stringify(text), + `\nhttps://next-auth.js.org/errors#${code.toLowerCase()}` ) }, - warn: (warnCode, ...text) => { - if (!console) { return } - if (text && text.length <= 1) { text = text[0] || '' } + warn (code, ...text) { console.warn( - `[next-auth][warn][${warnCode.toLowerCase()}]`, - text, - `\nhttps://next-auth.js.org/warnings#${warnCode.toLowerCase()}` + `[next-auth][warn][${code.toLowerCase()}]`, + JSON.stringify(text), + `\nhttps://next-auth.js.org/warnings#${code.toLowerCase()}` ) }, - debug: (debugCode, ...text) => { - if (!console) { return } - if (text && text.length <= 1) { text = text[0] || '' } - if (process && process.env && process.env._NEXTAUTH_DEBUG) { - console.log( - `[next-auth][debug][${debugCode.toLowerCase()}]`, - text - ) - } + debug (code, ...text) { + if (!process?.env?._NEXTAUTH_DEBUG) return + console.log( + `[next-auth][debug][${code.toLowerCase()}]`, + JSON.stringify(text) + ) } } diff --git a/src/server/index.js b/src/server/index.js index 0bb43ab397..0d505b2c52 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,20 +1,17 @@ -import { createHash, randomBytes } from 'crypto' +import adapters from '../adapters' import jwt from '../lib/jwt' import parseUrl from '../lib/parse-url' +import logger from '../lib/logger' import * as cookie from './lib/cookie' -import callbackUrlHandler from './lib/callback-url-handler' +import * as defaultEvents from './lib/default-events' +import * as defaultCallbacks from './lib/default-callbacks' import parseProviders from './lib/providers' -import * as events from './lib/events' -import * as defaultCallbacks from './lib/defaultCallbacks' -import providers from './routes/providers' -import signin from './routes/signin' -import signout from './routes/signout' -import callback from './routes/callback' -import session from './routes/session' +import callbackUrlHandler from './lib/callback-url-handler' +import extendRes from './lib/extend-req' +import * as routes from './routes' import renderPage from './pages' -import adapters from '../adapters' -import logger from '../lib/logger' -import redirect from './lib/redirect' +import csrfTokenHandler from './lib/csrf-token-handler' +import createSecret from './lib/create-secret' // To work properly in production with OAuth providers the NEXTAUTH_URL // environment variable must be set. @@ -23,182 +20,63 @@ if (!process.env.NEXTAUTH_URL) { } async function NextAuthHandler (req, res, userOptions) { + // If debug enabled, set ENV VAR so that logger logs debug messages + if (userOptions.debug) { + process.env._NEXTAUTH_DEBUG = true + } + // To the best of my knowledge, we need to return a promise here // to avoid early termination of calls to the serverless function // (and then return that promise when we are done) - eslint // complains but I'm not sure there is another way to do this. return new Promise(async resolve => { // eslint-disable-line no-async-promise-executor - // This is passed to all methods that handle responses, and must be called - // when they are complete so that the serverless function knows when it is - // safe to return and that no more data will be sent. - - const originalResEnd = res.end.bind(res) - res.end = (...args) => { - resolve() - return originalResEnd(...args) - } - res.redirect = redirect(req, res) + extendRes(req, res, resolve) if (!req.query.nextauth) { const error = 'Cannot find [...nextauth].js in pages/api/auth. Make sure the filename is written correctly.' logger.error('MISSING_NEXTAUTH_API_ROUTE_ERROR', error) - res.status(500) - return res.end(`Error: ${error}`) + return res.status(500).end(`Error: ${error}`) } - const { url, query, body } = req const { nextauth, action = nextauth[0], - provider = nextauth[1], + providerId = nextauth[1], error = nextauth[1] - } = query - - const { - csrfToken: csrfTokenFromPost - } = body + } = req.query // @todo refactor all existing references to baseUrl and basePath const { basePath, baseUrl } = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL) - // Parse database / adapter - let adapter - if (userOptions.adapter) { - // If adapter is provided, use it (advanced usage, overrides database) - adapter = userOptions.adapter - } else if (userOptions.database) { - // If database URI or config object is provided, use it (simple usage) - adapter = adapters.Default(userOptions.database) - } - - // Secret used salt cookies and tokens (e.g. for CSRF protection). - // If no secret option is specified then it creates one on the fly - // based on options passed here. A options contains unique data, such as - // OAuth provider secrets and database credentials it should be sufficent. - const secret = userOptions.secret || createHash('sha256').update(JSON.stringify({ - baseUrl, basePath, ...userOptions - })).digest('hex') - - // Use secure cookies if the site uses HTTPS - // This being conditional allows cookies to work non-HTTPS development URLs - // Honour secure cookie option, which sets 'secure' and also adds '__Secure-' - // prefix, but enable them by default if the site URL is HTTPS; but not for - // non-HTTPS URLs like http://localhost which are used in development). - // For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/ - const useSecureCookies = userOptions.useSecureCookies || baseUrl.startsWith('https://') - const cookiePrefix = useSecureCookies ? '__Secure-' : '' - - // @TODO Review cookie settings (names, options) const cookies = { - // default cookie options - sessionToken: { - name: `${cookiePrefix}next-auth.session-token`, - options: { - httpOnly: true, - sameSite: 'lax', - path: '/', - secure: useSecureCookies - } - }, - callbackUrl: { - name: `${cookiePrefix}next-auth.callback-url`, - options: { - sameSite: 'lax', - path: '/', - secure: useSecureCookies - } - }, - csrfToken: { - // Default to __Host- for CSRF token for additional protection if using useSecureCookies - // NB: The `__Host-` prefix is stricter than the `__Secure-` prefix. - name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`, - options: { - httpOnly: true, - sameSite: 'lax', - path: '/', - secure: useSecureCookies - } - }, + ...cookie.defaultCookies(userOptions.useSecureCookies || baseUrl.startsWith('https://')), // Allow user cookie options to override any cookie settings above ...userOptions.cookies } - // Session options - const sessionOptions = { - jwt: false, - maxAge: 30 * 24 * 60 * 60, // Sessions expire after 30 days of being idle - updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours) - ...userOptions.session - } - - // JWT options - const jwtOptions = { - secret, // Use application secret if no keys specified - maxAge: sessionOptions.maxAge, // maxAge is dereived from session maxAge, - encode: jwt.encode, - decode: jwt.decode, - ...userOptions.jwt - } + const secret = createSecret({ userOptions, basePath, baseUrl }) - // If no adapter specified, force use of JSON Web Tokens (stateless) - if (!adapter) { - sessionOptions.jwt = true - } - - // Event messages - const eventsOptions = { - ...events, - ...userOptions.events - } + const { csrfToken, csrfTokenVerified } = csrfTokenHandler(req, res, cookies, secret) - // Callback functions - const callbacksOptions = { - ...defaultCallbacks, - ...userOptions.callbacks - } + const providers = parseProviders({ providers: userOptions.providers, baseUrl, basePath }) + const provider = providers.find(({ id }) => id === providerId) - // Ensure CSRF Token cookie is set for any subsequent requests. - // Used as part of the strateigy for mitigation for CSRF tokens. - // - // Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash', - // where 'token' is the CSRF token and 'hash' is a hash made of the token and - // the secret, and the two values are joined by a pipe '|'. By storing the - // value and the hash of the value (with the secret used as a salt) we can - // verify the cookie was set by the server and not by a malicous attacker. - // - // For more details, see the following OWASP links: - // 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 - let csrfToken - let csrfTokenVerified = false - if (req.cookies[cookies.csrfToken.name]) { - const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|') - if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) { - // If hash matches then we trust the CSRF token value - csrfToken = csrfTokenValue + const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle - // If this is a POST request and the CSRF Token in the Post request matches - // the cookie we have already verified is one we have set, then token is verified! - if (req.method === 'POST' && csrfToken === csrfTokenFromPost) { csrfTokenVerified = true } - } - } - if (!csrfToken) { - // If no csrfToken - 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. - csrfToken = randomBytes(32).toString('hex') - const newCsrfTokenCookie = `${csrfToken}|${createHash('sha256').update(`${csrfToken}${secret}`).digest('hex')}` - cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options) - } + // Parse database / adapter + // If adapter is provided, use it (advanced usage, overrides database) + // If database URI or config object is provided, use it (simple usage) + const adapter = userOptions.adapter ?? (userOptions.database && adapters.Default(userOptions.database)) // User provided options are overriden by other options, // except for the options with special handling above - const options = { + req.options = { debug: false, pages: {}, // Custom options override defaults ...userOptions, - // These computed settings can values in userSuppliedOptions but override them + // These computed settings can have values in userOptions but we override them // and are request-specific. adapter, baseUrl, @@ -208,112 +86,122 @@ async function NextAuthHandler (req, res, userOptions) { cookies, secret, csrfToken, - providers: parseProviders({ providers: userOptions.providers, baseUrl, basePath }), - session: sessionOptions, - jwt: jwtOptions, - events: eventsOptions, - callbacks: callbacksOptions + providers, + // Session options + session: { + jwt: !adapter, // If no adapter specified, force use of JSON Web Tokens (stateless) + maxAge, + updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours) + ...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: { + ...defaultEvents, + ...userOptions.events + }, + // Callback functions + callbacks: { + ...defaultCallbacks, + ...userOptions.callbacks + } } - req.options = options - // If debug enabled, set ENV VAR so that logger logs debug messages - if (options.debug) { - process.env._NEXTAUTH_DEBUG = true - } + await callbackUrlHandler(req, res) - // Get / Set callback URL based on query param / cookie + validation - const callbackUrl = await callbackUrlHandler(req, res) + const render = renderPage(req, res) + const { pages } = req.options if (req.method === 'GET') { switch (action) { case 'providers': - providers(req, res) - break + return routes.providers(req, res) case 'session': - session(req, res) - break + return routes.session(req, res) case 'csrf': - res.json({ csrfToken }) - return res.end() + return res.json({ csrfToken }) case 'signin': - if (options.pages.signIn) { - let redirectUrl = `${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${callbackUrl}` - if (req.query.error) { redirectUrl = `${redirectUrl}&error=${req.query.error}` } - return res.redirect(redirectUrl) + if (pages.signIn) { + let signinUrl = `${pages.signIn}${pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${req.options.callbackUrl}` + if (error) { signinUrl = `${signinUrl}&error=${error}` } + return res.redirect(signinUrl) } - renderPage(req, res, 'signin', { providers: Object.values(options.providers), callbackUrl, csrfToken }) - break + return render.signin() case 'signout': - if (options.pages.signOut) { - return res.redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}error=${error}`) + if (pages.signOut) { + return res.redirect(`${pages.signOut}${pages.signOut.includes('?') ? '&' : '?'}error=${error}`) } - - renderPage(req, res, 'signout', { csrfToken, callbackUrl }) - break + return render.signout() case 'callback': - if (provider && options.providers[provider]) { - callback(req, res) - } else { - res.status(400) - return res.end(`Error: HTTP GET is not supported for ${url}`) + if (provider) { + return routes.callback(req, res) } break case 'verify-request': - if (options.pages.verifyRequest) { return res.redirect(options.pages.verifyRequest) } - - renderPage(req, res, 'verify-request') - break + if (pages.verifyRequest) { + return res.redirect(pages.verifyRequest) + } + return render.verifyRequest() case 'error': - if (options.pages.error) { return res.redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) } + if (pages.error) { + return res.redirect(`${pages.error}${pages.error.includes('?') ? '&' : '?'}error=${error}`) + } - renderPage(req, res, 'error', { error }) - break + // These error messages are displayed in line on the sign in page + if ([ + 'Signin', + 'OAuthSignin', + 'OAuthCallback', + 'OAuthCreateAccount', + 'EmailCreateAccount', + 'Callback', + 'OAuthAccountNotLinked', + 'EmailSignin', + 'CredentialsSignin' + ].includes(error)) { + return res.redirect(`${baseUrl}${basePath}/signin?error=${error}`) + } + + return render.error({ error }) default: - res.status(404) - return res.end() } } else if (req.method === 'POST') { switch (action) { case 'signin': // Verified CSRF Token required for all sign in routes - if (!csrfTokenVerified) { - return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`) + if (csrfTokenVerified && provider) { + return routes.signin(req, res) } - if (provider && options.providers[provider]) { - signin(req, res) - } - break + return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`) case 'signout': // Verified CSRF Token required for signout - if (!csrfTokenVerified) { - return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`) + if (csrfTokenVerified) { + return routes.signout(req, res) } - - signout(req, res) - break + return res.redirect(`${baseUrl}${basePath}/signout?csrf=true`) case 'callback': - if (provider && options.providers[provider]) { + if (provider) { // Verified CSRF Token required for credentials providers only - if (options.providers[provider].type === 'credentials' && !csrfTokenVerified) { + if (provider.type === 'credentials' && !csrfTokenVerified) { return res.redirect(`${baseUrl}${basePath}/signin?csrf=true`) } - callback(req, res) - } else { - res.status(400) - return res.end(`Error: HTTP POST is not supported for ${url}`) + return routes.callback(req, res) } break default: - res.status(400) - return res.end(`Error: HTTP POST is not supported for ${url}`) } - } else { - res.status(400) - return res.end(`Error: HTTP ${req.method} is not supported for ${url}`) } + return res.status(400).end(`Error: HTTP ${req.method} is not supported for ${req.url}`) }) } diff --git a/src/server/lib/callback-handler.js b/src/server/lib/callback-handler.js index 69292f19af..ce51fe514e 100644 --- a/src/server/lib/callback-handler.js +++ b/src/server/lib/callback-handler.js @@ -14,212 +14,208 @@ import dispatchEvent from '../lib/dispatch-event' * handler. */ export default async function callbackHandler (sessionToken, profile, providerAccount, options) { - try { - // Input validation - if (!profile) { throw new Error('Missing profile') } - if (!providerAccount || !providerAccount.id || !providerAccount.type) { throw new Error('Missing or invalid provider account') } + // Input validation + if (!profile) throw new Error('Missing profile') + if (!providerAccount?.id || !providerAccount.type) throw new Error('Missing or invalid provider account') + if (!['email', 'oauth'].includes(providerAccount.type)) throw new Error('Provider not supported') - const { adapter, jwt, events } = options - - const useJwtSession = options.session.jwt + const { + adapter, + jwt, + events, + session: { + jwt: useJwtSession + } + } = options - // If no adapter is configured then we don't have a database and cannot - // persist data; in this mode we just return a dummy session object. - if (!adapter) { - return { - user: profile, - account: providerAccount, - session: {} - } + // If no adapter is configured then we don't have a database and cannot + // persist data; in this mode we just return a dummy session object. + if (!adapter) { + return { + user: profile, + account: providerAccount, + session: {} } + } - const { - createUser, - updateUser, - getUser, - getUserByProviderAccountId, - getUserByEmail, - linkAccount, - createSession, - getSession, - deleteSession - } = await adapter.getAdapter(options) + const { + createUser, + updateUser, + getUser, + getUserByProviderAccountId, + getUserByEmail, + linkAccount, + createSession, + getSession, + deleteSession + } = await adapter.getAdapter(options) - let session = null - let user = null - let isSignedIn = null - let isNewUser = false + let session = null + let user = null + let isSignedIn = null + let isNewUser = false - if (sessionToken) { - if (useJwtSession) { - try { - session = await jwt.decode({ ...jwt, token: sessionToken }) - if (session && session.sub) { - user = await getUser(session.sub) - isSignedIn = !!user - } - } catch (e) { - // If session can't be verified, treat as no session - } - } else { - session = await getSession(sessionToken) - if (session && session.userId) { - user = await getUser(session.userId) + if (sessionToken) { + if (useJwtSession) { + try { + session = await jwt.decode({ ...jwt, token: sessionToken }) + if (session?.sub) { + user = await getUser(session.sub) isSignedIn = !!user } + } catch { + // If session can't be verified, treat as no session } } + session = await getSession(sessionToken) + if (session?.userId) { + user = await getUser(session.userId) + isSignedIn = !!user + } + } - if (providerAccount.type === 'email') { - // If signing in with an email, check if an account with the same email address exists already - const userByEmail = profile.email ? await getUserByEmail(profile.email) : null - if (userByEmail) { - // If they are not already signed in as the same user, this flow will - // sign them out of the current session and sign them in as the new user - if (isSignedIn) { - if (user.id !== userByEmail.id && !useJwtSession) { - // Delete existing session if they are currently signed in as another user. - // This will switch user accounts for the session in cases where the user was - // already logged in with a different account. - await deleteSession(sessionToken) - } + if (providerAccount.type === 'email') { + // If signing in with an email, check if an account with the same email address exists already + const userByEmail = profile.email ? await getUserByEmail(profile.email) : null + if (userByEmail) { + // If they are not already signed in as the same user, this flow will + // sign them out of the current session and sign them in as the new user + if (isSignedIn) { + if (user.id !== userByEmail.id && !useJwtSession) { + // Delete existing session if they are currently signed in as another user. + // This will switch user accounts for the session in cases where the user was + // already logged in with a different account. + await deleteSession(sessionToken) } - - // Update emailVerified property on the user object - const currentDate = new Date() - user = await updateUser({ ...userByEmail, emailVerified: currentDate }) - await dispatchEvent(events.updateUser, user) - } else { - // Create user account if there isn't one for the email address already - const currentDate = new Date() - user = await createUser({ ...profile, emailVerified: currentDate }) - await dispatchEvent(events.createUser, user) - isNewUser = true } - // Create new session - session = useJwtSession ? {} : await createSession(user) + // Update emailVerified property on the user object + const currentDate = new Date() + user = await updateUser({ ...userByEmail, emailVerified: currentDate }) + await dispatchEvent(events.updateUser, user) + } else { + // Create user account if there isn't one for the email address already + const currentDate = new Date() + user = await createUser({ ...profile, emailVerified: currentDate }) + await dispatchEvent(events.createUser, user) + isNewUser = true + } - return { - session, - user, - isNewUser - } - } else if (providerAccount.type === 'oauth') { - // If signing in with oauth account, check to see if the account exists already - const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id) - if (userByProviderAccountId) { - if (isSignedIn) { - // If the user is already signed in with this account, we don't need to do anything - // Note: These are cast as strings here to ensure they match as in - // some flows (e.g. JWT with a database) one of the values might be a - // string and the other might be an ObjectID and would otherwise fail. - if (`${userByProviderAccountId.id}` === `${user.id}`) { - return { - session, - user, - isNewUser - } - } else { - // If the user is currently signed in, but the new account they are signing in - // with is already associated with another account, then we cannot link them - // and need to return an error. - throw new AccountNotLinkedError() - } - } else { - // If there is no active session, but the account being signed in with is already - // associated with a valid user then create session to sign the user in. - session = useJwtSession ? {} : await createSession(userByProviderAccountId) - return { - session, - user: userByProviderAccountId, - isNewUser - } - } - } else { - if (isSignedIn) { - // If the user is already signed in and the OAuth account isn't already associated - // with another user account then we can go ahead and link the accounts safely. - await linkAccount( - user.id, - providerAccount.provider, - providerAccount.type, - providerAccount.id, - providerAccount.refreshToken, - providerAccount.accessToken, - providerAccount.accessTokenExpires - ) - await dispatchEvent(events.linkAccount, { user, providerAccount }) + // Create new session + session = useJwtSession ? {} : await createSession(user) - // As they are already signed in, we don't need to do anything after linking them + return { + session, + user, + isNewUser + } + } else if (providerAccount.type === 'oauth') { + // If signing in with oauth account, check to see if the account exists already + const userByProviderAccountId = await getUserByProviderAccountId(providerAccount.provider, providerAccount.id) + if (userByProviderAccountId) { + if (isSignedIn) { + // If the user is already signed in with this account, we don't need to do anything + // Note: These are cast as strings here to ensure they match as in + // some flows (e.g. JWT with a database) one of the values might be a + // string and the other might be an ObjectID and would otherwise fail. + if (`${userByProviderAccountId.id}` === `${user.id}`) { return { session, user, isNewUser } } + // If the user is currently signed in, but the new account they are signing in + // with is already associated with another account, then we cannot link them + // and need to return an error. + throw new AccountNotLinkedError() + } + // If there is no active session, but the account being signed in with is already + // associated with a valid user then create session to sign the user in. + session = useJwtSession ? {} : await createSession(userByProviderAccountId) + return { + session, + user: userByProviderAccountId, + isNewUser + } + } else { + if (isSignedIn) { + // If the user is already signed in and the OAuth account isn't already associated + // with another user account then we can go ahead and link the accounts safely. + await linkAccount( + user.id, + providerAccount.provider, + providerAccount.type, + providerAccount.id, + providerAccount.refreshToken, + providerAccount.accessToken, + providerAccount.accessTokenExpires + ) + await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount }) - // If the user is not signed in and it looks like a new OAuth account then we - // check there also isn't an user account already associated with the same - // email address as the one in the OAuth profile. - // - // This step is often overlooked in OAuth implementations, but covers the following cases: - // - // 1. It makes it harder for someone to accidentally create two accounts. - // e.g. by signin in with email, then again with an oauth account connected to the same email. - // 2. It makes it harder to hijack a user account using a 3rd party OAuth account. - // e.g. by creating an oauth account then changing the email address associated with it. - // - // It's quite common for services to automatically link accounts in this case, but it's - // better practice to require the user to sign in *then* link accounts to be sure - // someone is not exploiting a problem with a third party OAuth service. + // As they are already signed in, we don't need to do anything after linking them + return { + session, + user, + isNewUser + } + } + + // If the user is not signed in and it looks like a new OAuth account then we + // check there also isn't an user account already associated with the same + // email address as the one in the OAuth profile. + // + // This step is often overlooked in OAuth implementations, but covers the following cases: + // + // 1. It makes it harder for someone to accidentally create two accounts. + // e.g. by signin in with email, then again with an oauth account connected to the same email. + // 2. It makes it harder to hijack a user account using a 3rd party OAuth account. + // e.g. by creating an oauth account then changing the email address associated with it. + // + // It's quite common for services to automatically link accounts in this case, but it's + // better practice to require the user to sign in *then* link accounts to be sure + // someone is not exploiting a problem with a third party OAuth service. + // + // OAuth providers should require email address verification to prevent this, but in + // practice that is not always the case; this helps protect against that. + const userByEmail = profile.email ? await getUserByEmail(profile.email) : null + if (userByEmail) { + // We end up here when we don't have an account with the same [provider].id *BUT* + // we do already have an account with the same email address as the one in the + // OAuth profile the user has just tried to sign in with. // - // OAuth providers should require email address verification to prevent this, but in - // practice that is not always the case; this helps protect against that. - const userByEmail = profile.email ? await getUserByEmail(profile.email) : null - if (userByEmail) { - // We end up here when we don't have an account with the same [provider].id *BUT* - // we do already have an account with the same email address as the one in the - // OAuth profile the user has just tried to sign in with. - // - // We don't want to have two accounts with the same email address, and we don't - // want to link them in case it's not safe to do so, so instead we prompt the user - // to sign in via email to verify their identity and then link the accounts. - throw new AccountNotLinkedError() - } else { - // If the current user is not logged in and the profile isn't linked to any user - // accounts (by email or provider account id)... - // - // If no account matching the same [provider].id or .email exists, we can - // create a new account for the user, link it to the OAuth acccount and - // create a new session for them so they are signed in with it. - user = await createUser(profile) - await dispatchEvent(events.createUser, user) + // We don't want to have two accounts with the same email address, and we don't + // want to link them in case it's not safe to do so, so instead we prompt the user + // to sign in via email to verify their identity and then link the accounts. + throw new AccountNotLinkedError() + } + // If the current user is not logged in and the profile isn't linked to any user + // accounts (by email or provider account id)... + // + // If no account matching the same [provider].id or .email exists, we can + // create a new account for the user, link it to the OAuth acccount and + // create a new session for them so they are signed in with it. + user = await createUser(profile) + await dispatchEvent(events.createUser, user) - await linkAccount( - user.id, - providerAccount.provider, - providerAccount.type, - providerAccount.id, - providerAccount.refreshToken, - providerAccount.accessToken, - providerAccount.accessTokenExpires - ) - await dispatchEvent(events.linkAccount, { user, providerAccount }) + await linkAccount( + user.id, + providerAccount.provider, + providerAccount.type, + providerAccount.id, + providerAccount.refreshToken, + providerAccount.accessToken, + providerAccount.accessTokenExpires + ) + await dispatchEvent(events.linkAccount, { user, providerAccount: providerAccount }) - session = useJwtSession ? {} : await createSession(user) - isNewUser = true - return { - session, - user, - isNewUser - } - } + session = useJwtSession ? {} : await createSession(user) + isNewUser = true + return { + session, + user, + isNewUser } - } else { - return Promise.reject(new Error('Provider not supported')) } - } catch (error) { - return Promise.reject(error) } } diff --git a/src/server/lib/callback-url-handler.js b/src/server/lib/callback-url-handler.js index 73fee30677..87c43e7b7a 100644 --- a/src/server/lib/callback-url-handler.js +++ b/src/server/lib/callback-url-handler.js @@ -1,5 +1,10 @@ import * as cookie from '../lib/cookie' +/** + * Get callback URL based on query param / cookie + validation, + * and add it to `req.options.callbackUrl`. + * @note: `req.options` must already be defined when called. + */ export default async function callbackUrlHandler (req, res) { const { query } = req const { body } = req @@ -24,5 +29,5 @@ export default async function callbackUrlHandler (req, res) { cookie.set(res, cookies.callbackUrl.name, callbackUrl, cookies.callbackUrl.options) } - return callbackUrl + req.options.callbackUrl = callbackUrl } diff --git a/src/server/lib/cookie.js b/src/server/lib/cookie.js index a2f5ba0e6c..87e59d23dd 100644 --- a/src/server/lib/cookie.js +++ b/src/server/lib/cookie.js @@ -100,3 +100,48 @@ function _serialize (name, val, options) { return str } + +/** + * Use secure cookies if the site uses HTTPS + * This being conditional allows cookies to work non-HTTPS development URLs + * Honour secure cookie option, which sets 'secure' and also adds '__Secure-' + * prefix, but enable them by default if the site URL is HTTPS; but not for + * non-HTTPS URLs like http://localhost which are used in development). + * For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/ + * + * @TODO Review cookie settings (names, options) + */ +export function defaultCookies (useSecureCookies) { + const cookiePrefix = useSecureCookies ? '__Secure-' : '' + return { + // default cookie options + sessionToken: { + name: `${cookiePrefix}next-auth.session-token`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies + } + }, + callbackUrl: { + name: `${cookiePrefix}next-auth.callback-url`, + options: { + sameSite: 'lax', + path: '/', + secure: useSecureCookies + } + }, + csrfToken: { + // Default to __Host- for CSRF token for additional protection if using useSecureCookies + // NB: The `__Host-` prefix is stricter than the `__Secure-` prefix. + name: `${useSecureCookies ? '__Host-' : ''}next-auth.csrf-token`, + options: { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: useSecureCookies + } + } + } +} diff --git a/src/server/lib/create-secret.js b/src/server/lib/create-secret.js new file mode 100644 index 0000000000..0364156a2b --- /dev/null +++ b/src/server/lib/create-secret.js @@ -0,0 +1,13 @@ +import { createHash } from 'crypto' + +/** + * Secret used salt cookies and tokens (e.g. for CSRF protection). + * If no secret option is specified then it creates one on the fly + * based on options passed here. A options contains unique data, such as + * OAuth provider secrets and database credentials it should be sufficent. + */ +export default function createSecret ({ userOptions, basePath, baseUrl }) { + return userOptions.secret || createHash('sha256').update(JSON.stringify({ + baseUrl, basePath, ...userOptions + })).digest('hex') +} diff --git a/src/server/lib/csrf-token-handler.js b/src/server/lib/csrf-token-handler.js new file mode 100644 index 0000000000..df3dcef6b1 --- /dev/null +++ b/src/server/lib/csrf-token-handler.js @@ -0,0 +1,42 @@ +import { createHash, randomBytes } from 'crypto' +import * as cookie from './cookie' + +/** + * Ensure CSRF Token cookie is set for any subsequent requests. + * Used as part of the strateigy for mitigation for CSRF tokens. + * + * Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash', + * where 'token' is the CSRF token and 'hash' is a hash made of the token and + * the secret, and the two values are joined by a pipe '|'. By storing the + * value and the hash of the value (with the secret used as a salt) we can + * verify the cookie was set by the server and not by a malicous attacker. + * + * For more details, see the following OWASP links: + * 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, res, cookies, secret) { + const { csrfToken: csrfTokenFromRequest } = req.body + + let csrfTokenFromCookie + let csrfTokenVerified = false + if (req.cookies[cookies.csrfToken.name]) { + const [csrfTokenValue, csrfTokenHash] = req.cookies[cookies.csrfToken.name].split('|') + if (csrfTokenHash === createHash('sha256').update(`${csrfTokenValue}${secret}`).digest('hex')) { + // If hash matches then we trust the CSRF token value + csrfTokenFromCookie = csrfTokenValue + + // If this is a POST request and the CSRF Token in the Post request matches + // the cookie we have already verified is one we have set, then token is verified! + if (req.method === 'POST' && csrfTokenFromCookie === csrfTokenFromRequest) { csrfTokenVerified = true } + } + } + if (!csrfTokenFromCookie) { + // If no csrfToken - 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. + csrfTokenFromCookie = randomBytes(32).toString('hex') + const newCsrfTokenCookie = `${csrfTokenFromCookie}|${createHash('sha256').update(`${csrfTokenFromCookie}${secret}`).digest('hex')}` + cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options) + } + return { csrfToken: csrfTokenFromCookie, csrfTokenVerified } +} diff --git a/src/server/lib/defaultCallbacks.js b/src/server/lib/default-callbacks.js similarity index 100% rename from src/server/lib/defaultCallbacks.js rename to src/server/lib/default-callbacks.js diff --git a/src/server/lib/events.js b/src/server/lib/default-events.js similarity index 100% rename from src/server/lib/events.js rename to src/server/lib/default-events.js diff --git a/src/server/lib/extend-req.js b/src/server/lib/extend-req.js new file mode 100644 index 0000000000..27a88338c5 --- /dev/null +++ b/src/server/lib/extend-req.js @@ -0,0 +1,35 @@ +/** + * Extends res.{end,json,send} with `done()`, + * and redirect to support sending url as json. + * + * When a response is complete, it will call the `done` method, + * so that the serverless function knows when it is + * safe to return and that no more data will be sent. + */ +export default function extendRes (req, res, done) { + const originalResEnd = res.end.bind(res) + res.end = (...args) => { + done() + return originalResEnd(...args) + } + + const originalResJson = res.json.bind(res) + res.json = (...args) => { + done() + return originalResJson(...args) + } + + const originalResSend = res.send.bind(res) + res.send = (...args) => { + done() + return originalResSend(...args) + } + + res.redirect = (url) => { + if (req.body?.json === 'true') { + return res.json({ url }) + } + res.status(302).setHeader('Location', url) + return res.end() + } +} diff --git a/src/server/lib/oauth/callback.js b/src/server/lib/oauth/callback.js index a827cbf716..5fa25dd731 100644 --- a/src/server/lib/oauth/callback.js +++ b/src/server/lib/oauth/callback.js @@ -10,13 +10,13 @@ class OAuthCallbackError extends Error { } } -export default async function oAuthCallback (req, csrfToken) { - // The "user" object is specific to the Apple provider and is provided on first sign in - // e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"} - const provider = req.options.providers[req.options.provider] +export default async function oAuthCallback (req) { + const { provider, csrfToken } = req.options const client = oAuthClient(provider) if (provider.version?.startsWith('2.')) { + // The "user" object is specific to the Apple provider and is provided on first sign in + // e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"} let { code, user, state } = req.query // eslint-disable-line camelcase // For OAuth 2.0 flows, check state returned and matches expected value // (a hash of the NextAuth.js CSRF token). diff --git a/src/server/lib/oauth/client.js b/src/server/lib/oauth/client.js index 5ca66a95e8..a8543b84c7 100644 --- a/src/server/lib/oauth/client.js +++ b/src/server/lib/oauth/client.js @@ -158,11 +158,9 @@ async function getOAuth2AccessToken (code, provider) { // Clients of these services suffer a minor performance cost. results = querystring.parse(data) } - let accessToken + let accessToken = results.access_token if (provider.id === 'spotify') { accessToken = results.authed_user.access_token - } else { - accessToken = results.access_token } const refreshToken = results.refresh_token resolve({ accessToken, refreshToken, results }) diff --git a/src/server/lib/providers.js b/src/server/lib/providers.js index 99a4bc3b09..0a6b489199 100644 --- a/src/server/lib/providers.js +++ b/src/server/lib/providers.js @@ -1,11 +1,8 @@ -export default function parseProviders ({ providers, baseUrl, basePath }) { - return providers.reduce((acc, provider) => { - const providerId = provider.id - acc[providerId] = { - ...provider, - signinUrl: `${baseUrl}${basePath}/signin/${providerId}`, - callbackUrl: `${baseUrl}${basePath}/callback/${providerId}` - } - return acc - }, {}) +/** Adds `signinUrl` and `callbackUrl` to each provider. */ +export default function parseProviders ({ providers = [], baseUrl, basePath }) { + return providers.map((provider) => ({ + ...provider, + signinUrl: `${baseUrl}${basePath}/signin/${provider.id}`, + callbackUrl: `${baseUrl}${basePath}/callback/${provider.id}` + })) } diff --git a/src/server/lib/redirect.js b/src/server/lib/redirect.js deleted file mode 100644 index 38886185ea..0000000000 --- a/src/server/lib/redirect.js +++ /dev/null @@ -1,12 +0,0 @@ -export default function redirect (req, res) { - // This is the one you will use. The wrapper is just to set it up in src/server/index. - return function redirect (url) { - const reponseAsJson = req.body?.json === 'true' - if (reponseAsJson) { - res.json({ url }) - } else { - res.status(302).setHeader('Location', url) - } - return res.end() - } -} diff --git a/src/server/pages/error.js b/src/server/pages/error.js index 12e147b480..8452f3d962 100644 --- a/src/server/pages/error.js +++ b/src/server/pages/error.js @@ -1,74 +1,58 @@ import { h } from 'preact' // eslint-disable-line no-unused-vars import render from 'preact-render-to-string' +/** Renders an error page. */ export default function error ({ baseUrl, basePath, error, res }) { const signinPageUrl = `${baseUrl}${basePath}/signin` - let statusCode = 200 - let heading =
{baseUrl.replace(/^https?:\/\//, '')}
- - switch (error) { - case 'Signin': - case 'OAuthSignin': - case 'OAuthCallback': - case 'OAuthCreateAccount': - case 'EmailCreateAccount': - case 'Callback': - case 'OAuthAccountNotLinked': - case 'EmailSignin': - case 'CredentialsSignin': - // These messages are displayed in line on the sign in page - res.redirect(`${signinPageUrl}?error=${error}`) - return false - case 'Configuration': - statusCode = 500 - heading ={baseUrl.replace(/^https?:\/\//, '')}
+ }, + configuration: { + statusCode: 500, + heading: 'Server error', + message: (There is a problem with the server configuration.
-Check the server logs for more information.
-There is a problem with the server configuration.
+Check the server logs for more information.
You do not have permission to sign in.
- -You do not have permission to sign in.
+The sign in link is no longer valid.
-It may have be used already or it may have expired.
-The sign in link is no longer valid.
+It may have be used already or it may have expired.