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 =

Error

- let message =

{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 =

Server error

- message = ( + const errors = { + default: { + statusCode: 200, + heading: 'Error', + message:

{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.

) - break - case 'AccessDenied': - statusCode = 403 - heading =

Access Denied

- message = ( + }, + accessdenied: { + statusCode: 403, + heading: 'Access Denied', + message: (
-
-

You do not have permission to sign in.

-

Sign in

-
+

You do not have permission to sign in.

+

Sign in

) - break - case 'Verification': - // @TODO Check if user is signed in already with the same email address. - // If they are, no need to display this message, can just direct to callbackUrl - statusCode = 403 - heading =

Unable to sign in

- message = ( + }, + verification: { + statusCode: 403, + heading: 'Unable to sign in', + message: (
-
-

The sign in link is no longer valid.

-

It may have be used already or it may have expired.

-
-

Sign in

+

The sign in link is no longer valid.

+

It may have be used already or it may have expired.

- ) - break - default: + ), + signin:

Sign in

+ } } + const { statusCode, heading, message, signin } = errors[error.toLowerCase()] || errors.default + res.status(statusCode) return render(
- {heading} - {message} +

{heading}

+
{message}
+ {signin}
) } diff --git a/src/server/pages/index.js b/src/server/pages/index.js index 67940a822f..4b86fe2f77 100644 --- a/src/server/pages/index.js +++ b/src/server/pages/index.js @@ -4,30 +4,19 @@ import verifyRequest from './verify-request' import error from './error' import css from '../../css' -export default function renderPage (req, res, page, props = {}) { - props.baseUrl = req.options.baseUrl - props.basePath = req.options.basePath - let html = '' - switch (page) { - case 'signin': - html = signin({ ...props, req }) - break - case 'signout': - html = signout(props) - break - case 'verify-request': - html = verifyRequest(props) - break - case 'error': - html = error({ ...props, res }) - if (html === false) return res.end() - break - default: - html = error(props) - return - } +/** Takes a request and response, and gives renderable pages */ +export default function renderPage (req, res) { + const { baseUrl, basePath, callbackUrl, csrfToken, providers } = req.options res.setHeader('Content-Type', 'text/html') - res.send(`
${html}
`) - res.end() + function send (html) { + res.send(`
${html}
`) + } + + return { + signin (props) { send(signin({ providers, callbackUrl, ...req.query, ...props })) }, + signout (props) { send(signout({ csrfToken, baseUrl, basePath, ...props })) }, + verifyRequest (props) { send(verifyRequest({ baseUrl, ...props })) }, + error (props) { send(error({ basePath, baseUrl, res, ...props })) } + } } diff --git a/src/server/pages/signin.js b/src/server/pages/signin.js index 409de87c6b..03a4f2caab 100644 --- a/src/server/pages/signin.js +++ b/src/server/pages/signin.js @@ -1,9 +1,7 @@ import { h } from 'preact' // eslint-disable-line no-unused-vars import render from 'preact-render-to-string' -export default function signin ({ req, csrfToken, providers, callbackUrl }) { - const { email, error } = req.query - +export default function signin ({ csrfToken, providers, callbackUrl, email, error: errorType }) { // We only want to render providers const providersToRender = providers.filter(provider => { if (provider.type === 'oauth' || provider.type === 'email') { @@ -12,43 +10,31 @@ export default function signin ({ req, csrfToken, providers, callbackUrl }) { } else if (provider.type === 'credentials' && provider.credentials) { // Only render credentials type provider if credentials are defined return true - } else { - // Don't render other provider types - return false } + // Don't render other provider types + return false }) - let errorMessage - if (error) { - switch (error) { - case 'Signin': - case 'OAuthSignin': - case 'OAuthCallback': - case 'OAuthCreateAccount': - case 'EmailCreateAccount': - case 'Callback': - errorMessage =

Try signing with a different account.

- break - case 'OAuthAccountNotLinked': - errorMessage =

To confirm your identity, sign in with the same account you used originally.

- break - case 'EmailSignin': - errorMessage =

Check your email address.

- break - case 'CredentialsSignin': - errorMessage =

Sign in failed. Check the details you provided are correct.

- break - default: - errorMessage =

Unable to sign in.

- break - } + const errors = { + Signin: 'Try signing with a different account.', + OAuthSignin: 'Try signing with a different account.', + OAuthCallback: 'Try signing with a different account.', + OAuthCreateAccount: 'Try signing with a different account.', + EmailCreateAccount: 'Try signing with a different account.', + Callback: 'Try signing with a different account.', + OAuthAccountNotLinked: 'To confirm your identity, sign in with the same account you used originally.', + EmailSignin: 'Check your email address.', + CredentialsSignin: 'Sign in failed. Check the details you provided are correct.', + default: 'Unable to sign in.' } + const error = errorType && (errors[errorType] ?? errors.default) + return render(
- {errorMessage && + {error &&
- {errorMessage} +

{error}

} {providersToRender.map((provider, i) =>
diff --git a/src/server/routes/callback.js b/src/server/routes/callback.js index 44b6bba27d..1def4c52a4 100644 --- a/src/server/routes/callback.js +++ b/src/server/routes/callback.js @@ -7,8 +7,7 @@ import dispatchEvent from '../lib/dispatch-event' /** Handle callbacks from login services */ export default async function callback (req, res) { const { - provider: providerName, - providers, + provider, adapter, baseUrl, basePath, @@ -19,21 +18,18 @@ export default async function callback (req, res) { jwt, events, callbacks, - csrfToken, session: { jwt: useJwtSession, maxAge: sessionMaxAge } } = req.options - const provider = providers[providerName] - const { type } = provider // Get session ID (if set) const sessionToken = req.cookies?.[cookies.sessionToken.name] ?? null - if (type === 'oauth') { + if (provider.type === 'oauth') { try { - const { profile, account, OAuthProfile } = await oAuthCallback(req, csrfToken) + const { profile, account, OAuthProfile } = await oAuthCallback(req) try { // Make it easier to debug when adding a new provider logger.debug('OAUTH_CALLBACK_RESPONSE', { profile, account, OAuthProfile }) @@ -121,10 +117,9 @@ export default async function callback (req, res) { return res.redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`) } else if (error.name === 'CreateUserError') { return res.redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`) - } else { - logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error) - return res.redirect(`${baseUrl}${basePath}/error?error=Callback`) } + logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error) + return res.redirect(`${baseUrl}${basePath}/error?error=Callback`) } } catch (error) { if (error.name === 'OAuthCallbackError') { @@ -134,7 +129,7 @@ export default async function callback (req, res) { logger.error('OAUTH_CALLBACK_ERROR', error) return res.redirect(`${baseUrl}${basePath}/error?error=Callback`) } - } else if (type === 'email') { + } else if (provider.type === 'email') { try { if (!adapter) { logger.error('EMAIL_REQUIRES_ADAPTER_ERROR') @@ -215,12 +210,11 @@ export default async function callback (req, res) { } catch (error) { if (error.name === 'CreateUserError') { return res.redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`) - } else { - logger.error('CALLBACK_EMAIL_ERROR', error) - return res.redirect(`${baseUrl}${basePath}/error?error=Callback`) } + logger.error('CALLBACK_EMAIL_ERROR', error) + return res.redirect(`${baseUrl}${basePath}/error?error=Callback`) } - } else if (type === 'credentials' && req.method === 'POST') { + } else if (provider.type === 'credentials' && req.method === 'POST') { if (!useJwtSession) { logger.error('CALLBACK_CREDENTIALS_JWT_ERROR', 'Signin in with credentials is only supported if JSON Web Tokens are enabled') return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`) @@ -242,9 +236,8 @@ export default async function callback (req, res) { } catch (error) { if (error instanceof Error) { return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`) - } else { - return res.redirect(error) } + return res.redirect(error) } const user = userObjectReturnedFromAuthorizeHandler @@ -258,9 +251,8 @@ export default async function callback (req, res) { } catch (error) { if (error instanceof Error) { return res.redirect(`${baseUrl}${basePath}/error?error=${encodeURIComponent(error)}`) - } else { - return res.redirect(error) } + return res.redirect(error) } const defaultJwtPayload = { @@ -282,7 +274,6 @@ export default async function callback (req, res) { await dispatchEvent(events.signIn, { user, account }) return res.redirect(callbackUrl || baseUrl) - } else { - return res.status(500).end(`Error: Callback for provider type ${type} not supported`) } + return res.status(500).end(`Error: Callback for provider type ${provider.type} not supported`) } diff --git a/src/server/routes/index.js b/src/server/routes/index.js new file mode 100644 index 0000000000..4cf7daa8f3 --- /dev/null +++ b/src/server/routes/index.js @@ -0,0 +1,5 @@ +export { default as callback } from './callback' +export { default as signin } from './signin' +export { default as signout } from './signout' +export { default as session } from './session' +export { default as providers } from './providers' diff --git a/src/server/routes/providers.js b/src/server/routes/providers.js index 4f2f0eac0c..849cdf437b 100644 --- a/src/server/routes/providers.js +++ b/src/server/routes/providers.js @@ -1,24 +1,15 @@ /** - * Return a JSON object with a list of all outh providers currently configured + * Return a JSON object with a list of all OAuth providers currently configured * and their signin and callback URLs. This makes it possible to automatically * generate buttons for all providers when rendering client side. */ export default function providers (req, res) { const { providers } = req.options - const result = Object.entries(providers) - .reduce((acc, [provider, providerConfig]) => ({ - ...acc, - [provider]: { - id: provider, - name: providerConfig.name, - type: providerConfig.type, - signinUrl: providerConfig.signinUrl, - callbackUrl: providerConfig.callbackUrl - } - }), {}) + const result = providers.reduce((acc, { id, name, type, signinUrl, callbackUrl }) => { + acc[id] = { id, name, type, signinUrl, callbackUrl } + return acc + }, {}) - res.setHeader('Content-Type', 'application/json') res.json(result) - return res.end() } diff --git a/src/server/routes/session.js b/src/server/routes/session.js index d8d3de3a04..d8d3431625 100644 --- a/src/server/routes/session.js +++ b/src/server/routes/session.js @@ -13,9 +13,7 @@ export default async function session (req, res) { const sessionToken = req.cookies[cookies.sessionToken.name] if (!sessionToken) { - res.setHeader('Content-Type', 'application/json') - res.json({}) - return res.end() + return res.json({}) } let response = {} @@ -101,7 +99,5 @@ export default async function session (req, res) { } } - res.setHeader('Content-Type', 'application/json') res.json(response) - return res.end() } diff --git a/src/server/routes/signin.js b/src/server/routes/signin.js index 372a10b970..e5e323911f 100644 --- a/src/server/routes/signin.js +++ b/src/server/routes/signin.js @@ -5,23 +5,19 @@ import logger from '../../lib/logger' /** Handle requests to /api/auth/signin */ export default async function signin (req, res) { const { - provider: providerName, - providers, + provider, baseUrl, basePath, adapter, callbacks, csrfToken } = req.options - const provider = providers[providerName] - const { type } = provider - if (!type) { - res.status(500) - return res.end(`Error: Type not specified for ${provider}`) + if (!provider.type) { + return res.status(500).end(`Error: Type not specified for ${provider.name}`) } - if (type === 'oauth' && req.method === 'POST') { + if (provider.type === 'oauth' && req.method === 'POST') { try { const oAuthSigninUrl = await oAuthSignin(provider, csrfToken) return res.redirect(oAuthSigninUrl) @@ -29,7 +25,7 @@ export default async function signin (req, res) { logger.error('SIGNIN_OAUTH_ERROR', error) return res.redirect(`${baseUrl}${basePath}/error?error=OAuthSignin`) } - } else if (type === 'email' && req.method === 'POST') { + } else if (provider.type === 'email' && req.method === 'POST') { if (!adapter) { logger.error('EMAIL_REQUIRES_ADAPTER_ERROR') return res.redirect(`${baseUrl}${basePath}/error?error=Configuration`) @@ -41,7 +37,7 @@ export default async function signin (req, res) { // according to RFC 2821, but in practice this causes more problems than // it solves. We treat email addresses as all lower case. If anyone // complains about this we can make strict RFC 2821 compliance an option. - const email = req.body.email ? req.body.email.toLowerCase() : null + const email = req.body.email?.toLowerCase() ?? null // If is an existing user return a user object (otherwise use placeholder) const profile = await getUserByEmail(email) || { email } @@ -74,7 +70,6 @@ export default async function signin (req, res) { return res.redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent( provider.id )}&type=${encodeURIComponent(provider.type)}`) - } else { - return res.redirect(`${baseUrl}${basePath}/signin`) } + return res.redirect(`${baseUrl}${basePath}/signin`) }