diff --git a/.gitignore b/.gitignore index c9c23d890a..0a945f3d2d 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,9 @@ yarn-debug.log* yarn-error.log* # Docusaurus -www/build \ No newline at end of file +www/build + +#VS +/.vs/slnx.sqlite-journal +/.vs/slnx.sqlite +/.vs diff --git a/package-lock.json b/package-lock.json index cf0e8f80e7..8a1c8345b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "next-auth", - "version": "2.1.0", + "version": "3.0.0-beta.9", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 2dcfb83187..068583fd0c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-auth", - "version": "2.2.0", + "version": "3.0.0-beta.18", "description": "An authentication library for Next.js", "repository": "https://github.com/iaincollins/next-auth.git", "author": "Iain Collins ", diff --git a/src/adapters/index.js b/src/adapters/index.js index 6b7e0c304d..38cbe0b109 100644 --- a/src/adapters/index.js +++ b/src/adapters/index.js @@ -1,6 +1,8 @@ import TypeORM from './typeorm' +import Prisma from './prisma' export default { Default: TypeORM.Adapter, - TypeORM + TypeORM, + Prisma } diff --git a/src/adapters/prisma/index.js b/src/adapters/prisma/index.js new file mode 100644 index 0000000000..769c01a783 --- /dev/null +++ b/src/adapters/prisma/index.js @@ -0,0 +1,331 @@ +import { createHash } from 'crypto' + +import { CreateUserError } from '../../lib/errors' +import logger from '../../lib/logger' + +const Adapter = (prismaConfig, options = {}) => { + const { + prisma, modelMapping = { + User: 'user', + Account: 'account', + Session: 'session', + VerificationRequest: 'verificationRequest' + } + } = prismaConfig + + const { User, Account, Session, VerificationRequest } = modelMapping + + async function getAdapter (appOptions) { + function debugMessage (debugCode, ...args) { + if (appOptions && appOptions.debug) { + logger.debug(`PRISMA_${debugCode}`, ...args) + } + } + + if (appOptions && (!appOptions.session || !appOptions.session.maxAge)) { + debugMessage('GET_ADAPTER', 'Session expiry not configured (defaulting to 30 days') + } + const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000 + const sessionMaxAge = (appOptions && appOptions.session && appOptions.session.maxAge) + ? appOptions.session.maxAge * 1000 + : defaultSessionMaxAge + const sessionUpdateAge = (appOptions && appOptions.session && appOptions.session.updateAge) + ? appOptions.session.updateAge * 1000 + : 0 + + async function createUser (profile) { + debugMessage('CREATE_USER', profile) + try { + return prisma[User].create({ + data: { + name: profile.name, + email: profile.email, + image: profile.image, + emailVerified: profile.emailVerified && + Object.prototype.toString.call(profile.emailVerified) === '[object Date]' + ? profile.emailVerified.toISOString() + : null + } + }) + } catch (error) { + logger.error('CREATE_USER_ERROR', error) + return Promise.reject(new CreateUserError(error)) + } + } + + async function getUser (id) { + debugMessage('GET_USER', id) + try { + return prisma[User].findOne({ where: { id } }) + } catch (error) { + logger.error('GET_USER_BY_ID_ERROR', error) + return Promise.reject(new Error('GET_USER_BY_ID_ERROR', error)) + } + } + + async function getUserByEmail (email) { + debugMessage('GET_USER_BY_EMAIL', email) + try { + if (!email) { return Promise.resolve(null) } + return prisma[User].findOne({ where: { email } }) + } catch (error) { + logger.error('GET_USER_BY_EMAIL_ERROR', error) + return Promise.reject(new Error('GET_USER_BY_EMAIL_ERROR', error)) + } + } + + async function getUserByProviderAccountId (providerId, providerAccountId) { + debugMessage('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId) + try { + return prisma[Account].findOne({ where: { providerAccountId: `${providerAccountId}` } })[User]() + } catch (error) { + logger.error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error) + return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', error)) + } + } + + async function updateUser (user) { + debugMessage('UPDATE_USER', user) + try { + const { id, ...rest } = user + return prisma[User].update({ where: { id }, data: rest }) + } catch (error) { + logger.error('UPDATE_USER_ERROR', error) + return Promise.reject(new Error('UPDATE_USER_ERROR', error)) + } + } + + async function deleteUser (userId) { + debugMessage('DELETE_USER', userId) + try { + return prisma[User].delete({ where: { id: userId } }) + } catch (error) { + logger.error('DELETE_USER_ERROR', error) + return Promise.reject(new Error('DELETE_USER_ERROR', error)) + } + } + + async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) { + debugMessage('LINK_ACCOUNT', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) + try { + return prisma[Account].create({ + data: { + accessToken, + refreshToken, + compoundId: createHash('sha256').update(`${providerId}:${providerAccountId}`).digest('hex'), + providerAccountId: `${providerAccountId}`, + providerId, + providerType, + accessTokenExpires, + [User]: { + connect: { + id: userId + } + } + } + }) + } catch (error) { + logger.error('LINK_ACCOUNT_ERROR', error) + return Promise.reject(new Error('LINK_ACCOUNT_ERROR', error)) + } + } + + async function unlinkAccount (userId, providerId, providerAccountId) { + debugMessage('UNLINK_ACCOUNT', userId, providerId, providerAccountId) + try { + return prisma[Account].delete({ where: { providerAccountId: `${providerAccountId}` } }) + } catch (error) { + logger.error('UNLINK_ACCOUNT_ERROR', error) + return Promise.reject(new Error('UNLINK_ACCOUNT_ERROR', error)) + } + } + + async function createSession (user) { + debugMessage('CREATE_SESSION', user) + try { + let expires = null + if (sessionMaxAge) { + const dateExpires = new Date() + dateExpires.setTime(dateExpires.getTime() + sessionMaxAge) + expires = dateExpires.toISOString() + } + + return prisma[Session].create({ + data: { + expires, + [User]: { + connect: { + id: user.id + } + } + } + }) + } catch (error) { + logger.error('CREATE_SESSION_ERROR', error) + return Promise.reject(new Error('CREATE_SESSION_ERROR', error)) + } + } + + async function getSession (sessionToken) { + debugMessage('GET_SESSION', sessionToken) + try { + const session = await prisma[Session].findOne({ where: { sessionToken } }) + + // Check session has not expired (do not return it if it has) + if (session && session.expires && new Date() > session.expires) { + await prisma[Session].delete({ where: { sessionToken } }) + return null + } + + return session + } catch (error) { + logger.error('GET_SESSION_ERROR', error) + return Promise.reject(new Error('GET_SESSION_ERROR', error)) + } + } + + async function updateSession (session, force) { + debugMessage('UPDATE_SESSION', session) + try { + if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.expires) { + // Calculate last updated date, to throttle write updates to database + // Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge + // e.g. ({expiry date} - 30 days) + 1 hour + // + // Default for sessionMaxAge is 30 days. + // Default for sessionUpdateAge is 1 hour. + const dateSessionIsDueToBeUpdated = new Date(session.expires) + dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge) + dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge) + + // Trigger update of session expiry date and write to database, only + // if the session was last updated more than {sessionUpdateAge} ago + if (new Date() > dateSessionIsDueToBeUpdated) { + const newExpiryDate = new Date() + newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge) + session.expires = newExpiryDate + } else if (!force) { + return null + } + } else { + // If session MaxAge, session UpdateAge or session.expires are + // missing then don't even try to save changes, unless force is set. + if (!force) { return null } + } + + const { id, ...rest } = session + return prisma[Session].update({ where: { id }, data: rest }) + } catch (error) { + logger.error('UPDATE_SESSION_ERROR', error) + return Promise.reject(new Error('UPDATE_SESSION_ERROR', error)) + } + } + + async function deleteSession (sessionToken) { + debugMessage('DELETE_SESSION', sessionToken) + try { + return prisma[Session].delete({ where: { sessionToken } }) + } catch (error) { + logger.error('DELETE_SESSION_ERROR', error) + return Promise.reject(new Error('DELETE_SESSION_ERROR', error)) + } + } + + async function createVerificationRequest (identifier, url, token, secret, provider) { + debugMessage('CREATE_VERIFICATION_REQUEST', identifier) + try { + const { baseUrl } = appOptions + const { sendVerificationRequest, maxAge } = provider + + // Store hashed token (using secret as salt) so that tokens cannot be exploited + // even if the contents of the database is compromised. + // @TODO Use bcrypt function here instead of simple salted hash + const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex') + + let expires = null + if (maxAge) { + const dateExpires = new Date() + dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000)) + expires = dateExpires.toISOString() + } + + // Save to database + const verificationRequest = await prisma[VerificationRequest].create({ + data: { + identifier, + token: hashedToken, + expires + } + }) + + // With the verificationCallback on a provider, you can send an email, or queue + // an email to be sent, or perform some other action (e.g. send a text message) + await sendVerificationRequest({ identifier, url, token, baseUrl, provider }) + + return verificationRequest + } catch (error) { + logger.error('CREATE_VERIFICATION_REQUEST_ERROR', error) + return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR', error)) + } + } + + async function getVerificationRequest (identifier, token, secret, provider) { + debugMessage('GET_VERIFICATION_REQUEST', identifier, token) + try { + // Hash token provided with secret before trying to match it with database + // @TODO Use bcrypt instead of salted SHA-256 hash for token + const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex') + const verificationRequest = await prisma[VerificationRequest].findOne({ where: { token: hashedToken } }) + + if (verificationRequest && verificationRequest.expires && new Date() > verificationRequest.expires) { + // Delete verification entry so it cannot be used again + await prisma[VerificationRequest].delete({ where: { token: hashedToken } }) + return null + } + + return verificationRequest + } catch (error) { + logger.error('GET_VERIFICATION_REQUEST_ERROR', error) + return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR', error)) + } + } + + async function deleteVerificationRequest (identifier, token, secret, provider) { + debugMessage('DELETE_VERIFICATION', identifier, token) + try { + // Delete verification entry so it cannot be used again + const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex') + await prisma[VerificationRequest].delete({ where: { token: hashedToken } }) + } catch (error) { + logger.error('DELETE_VERIFICATION_REQUEST_ERROR', error) + return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error)) + } + } + + return Promise.resolve({ + createUser, + getUser, + getUserByEmail, + getUserByProviderAccountId, + updateUser, + deleteUser, + linkAccount, + unlinkAccount, + createSession, + getSession, + updateSession, + deleteSession, + createVerificationRequest, + getVerificationRequest, + deleteVerificationRequest + }) + } + + return { + getAdapter + } +} + +export default { + Adapter +} diff --git a/src/adapters/typeorm/index.js b/src/adapters/typeorm/index.js index 8030fc0176..b57d0f7546 100644 --- a/src/adapters/typeorm/index.js +++ b/src/adapters/typeorm/index.js @@ -193,6 +193,30 @@ const Adapter = (typeOrmConfig, options = {}) => { return false } + async function getAccounts (userId) { + debugMessage('GET_ACCOUNTS', userId) + try { + const accounts = await connection.getRepository(Account).find({ userId }) + if (!accounts) { return null } + return accounts + } catch (error) { + logger.error('GET_ACCOUNTS_ERROR', error) + return Promise.reject(new Error('GET_ACCOUNTS_ERROR', error)) + } + } + + async function getAccount (userId, providerId) { + debugMessage('GET_ACCOUNT', userId) + try { + const account = await connection.getRepository(Account).findOne({ userId, providerType: 'oauth', providerId }) + if (!account) { return null } + return account + } catch (error) { + logger.error('GET_ACCOUNT_ERROR', error) + return Promise.reject(new Error('GET_ACCOUNT_ERROR', error)) + } + } + async function createSession (user) { debugMessage('CREATE_SESSION', user) try { @@ -279,7 +303,7 @@ const Adapter = (typeOrmConfig, options = {}) => { async function createVerificationRequest (identifier, url, token, secret, provider) { debugMessage('CREATE_VERIFICATION_REQUEST', identifier) try { - const { site } = appOptions + const { baseUrl } = appOptions const { sendVerificationRequest, maxAge } = provider // Store hashed token (using secret as salt) so that tokens cannot be exploited @@ -300,7 +324,7 @@ const Adapter = (typeOrmConfig, options = {}) => { // With the verificationCallback on a provider, you can send an email, or queue // an email to be sent, or perform some other action (e.g. send a text message) - await sendVerificationRequest({ identifier, url, token, site, provider }) + await sendVerificationRequest({ identifier, url, token, baseUrl, provider }) return verificationRequest } catch (error) { @@ -351,6 +375,8 @@ const Adapter = (typeOrmConfig, options = {}) => { deleteUser, linkAccount, unlinkAccount, + getAccounts, + getAccount, createSession, getSession, updateSession, diff --git a/src/adapters/typeorm/lib/transform.js b/src/adapters/typeorm/lib/transform.js index 136c7ac4ae..2b636f4e95 100644 --- a/src/adapters/typeorm/lib/transform.js +++ b/src/adapters/typeorm/lib/transform.js @@ -7,40 +7,29 @@ const postgres = (models, options) => { options.namingStrategy = new SnakeCaseNamingStrategy() } - // Only transforms models that are not custom models - const { models: customModels = {} } = options - // For Postgres we need to use the `timestamp with time zone` type // aka `timestamptz` to store timestamps correctly in UTC. - if (!customModels.User) { - for (const column in models.User.schema.columns) { - if (models.User.schema.columns[column].type === 'timestamp') { - models.User.schema.columns[column].type = 'timestamptz' - } + for (const column in models.User.schema.columns) { + if (models.User.schema.columns[column].type === 'timestamp') { + models.User.schema.columns[column].type = 'timestamptz' } } - if (!customModels.Account) { - for (const column in models.Account.schema.columns) { - if (models.Account.schema.columns[column].type === 'timestamp') { - models.Account.schema.columns[column].type = 'timestamptz' - } + for (const column in models.Account.schema.columns) { + if (models.Account.schema.columns[column].type === 'timestamp') { + models.Account.schema.columns[column].type = 'timestamptz' } } - if (!customModels.Session) { - for (const column in models.Session.schema.columns) { - if (models.Session.schema.columns[column].type === 'timestamp') { - models.Session.schema.columns[column].type = 'timestamptz' - } + for (const column in models.Session.schema.columns) { + if (models.Session.schema.columns[column].type === 'timestamp') { + models.Session.schema.columns[column].type = 'timestamptz' } } - if (!customModels.VerificationRequest) { - for (const column in models.VerificationRequest.schema.columns) { - if (models.VerificationRequest.schema.columns[column].type === 'timestamp') { - models.VerificationRequest.schema.columns[column].type = 'timestamptz' - } + for (const column in models.VerificationRequest.schema.columns) { + if (models.VerificationRequest.schema.columns[column].type === 'timestamp') { + models.VerificationRequest.schema.columns[column].type = 'timestamptz' } } } @@ -66,44 +55,33 @@ const mongodb = (models, options) => { // Object ID in every property of type Object ID in the result (but the // database will look fine); so use `type: 'objectId'` for them instead. - // Only transforms models that are not custom models - const { models: customModels = {} } = options - - if (!customModels.User) { - delete models.User.schema.columns.id.type - models.User.schema.columns.id.objectId = true - - // The options `unique: true` and `nullable: true` don't work the same - // with MongoDB as they do with SQL databases like MySQL and Postgres, - // we also to add sparce to the index. This still doesn't allow multiple - // *null* values, but does allow some records to omit the property. - delete models.User.schema.columns.email.unique - models.User.schema.indices = [ - { - name: 'email', - unique: true, - sparse: true, - columns: ['email'] - } - ] - } + delete models.User.schema.columns.id.type + models.User.schema.columns.id.objectId = true + + // The options `unique: true` and `nullable: true` don't work the same + // with MongoDB as they do with SQL databases like MySQL and Postgres, + // we also to add sparce to the index. This still doesn't allow multiple + // *null* values, but does allow some records to omit the property. + delete models.User.schema.columns.email.unique + models.User.schema.indices = [ + { + name: 'email', + unique: true, + sparse: true, + columns: ['email'] + } + ] - if (!customModels.Account) { - delete models.Account.schema.columns.id.type - models.Account.schema.columns.id.objectId = true - models.Account.schema.columns.userId.type = 'objectId' - } + delete models.Account.schema.columns.id.type + models.Account.schema.columns.id.objectId = true + models.Account.schema.columns.userId.type = 'objectId' - if (!customModels.Session) { - delete models.Session.schema.columns.id.type - models.Session.schema.columns.id.objectId = true - models.Session.schema.columns.userId.type = 'objectId' - } + delete models.Session.schema.columns.id.type + models.Session.schema.columns.id.objectId = true + models.Session.schema.columns.userId.type = 'objectId' - if (!customModels.VerificationRequest) { - delete models.VerificationRequest.schema.columns.id.type - models.VerificationRequest.schema.columns.id.objectId = true - } + delete models.VerificationRequest.schema.columns.id.type + models.VerificationRequest.schema.columns.id.objectId = true } const sqlite = (models, options) => { @@ -112,9 +90,6 @@ const sqlite = (models, options) => { options.namingStrategy = new SnakeCaseNamingStrategy() } - // Only transforms models that are not custom models - const { models: customModels = {} } = options - // SQLite does not support `timestamp` fields so we remap them to `datetime` // in all models. // @@ -123,35 +98,27 @@ const sqlite = (models, options) => { // // NB: SQLite adds 'create' and 'update' fields to allow rows, but that is // specific to SQLite and so we ignore that behaviour. - if (!customModels.User) { - for (const column in models.User.schema.columns) { - if (models.User.schema.columns[column].type === 'timestamp') { - models.User.schema.columns[column].type = 'datetime' - } + for (const column in models.User.schema.columns) { + if (models.User.schema.columns[column].type === 'timestamp') { + models.User.schema.columns[column].type = 'datetime' } } - if (!customModels.Account) { - for (const column in models.Account.schema.columns) { - if (models.Account.schema.columns[column].type === 'timestamp') { - models.Account.schema.columns[column].type = 'datetime' - } + for (const column in models.Account.schema.columns) { + if (models.Account.schema.columns[column].type === 'timestamp') { + models.Account.schema.columns[column].type = 'datetime' } } - if (!customModels.Session) { - for (const column in models.Session.schema.columns) { - if (models.Session.schema.columns[column].type === 'timestamp') { - models.Session.schema.columns[column].type = 'datetime' - } + for (const column in models.Session.schema.columns) { + if (models.Session.schema.columns[column].type === 'timestamp') { + models.Session.schema.columns[column].type = 'datetime' } } - if (!customModels.VerificationRequest) { - for (const column in models.VerificationRequest.schema.columns) { - if (models.VerificationRequest.schema.columns[column].type === 'timestamp') { - models.VerificationRequest.schema.columns[column].type = 'datetime' - } + for (const column in models.VerificationRequest.schema.columns) { + if (models.VerificationRequest.schema.columns[column].type === 'timestamp') { + models.VerificationRequest.schema.columns[column].type = 'datetime' } } } diff --git a/src/client/index.js b/src/client/index.js index 7f149f46cb..e65ba1e083 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -1,176 +1,280 @@ -// fetch() is built in to Next.js 9.4 (you can use a polyfill if using an older version) +/// Note: fetch() is built in to Next.js 9.4 +// +// Note about signIn() and signOut() methods: +// +// On signIn() and signOut() we pass 'json: true' to request a response in JSON +// instead of HTTP as redirect URLs on other domains are not returned to +// requests made using the fetch API in the browser, and we need to ask the API +// to return the response as a JSON object (the end point still defaults to +// returning an HTTP response with a redirect for non-JavaScript clients). +// +// We use HTTP POST requests with CSRF Tokens to protect against CSRF attacks. + /* global fetch:false */ import { useState, useEffect, useContext, createContext, createElement } from 'react' import logger from '../lib/logger' +import parseUrl from '../lib/parse-url' +// This behaviour mirrors the default behaviour for getting the site name that +// happens server side in server/index.js +// 1. An empty value is legitimate when the code is being invoked client side as +// relative URLs are valid in that context and so defaults to empty. +// 2. When invoked server side the value is picked up from an environment +// variable and defaults to 'http://localhost:3000'. const __NEXTAUTH = { - site: '', - basePath: '/api/auth', - clientMaxAge: 0 // e.g. 0 == disabled, 60 == 60 seconds + baseUrl: parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL).baseUrl, + basePath: parseUrl(process.env.NEXTAUTH_URL).basePath, + keepAlive: 0, // 0 == disabled (don't send); 60 == send every 60 seconds + clientMaxAge: 0, // 0 == disabled (only use cache); 60 == sync if last checked > 60 seconds ago + // Properties starting with _ are used for tracking internal app state + _clientLastSync: 0, // used for timestamp since last sycned (in seconds) + _clientSyncTimer: null, // stores timer for poll interval + _eventListenersAdded: false, // tracks if event listeners have been added, + _clientSession: undefined, // stores last session response from hook, + // Generate a unique ID to make it possible to identify when a message + // was sent from this tab/window so it can be ignored to avoid event loops. + _clientId: Math.random().toString(36).substring(2) + Date.now().toString(36), + // Used to store to function export by getSession() hook + _getSession: () => {} } -let __NEXTAUTH_EVENT_LISTENER_ADDED = false +// Add event listners on load +if (typeof window !== 'undefined') { + if (__NEXTAUTH._eventListenersAdded === false) { + __NEXTAUTH._eventListenersAdded = true + + // Listen for storage events and update session if event fired from + // another window (but suppress firing another event to avoid a loop) + window.addEventListener('storage', async (event) => { + if (event.key === 'nextauth.message') { + const message = JSON.parse(event.newValue) + if (message.event && message.event === 'session' && message.data) { + // Ignore storage events fired from the same window that created them + if (__NEXTAUTH._clientId === message.clientId) { + return + } + + // Fetch new session data but pass 'true' to it not to fire an event to + // avoid an infinite loop. + // + // Note: We could pass session data through and do something like + // `setData(message.data)` but that can cause problems depending + // on how the session object is being used in the client; it is + // more robust to have each window/tab fetch it's own copy of the + // session object rather than share it across instances. + await __NEXTAUTH._getSession({ event: 'storage' }) + } + } + }) + + // Listen for window focus/blur events + window.addEventListener('focus', async (event) => __NEXTAUTH._getSession({ event: 'focus' })) + window.addEventListener('blur', async (event) => __NEXTAUTH._getSession({ event: 'blur' })) + } +} // Method to set options. The documented way is to use the provider, but this // method is being left in as an alternative, that will be helpful if/when we // expose a vanilla JavaScript version that doesn't depend on React. const setOptions = ({ - site, + baseUrl, basePath, - clientMaxAge + clientMaxAge, + keepAlive } = {}) => { - if (site) { __NEXTAUTH.site = site } + if (baseUrl) { __NEXTAUTH.baseUrl = baseUrl } if (basePath) { __NEXTAUTH.basePath = basePath } if (clientMaxAge) { __NEXTAUTH.clientMaxAge = clientMaxAge } + if (keepAlive) { + __NEXTAUTH.keepAlive = keepAlive + + if (typeof window !== 'undefined' && keepAlive > 0) { + // Clear existing timer (if there is one) + if (__NEXTAUTH._clientSyncTimer !== null) { clearTimeout(__NEXTAUTH._clientSyncTimer) } + + // Set next timer to trigger in number of seconds + __NEXTAUTH._clientSyncTimer = setTimeout(async () => { + // Only invoke keepalive when a session exists + if (__NEXTAUTH._clientSession) { + await __NEXTAUTH._getSession({ event: 'timer' }) + } + }, keepAlive * 1000) + } + } } // Universal method (client + server) -const getSession = async ({ req, ctx } = {}) => { - if (!req && ctx.req) { - req = ctx.req - } +const getSession = async ({ req, ctx, triggerEvent = true } = {}) => { + // If passed 'appContext' via getInitialProps() in _app.js then get the req + // object from ctx and use that for the req value to allow getSession() to + // work seemlessly in getInitialProps() on server side pages *and* in _app.js. + if (!req && ctx && ctx.req) { req = ctx.req } - const baseUrl = _baseUrl() - const options = req ? { headers: { cookie: req.headers.cookie } } : {} - const session = await _fetchData(`${baseUrl}/session`, options) - _sendMessage({ event: 'session', data: { trigger: 'getSession' } }) + const baseUrl = _apiBaseUrl() + const fetchOptions = req ? { headers: { cookie: req.headers.cookie } } : {} + const session = await _fetchData(`${baseUrl}/session`, fetchOptions) + if (triggerEvent) { + _sendMessage({ event: 'session', data: { trigger: 'getSession' } }) + } return session } // Universal method (client + server) -const getProviders = async () => { - const baseUrl = _baseUrl() - return _fetchData(`${baseUrl}/providers`) -} +const getCsrfToken = async ({ req, ctx } = {}) => { + // If passed 'appContext' via getInitialProps() in _app.js then get the req + // object from ctx and use that for the req value to allow getCsrfToken() to + // work seemlessly in getInitialProps() on server side pages *and* in _app.js. + if (!req && ctx && ctx.req) { req = ctx.req } -// Universal method (client + server) -const getCsrfToken = async () => { - const baseUrl = _baseUrl() - const data = await _fetchData(`${baseUrl}/csrf`) + const baseUrl = _apiBaseUrl() + const fetchOptions = req ? { headers: { cookie: req.headers.cookie } } : {} + const data = await _fetchData(`${baseUrl}/csrf`, fetchOptions) return data && data.csrfToken ? data.csrfToken : null } +// Universal method (client + server); does not require request headers +const getProviders = async () => { + const baseUrl = _apiBaseUrl() + return _fetchData(`${baseUrl}/providers`) +} + // Context to store session data globally const SessionContext = createContext() // Client side method -// Hook to access the session data stored in the context const useSession = (session) => { + // Try to use context if we can const value = useContext(SessionContext) - // If we have no Provider in the tree we call the actual hook for fetching the session + + // If we have no Provider in the tree, call the actual hook if (value === undefined) { - return useSessionData(session) + return _useSessionHook(session) } return value } // Internal hook for getting session from the api. -const useSessionData = (session) => { - const clientMaxAge = __NEXTAUTH.clientMaxAge * 1000 +const _useSessionHook = (session) => { const [data, setData] = useState(session) const [loading, setLoading] = useState(true) - const _getSession = async (sendEvent = true) => { + const _getSession = async ({ event = null } = {}) => { try { - setData(await getSession()) - setLoading(false) + const triggredByEvent = (event !== null) + const triggeredByStorageEvent = !!((event && event === 'storage')) - // Send event to trigger other tabs to update (unless sendEvent is false) - if (sendEvent) { - _sendMessage({ event: 'session', data: { trigger: 'useSessionData' } }) - } + const clientMaxAge = __NEXTAUTH.clientMaxAge + const clientLastSync = parseInt(__NEXTAUTH._clientLastSync) + const currentTime = Math.floor(new Date().getTime() / 1000) + const clientSession = __NEXTAUTH._clientSession - if (typeof window !== 'undefined' && __NEXTAUTH_EVENT_LISTENER_ADDED === false) { - __NEXTAUTH_EVENT_LISTENER_ADDED = true - window.addEventListener('storage', async (event) => { - if (event.key === 'nextauth.message') { - const message = JSON.parse(event.newValue) - if (message.event && message.event === 'session' && message.data) { - // Fetch new session data but tell it not to fire an event to - // avoid an infinite loop. - // - // Note: We could pass session data through and do something like - // `setData(message.data)` but that causes problems depending on - // how the session object is being used and may expose session - // data to 3rd party scripts, it's safer to update the session - // this way. - await _getSession(false) - } - } - }) + // Updates triggered by a storage event *always* trigger an update and we + // always update if we don't have any value for the current session state. + if (triggeredByStorageEvent === false && clientSession !== undefined) { + if (clientMaxAge === 0 && triggredByEvent !== true) { + // If there is no time defined for when a session should be considered + // stale, then it's okay to use the value we have until an event is + // triggered which updates it. + return + } else if (clientMaxAge > 0 && clientSession === null) { + // If the client doesn't have a session then we don't need to call + // the server to check if it does (if they have signed in via another + // tab or window that will come through as a triggeredByStorageEvent + // event and will skip this logic) + return + } else if (clientMaxAge > 0 && currentTime < (clientLastSync + clientMaxAge)) { + // If the session freshness is within clientMaxAge then don't request + // it again on this call (avoids too many invokations). + return + } } - // If CLIENT_MAXAGE is greater than zero, trigger auto re-fetching session - if (clientMaxAge > 0) { - setTimeout(async (session) => { - await _getSession() - }, clientMaxAge) - } + if (clientSession === undefined) { __NEXTAUTH._clientSession = null } + + // Update clientLastSync before making response to avoid repeated + // invokations that would otherwise be triggered while we are still + // waiting for a response. + __NEXTAUTH._clientLastSync = Math.floor(new Date().getTime() / 1000) + + // If this call was invoked via a storage event (i.e. another window) then + // tell getSession not to trigger an event when it calls to avoid an + // infinate loop. + const triggerEvent = (triggeredByStorageEvent === false) + const newClientSessionData = await getSession({ triggerEvent }) + + // Save session state internally, just so we can track that we've checked + // if a session exists at least once. + __NEXTAUTH._clientSession = newClientSessionData + + setData(newClientSessionData) + setLoading(false) } catch (error) { logger.error('CLIENT_USE_SESSION_ERROR', error) } } - useEffect(() => { _getSession() }, []) + + __NEXTAUTH._getSession = _getSession + + useEffect(() => { + _getSession() + }) return [data, loading] } // Client side method -const signin = async (provider, args) => { +const signIn = async (provider, args = {}) => { + const baseUrl = _apiBaseUrl() const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location - - if (!provider) { - // Redirect to sign in page if no provider specified - const baseUrl = _baseUrl() - window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}` - return - } - const providers = await getProviders() - if (!providers[provider]) { + + // Redirect to sign in page if no valid provider specified + if (!provider || !providers[provider]) { // If Provider not recognized, redirect to sign in page - const baseUrl = _baseUrl() window.location = `${baseUrl}/signin?callbackUrl=${encodeURIComponent(callbackUrl)}` - } else if (providers[provider].type === 'oauth') { - // If is an OAuth provider, redirect to providers[provider].signinUrl - window.location = `${providers[provider].signinUrl}?callbackUrl=${encodeURIComponent(callbackUrl)}` } else { - // If is any other provider type, POST to providers[provider].signinUrl (with CSRF Token) - const options = { + const signInUrl = (providers[provider].type === 'credentials') + ? `${baseUrl}/callback/${provider}` + : `${baseUrl}/signin/${provider}` + // If is any other provider type, POST to provider URL with CSRF Token, + // callback URL and any other parameters supplied. + const fetchOptions = { method: 'post', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: _encodedForm({ + ...args, csrfToken: await getCsrfToken(), callbackUrl: callbackUrl, - ...args + json: true }) } - const res = await fetch(providers[provider].signinUrl, options) - window.location = res.url ? res.url : callbackUrl + const res = await fetch(signInUrl, fetchOptions) + const data = await res.json() + window.location = data.url ? data.url : callbackUrl } } // Client side method -const signout = async (args) => { +const signOut = async (args = {}) => { const callbackUrl = (args && args.callbackUrl) ? args.callbackUrl : window.location - const baseUrl = _baseUrl() - const options = { + const baseUrl = _apiBaseUrl() + const fetchOptions = { method: 'post', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: _encodedForm({ csrfToken: await getCsrfToken(), - callbackUrl: callbackUrl + callbackUrl: callbackUrl, + json: true }) } - const res = await fetch(`${baseUrl}/signout`, options) - + const res = await fetch(`${baseUrl}/signout`, fetchOptions) + const data = await res.json() _sendMessage({ event: 'session', data: { trigger: 'signout' } }) - - window.location = res.url ? res.url : callbackUrl + window.location = data.url ? data.url : callbackUrl } // Provider to wrap the app in to make session data available globally @@ -190,7 +294,18 @@ const _fetchData = async (url, options = {}) => { } } -const _baseUrl = () => `${__NEXTAUTH.site}${__NEXTAUTH.basePath}` +const _apiBaseUrl = () => { + if (typeof window === 'undefined') { + // NEXTAUTH_URL should always be set explicitly to support server side calls - log warning if not set + if (!process.env.NEXTAUTH_URL) { logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set') } + + // Return absolute path when called server side + return `${__NEXTAUTH.baseUrl}${__NEXTAUTH.basePath}` + } else { + // Return relative path when called client side + return __NEXTAUTH.basePath + } +} const _encodedForm = (formData) => { return Object.keys(formData).map((key) => { @@ -200,27 +315,29 @@ const _encodedForm = (formData) => { const _sendMessage = (message) => { if (typeof localStorage !== 'undefined') { - localStorage.setItem('nextauth.message', JSON.stringify(message)) // eslint-disable-line + const timestamp = Math.floor(new Date().getTime() / 1000) + localStorage.setItem('nextauth.message', JSON.stringify({ ...message, clientId: __NEXTAUTH._clientId, timestamp })) // eslint-disable-line } } export default { - // Call config() from _app.js to set options globally in the app. - // You need to set at least the site name to use server side calls. - options: setOptions, - setOptions, - // Some methods are exported with more than one name. This provides - // flexibility over how they can be invoked and compatibility with earlier - // releases (going back to v1 and earlier v2 beta releases). - // e.g. NextAuth.session() or const { getSession } from 'next-auth/client' - session: getSession, - providers: getProviders, - csrfToken: getCsrfToken, getSession, - getProviders, getCsrfToken, + getProviders, useSession, + signIn, + signOut, Provider, - signin, - signout + /* Deprecated / unsupported features below this line */ + // Use setOptions() set options globally in the app. + setOptions, + // Some methods are exported with more than one name. This provides some + // flexibility over how they can be invoked and backwards compatibility + // with earlier releases. + options: setOptions, + session: getSession, + providers: getProviders, + csrfToken: getCsrfToken, + signin: signIn, + signout: signOut } diff --git a/src/lib/jwt.js b/src/lib/jwt.js index 7412c04995..cadab1b076 100644 --- a/src/lib/jwt.js +++ b/src/lib/jwt.js @@ -1,37 +1,98 @@ import jwt from 'jsonwebtoken' import CryptoJS from 'crypto-js' -const encode = async ({ secret, key = secret, token = {}, maxAge }) => { +const SIGNING_ALGORITHM = 'HS256' // Specified explicitly to prevent tampering +const DEFAULT_ENCRYPTION_TYPE = 'AES' // Can be one of [ 'AES', false ] + +const encode = async ({ + secret, + key = secret, + token = {}, + maxAge, + encryption = DEFAULT_ENCRYPTION_TYPE +}) => { // If maxAge is set remove any existing created/expiry dates and replace them if (maxAge) { if (token.iat) { delete token.iat } if (token.exp) { delete token.exp } } - const signedToken = jwt.sign(token, secret, { expiresIn: maxAge }) - const encryptedToken = CryptoJS.AES.encrypt(signedToken, key).toString() - return encryptedToken + + const signedToken = jwt.sign(token, secret, { algorithm: SIGNING_ALGORITHM, expiresIn: maxAge }) + + switch (encryption) { + case false: + return signedToken + case 'AES': + return CryptoJS.AES.encrypt(signedToken, key).toString() + default: + throw new Error('Unsupported value for `encryption` passed to JWT encode()', encryption) + } } -const decode = async ({ secret, key = secret, token, maxAge }) => { +const decode = async ({ + secret, + key = secret, + token, + maxAge, + encryption = DEFAULT_ENCRYPTION_TYPE +}) => { if (!token) return null - const decryptedBytes = CryptoJS.AES.decrypt(token, key) - const decryptedToken = decryptedBytes.toString(CryptoJS.enc.Utf8) - const verifiedToken = jwt.verify(decryptedToken, secret, { maxAge }) - return verifiedToken + let tokenToVerify + + switch (encryption) { + case false: + tokenToVerify = token + break + case 'AES': { + const decryptedBytes = CryptoJS.AES.decrypt(token, key) + const decryptedToken = decryptedBytes.toString(CryptoJS.enc.Utf8) + tokenToVerify = decryptedToken + break + } + default: + throw new Error('Unsupported value for `encryption` passed to JWT decode()', encryption) + } + + return jwt.verify(tokenToVerify, secret, { algorithms: [SIGNING_ALGORITHM], maxAge }) } -// This is a simple helper method to make it easier to use JWT from an API route -const getJwt = async ({ req, secret, cookieName, maxAge }) => { - if (!req || !secret) throw new Error('Must pass { req, secret } to getJWT()') +const getToken = async ({ + req, + secret, + key = secret, + maxAge, + encryption, + secureCookie, + cookieName +}) => { + if (!req) throw new Error('Must pass `req` to JWT getToken()') - const secureCookieName = '__Secure-next-auth.session-token' - const insecureCookieName = 'next-auth.session-token' - const cookieValue = cookieName ? req.cookies[cookieName] : req.cookies[secureCookieName] || req.cookies[insecureCookieName] + // If cookie is not specified, choose what cookie name to use in a secure way + if (!cookieName) { + if (typeof secureCookie === 'undefined') { + // If secureCookie is not specified, assume unprefixed cookie in local dev + // environments or if an explictly non HTTPS url is specified. Otherwise + // asssume a secure prefixed cookie should be used. + secureCookie = !((!process.env.NEXTAUTH_URL || process.env.NEXTAUTH_URL.startsWith('http://'))) + } + // Use secure prefixed cookie by default. Only use unprefixed cookie name if + // secureCookie is false or if the site URL is HTTP (and not HTTPS). + cookieName = (secureCookie) ? '__Secure-next-auth.session-token' : 'next-auth.session-token' + } - if (!cookieValue) { return null } + // Try to get token from cookie + let token = req.cookies[cookieName] + + // If cookie not provided, look for bearer token in HTTP 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') { + const urlEncodedToken = req.headers.authorization.split(' ')[1] + token = decodeURIComponent(urlEncodedToken) + } try { - return await decode({ secret, token: cookieValue, maxAge }) + return await decode({ secret, key, token, maxAge, encryption }) } catch (error) { return null } @@ -40,5 +101,5 @@ const getJwt = async ({ req, secret, cookieName, maxAge }) => { export default { encode, decode, - getJwt + getToken } diff --git a/src/lib/logger.js b/src/lib/logger.js index a2fc5dcad3..d4b21c44a2 100644 --- a/src/lib/logger.js +++ b/src/lib/logger.js @@ -4,16 +4,26 @@ const logger = { !text ? console.error(errorCode) : console.error( - `[next-auth][error][${errorCode}]`, - text, - `\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}` + `[next-auth][error][${errorCode.toLowerCase()}]`, + text, + `\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}` + ) + } + }, + warn: (warnCode, ...text) => { + if (console) { + !text + ? console.warn(warnCode) + : console.warn( + `[next-auth][warn][${warnCode.toLowerCase()}]`, + text ) } }, debug: (debugCode, ...text) => { if (process && process.env && process.env._NEXTAUTH_DEBUG) { console.log( - `[next-auth][debug][${debugCode}]`, + `[next-auth][debug][${debugCode.toLowerCase()}]`, text ) } diff --git a/src/lib/parse-url.js b/src/lib/parse-url.js new file mode 100644 index 0000000000..0428925fc0 --- /dev/null +++ b/src/lib/parse-url.js @@ -0,0 +1,27 @@ +// Simple universal (client/server) function to split host and path +// We use this rather than a library because we need to use the same logic both +// client and server side and we only need to parse out the host and path, while +// supporting a default value, so a simple split is sufficent. +export default (url) => { + // Default values + const defaultHost = 'http://localhost:3000' + const defaultPath = '/api/auth' + + if (!url) { url = `${defaultHost}${defaultPath}` } + + // Default to HTTPS if no protocol explictly specified + const protocol = url.match(/^http?:\/\//) ? 'http' : 'https' + + // Normalize URLs by stripping protocol and no trailing slash + url = url.replace(/^https?:\/\//, '').replace(/\/$/, '') + + // Simple split based on first / + const [_host, ..._path] = url.split('/') + const baseUrl = _host ? `${protocol}://${_host}` : defaultHost + const basePath = _path.length > 0 ? `/${_path.join('/')}` : defaultPath + + return { + baseUrl, + basePath + } +} diff --git a/src/providers/apple.js b/src/providers/apple.js index 4b926454d5..ddfec79575 100644 --- a/src/providers/apple.js +++ b/src/providers/apple.js @@ -12,10 +12,12 @@ export default (options) => { authorizationUrl: 'https://appleid.apple.com/auth/authorize?response_type=code&id_token&response_mode=form_post', profileUrl: null, idToken: true, + state: false, // Apple doesn't support state verfication profile: (profile) => { + // The name of the user will only return on first login return { id: profile.sub, - name: profile.name == null ? profile.sub : profile.name, + name: profile.user != null ? profile.user.name.firstName + ' ' + profile.user.name.lastName : null, email: profile.email } }, @@ -35,7 +37,9 @@ export default (options) => { aud: 'https://appleid.apple.com', sub: appleId }, - privateKey, + // Automatically convert \\n into \n if found in private key. If the key + // is passed in an environment variable \n can get escaped as \\n + privateKey.replace(/\\n/g, '\n'), { algorithm: 'ES256', keyid: keyId diff --git a/src/providers/email.js b/src/providers/email.js index cee04b43f0..5f0acdf370 100644 --- a/src/providers/email.js +++ b/src/providers/email.js @@ -22,10 +22,11 @@ export default (options) => { } } -const sendVerificationRequest = ({ identifier: email, url, token, site, provider }) => { +const sendVerificationRequest = ({ identifier: email, url, baseUrl, provider }) => { return new Promise((resolve, reject) => { const { server, from } = provider - site = site.replace(/^https?:\/\//, '') // Strip protocol from site + // Strip protocol from URL and use domain as site name + const site = baseUrl.replace(/^https?:\/\//, '') nodemailer .createTransport(server) diff --git a/src/providers/spotify.js b/src/providers/spotify.js index 9320572c2a..933eee7c09 100644 --- a/src/providers/spotify.js +++ b/src/providers/spotify.js @@ -15,8 +15,8 @@ export default (options) => { id: profile.id, name: profile.display_name, email: profile.email, - image: profile.images[0].url, - }; + image: profile.images[0].url + } }, ...options } diff --git a/src/server/index.js b/src/server/index.js index 0c6d5315cf..f4ca74ee4e 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,5 +1,6 @@ import { createHash, randomBytes } from 'crypto' import jwt from '../lib/jwt' +import parseUrl from '../lib/parse-url' import cookie from './lib/cookie' import callbackUrlHandler from './lib/callback-url-handler' import parseProviders from './lib/providers' @@ -10,11 +11,16 @@ import signin from './routes/signin' import signout from './routes/signout' import callback from './routes/callback' import session from './routes/session' +import tokens from './routes/tokens' import pages from './pages' import adapters from '../adapters' +import logger from '../lib/logger' -const DEFAULT_SITE = 'http://localhost:3000' -const DEFAULT_BASE_PATH = '/api/auth' +// To work properly in production with OAuth providers the NEXTAUTH_URL +// environment variable must be set. +if (!process.env.NEXTAUTH_URL) { + logger.warn('NEXTAUTH_URL', 'NEXTAUTH_URL environment variable not set') +} export default async (req, res, userSuppliedOptions) => { // To the best of my knowledge, we need to return a promise here @@ -39,10 +45,10 @@ export default async (req, res, userSuppliedOptions) => { csrfToken: csrfTokenFromPost } = body - // Allow site name, path prefix to be overriden - const site = userSuppliedOptions.site || DEFAULT_SITE - const basePath = userSuppliedOptions.basePath || DEFAULT_BASE_PATH - const baseUrl = `${site}${basePath}` + // @todo refactor all existing references to site, baseUrl and basePath + const parsedUrl = parseUrl(process.env.NEXTAUTH_URL || process.env.VERCEL_URL) + const baseUrl = parsedUrl.baseUrl + const basePath = parsedUrl.basePath // Parse database / adapter let adapter @@ -116,6 +122,7 @@ export default async (req, res, userSuppliedOptions) => { const jwtOptions = { secret, key: secret, + encryption: 'AES', // One of [ 'AES', false ] (default 'AES') encode: jwt.encode, decode: jwt.decode, ...userSuppliedOptions.jwt @@ -171,6 +178,20 @@ export default async (req, res, userSuppliedOptions) => { cookie.set(res, cookies.csrfToken.name, newCsrfTokenCookie, cookies.csrfToken.options) } + // Helper method for handling redirects, this is passed to all routes + // @TODO Refactor into a lib instead of passing as an option + // e.g. and call as redirect(req, res, url) + const redirect = (redirectUrl) => { + const reponseAsJson = !!((req.body && req.body.json === 'true')) + if (reponseAsJson) { + res.json({ url: redirectUrl }) + } else { + res.status(302).setHeader('Location', redirectUrl) + res.end() + } + return done() + } + // User provided options are overriden by other options, // except for the options with special handling above const options = { @@ -182,21 +203,20 @@ export default async (req, res, userSuppliedOptions) => { // These computed settings can values in userSuppliedOptions but override them // and are request-specific. adapter, - site, - basePath, baseUrl, + basePath, action, provider, cookies, secret, csrfToken, - csrfTokenVerified, - providers: parseProviders(userSuppliedOptions.providers, baseUrl), + providers: parseProviders(userSuppliedOptions.providers, baseUrl, basePath), session: sessionOption, jwt: jwtOptions, events: eventsOption, callbacks: callbacksOption, - callbackUrl: site + callbackUrl: baseUrl, + redirect } // If debug enabled, set ENV VAR so that logger logs debug messages @@ -205,12 +225,6 @@ export default async (req, res, userSuppliedOptions) => { // Get / Set callback URL based on query param / cookie + validation options.callbackUrl = await callbackUrlHandler(req, res, options) - const redirect = (redirectUrl) => { - res.status(302).setHeader('Location', redirectUrl) - res.end() - return done() - } - if (req.method === 'GET') { switch (action) { case 'providers': @@ -219,22 +233,21 @@ export default async (req, res, userSuppliedOptions) => { case 'session': session(req, res, options, done) break + case 'tokens': + tokens(req, res, options, done) + break case 'csrf': res.json({ csrfToken }) return done() case 'signin': - if (provider && options.providers[provider]) { - signin(req, res, options, done) - } else { - if (options.pages.signin) { return redirect(`${options.pages.signin}${options.pages.signin.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`) } + if (options.pages.signIn) { return redirect(`${options.pages.signIn}${options.pages.signIn.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`) } - pages.render(req, res, 'signin', { site, providers: Object.values(options.providers), callbackUrl: options.callbackUrl, csrfToken }, done) - } + pages.render(req, res, 'signin', { baseUrl, basePath, providers: Object.values(options.providers), callbackUrl: options.callbackUrl, csrfToken }, done) break case 'signout': - if (options.pages.signout) { return redirect(`${options.pages.signout}${options.pages.signout.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`) } + if (options.pages.signOut) { return redirect(`${options.pages.signOut}${options.pages.signOut.includes('?') ? '&' : '?'}callbackUrl=${options.callbackUrl}`) } - pages.render(req, res, 'signout', { site, baseUrl, csrfToken, callbackUrl: options.callbackUrl }, done) + pages.render(req, res, 'signout', { baseUrl, basePath, csrfToken, callbackUrl: options.callbackUrl }, done) break case 'callback': if (provider && options.providers[provider]) { @@ -247,12 +260,12 @@ export default async (req, res, userSuppliedOptions) => { case 'verify-request': if (options.pages.verifyRequest) { return redirect(options.pages.verifyRequest) } - pages.render(req, res, 'verify-request', { site }, done) + pages.render(req, res, 'verify-request', { baseUrl }, done) break case 'error': if (options.pages.error) { return redirect(`${options.pages.error}${options.pages.error.includes('?') ? '&' : '?'}error=${error}`) } - pages.render(req, res, 'error', { site, error, baseUrl }, done) + pages.render(req, res, 'error', { baseUrl, basePath, error }, done) break default: res.status(404).end() @@ -261,17 +274,30 @@ export default async (req, res, userSuppliedOptions) => { } else if (req.method === 'POST') { switch (action) { case 'signin': - // Signin POST requests are used for email sign in + // Verified CSRF Token required for all sign in routes + if (!csrfTokenVerified) { + return redirect(`${baseUrl}${basePath}/signin?csrf=true`) + } + if (provider && options.providers[provider]) { signin(req, res, options, done) - break } break case 'signout': + // Verified CSRF Token required for signout + if (!csrfTokenVerified) { + return redirect(`${baseUrl}${basePath}/signout?csrf=true`) + } + signout(req, res, options, done) break case 'callback': if (provider && options.providers[provider]) { + // Verified CSRF Token required for credentials providers only + if (options.providers[provider].type === 'credentials' && !csrfTokenVerified) { + return redirect(`${baseUrl}${basePath}/signin?csrf=true`) + } + callback(req, res, options, done) } else { res.status(400).end(`Error: HTTP POST is not supported for ${url}`) diff --git a/src/server/lib/callback-url-handler.js b/src/server/lib/callback-url-handler.js index 8a6ca986c2..7bb6c6fddf 100644 --- a/src/server/lib/callback-url-handler.js +++ b/src/server/lib/callback-url-handler.js @@ -3,21 +3,21 @@ import cookie from '../lib/cookie' export default async (req, res, options) => { const { query } = req const { body } = req - const { cookies, site, defaultCallbackUrl, callbacks } = options + const { cookies, baseUrl, defaultCallbackUrl, callbacks } = options // Handle preserving and validating callback URLs // If no defaultCallbackUrl option specified, default to the homepage for the site - let callbackUrl = defaultCallbackUrl || site + let callbackUrl = defaultCallbackUrl || baseUrl // Try reading callbackUrlParamValue from request body (form submission) then from query param (get request) const callbackUrlParamValue = body.callbackUrl || query.callbackUrl || null const callbackUrlCookieValue = req.cookies[cookies.callbackUrl.name] || null if (callbackUrlParamValue) { // If callbackUrl form field or query parameter is passed try to use it if allowed - callbackUrl = await callbacks.redirect(callbackUrlParamValue, site) + callbackUrl = await callbacks.redirect(callbackUrlParamValue, baseUrl) } else if (callbackUrlCookieValue) { // If no callbackUrl specified, try using the value from the cookie if allowed - callbackUrl = await callbacks.redirect(callbackUrlCookieValue, site) + callbackUrl = await callbacks.redirect(callbackUrlCookieValue, baseUrl) } // Save callback URL in a cookie so that can be used for subsequent requests in signin/signout/callback flow diff --git a/src/server/lib/callbacks.js b/src/server/lib/callbacks.js index ec4e79aa38..4c760c20b6 100644 --- a/src/server/lib/callbacks.js +++ b/src/server/lib/callbacks.js @@ -1,5 +1,5 @@ /** - * Use the signin callback to control if a user is allowed to sign in or not. + * Use the signIn callback to control if a user is allowed to sign in or not. * * This is triggered before sign in flow completes, so the user profile may be * a user object (with an ID) or it may be just their name and email address, @@ -15,7 +15,7 @@ * @return {boolean|object} Return `true` (or a modified JWT) to allow sign in * Return `false` to deny access */ -const signin = async (profile, account, metadata) => { +const signIn = async (profile, account, metadata) => { const isAllowedToSignIn = true if (isAllowedToSignIn) { return Promise.resolve(true) @@ -68,7 +68,7 @@ const jwt = async (token, oAuthProfile) => { } export default { - signin, + signIn, redirect, session, jwt diff --git a/src/server/lib/cookie.js b/src/server/lib/cookie.js index bdc63dc054..078aaca3f3 100644 --- a/src/server/lib/cookie.js +++ b/src/server/lib/cookie.js @@ -15,7 +15,9 @@ const set = (res, name, value, options = {}) => { } // Preserve any existing cookies that have already been set in the same session - const setCookieHeader = res.getHeader('Set-Cookie') || [] + let setCookieHeader = res.getHeader('Set-Cookie') || [] + // If not an array (i.e. a string with a single cookie) convert it into an array + if (!Array.isArray(setCookieHeader)) { setCookieHeader = [setCookieHeader] } setCookieHeader.push(_serialize(name, String(stringValue), options)) res.setHeader('Set-Cookie', setCookieHeader) } diff --git a/src/server/lib/events.js b/src/server/lib/events.js index a95172d5d0..6b53a4a0d3 100644 --- a/src/server/lib/events.js +++ b/src/server/lib/events.js @@ -1,9 +1,9 @@ -const signin = async (message) => { +const signIn = async (message) => { // Event triggered on successful sign in } -const signout = async (message) => { - // Event triggered on signout +const signOut = async (message) => { + // Event triggered on sign out } const createUser = async (message) => { @@ -28,8 +28,8 @@ const error = async (message) => { } export default { - signin, - signout, + signIn, + signOut, createUser, updateUser, linkAccount, diff --git a/src/server/lib/oauth/callback.js b/src/server/lib/oauth/callback.js index aa2cb23742..474efd4c74 100644 --- a/src/server/lib/oauth/callback.js +++ b/src/server/lib/oauth/callback.js @@ -1,6 +1,8 @@ -import oAuthClient from './client' + +import { createHash } from 'crypto' import querystring from 'querystring' import jwtDecode from 'jwt-decode' +import oAuthClient from './client' import logger from '../../../lib/logger' // @TODO Refactor monkey patching in _getOAuthAccessToken() and _get() @@ -10,18 +12,37 @@ import logger from '../../../lib/logger' // come up, as the node-oauth package does not seem to be actively maintained. // @TODO Refactor to use promises and not callbacks - // @TODO Refactor to use jsonwebtoken instead of jwt-decode & remove dependancy - -export default async (req, provider, callback) => { - let { oauth_token, oauth_verifier, code } = req.query // eslint-disable-line camelcase +export default async (req, provider, csrfToken, callback) => { + // The "user" object is specific to apple provider and is provided on first sign in + // e.g. {"name":{"firstName":"Johnny","lastName":"Appleseed"},"email":"johnny.appleseed@nextauth.com"} + let { oauth_token, oauth_verifier, code, user, state } = req.query // eslint-disable-line camelcase const client = oAuthClient(provider) if (provider.version && provider.version.startsWith('2.')) { + // For OAuth 2.0 flows, check state returned and matches expected value + // (a hash of the NextAuth.js CSRF token). + // + // This check can be disabled for providers that do not support it by + // setting `state: false` as a option on the provider (defaults to true). + if (!Object.prototype.hasOwnProperty.call(provider, 'state') || provider.state === true) { + const expectedState = createHash('sha256').update(csrfToken).digest('hex') + if (state !== expectedState) { + return callback(new Error('Invalid state returned from oAuth provider')) + } + } + if (req.method === 'POST') { - // Get the CODE from Body - const body = JSON.parse(JSON.stringify(req.body)) - code = body.code + try { + const body = JSON.parse(JSON.stringify(req.body)) + if (body.error) { throw new Error(body.error) } + + code = body.code + user = body.user != null ? JSON.parse(body.user) : null + } catch (e) { + logger.error('OAUTH_CALLBACK_HANDLER_ERROR', e, req.body, provider.id, code) + return callback() + } } // Pass authToken in header by default (unless 'useAuthTokenHeader: false' is set) @@ -38,12 +59,22 @@ export default async (req, provider, callback) => { code, provider, (error, accessToken, refreshToken, results) => { - // @TODO Handle error if (error || results.error) { logger.error('OAUTH_GET_ACCESS_TOKEN_ERROR', error, results, provider.id, code) + return callback(error || results.error) } if (provider.idToken) { + // If we don't have an ID Token most likely the user hit a cancel + // button when signing in (or the provider is misconfigured). + // + // Unfortunately, we can't tell which, so we can't treat it as an + // error, so instead we just returning nothing, which will cause the + // user to be redirected back to the sign in page. + if (!results || !results.id_token) { + return callback() + } + // Support services that use OpenID ID Tokens to encode profile data _decodeToken( provider, @@ -51,7 +82,7 @@ export default async (req, provider, callback) => { refreshToken, results.id_token, async (error, profileData) => { - const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider) + const { profile, account, OAuthProfile } = await _getProfile(error, profileData, accessToken, refreshToken, provider, user) callback(error, profile, account, OAuthProfile) } ) @@ -96,7 +127,11 @@ export default async (req, provider, callback) => { } } -async function _getProfile (error, profileData, accessToken, refreshToken, provider) { +/** + * //6/30/2020 @geraldnolan added userData parameter to attach additional data to the profileData object + * Returns profile, raw profile and auth provider details + */ +async function _getProfile (error, profileData, accessToken, refreshToken, provider, userData) { // @TODO Handle error if (error) { logger.error('OAUTH_GET_PROFILE_ERROR', error) @@ -107,6 +142,13 @@ async function _getProfile (error, profileData, accessToken, refreshToken, provi // Convert profileData into an object if it's a string if (typeof profileData === 'string' || profileData instanceof String) { profileData = JSON.parse(profileData) } + // If a user object is supplied (e.g. Apple provider) add it to the profile object + if (userData != null) { + profileData.user = userData + } + + logger.debug('PROFILE_DATA', profileData) + profile = await provider.profile(profileData) } catch (exception) { // If we didn't get a response either there was a problem with the provider diff --git a/src/server/lib/providers.js b/src/server/lib/providers.js index 9ea308cca2..20b28aa96d 100644 --- a/src/server/lib/providers.js +++ b/src/server/lib/providers.js @@ -1,12 +1,12 @@ -export default (_providers, baseUrl) => { +export default (_providers, baseUrl, basePath) => { const providers = {} _providers.forEach(provider => { const providerId = provider.id providers[providerId] = { ...provider, - signinUrl: `${baseUrl}/signin/${providerId}`, - callbackUrl: `${baseUrl}/callback/${providerId}` + signinUrl: `${baseUrl}${basePath}/signin/${providerId}`, + callbackUrl: `${baseUrl}${basePath}/callback/${providerId}` } }) diff --git a/src/server/lib/signin/email.js b/src/server/lib/signin/email.js index cfff245b5b..da4563c8f7 100644 --- a/src/server/lib/signin/email.js +++ b/src/server/lib/signin/email.js @@ -2,7 +2,7 @@ import { randomBytes } from 'crypto' export default async (email, provider, options) => { try { - const { baseUrl, adapter } = options + const { baseUrl, basePath, adapter } = options const { createVerificationRequest } = await adapter.getAdapter(options) @@ -13,7 +13,7 @@ export default async (email, provider, options) => { const token = randomBytes(32).toString('hex') // Send email with link containing token (the unhashed version) - const url = `${baseUrl}/callback/${encodeURIComponent(provider.id)}?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}` + const url = `${baseUrl}${basePath}/callback/${encodeURIComponent(provider.id)}?email=${encodeURIComponent(email)}&token=${encodeURIComponent(token)}` // @TODO Create invite (send secret so can be hashed) await createVerificationRequest(email, url, token, secret, provider, options) diff --git a/src/server/lib/signin/oauth.js b/src/server/lib/signin/oauth.js index b43d257178..b97ef74261 100644 --- a/src/server/lib/signin/oauth.js +++ b/src/server/lib/signin/oauth.js @@ -1,8 +1,8 @@ import oAuthClient from '../oauth/client' -import crypto from 'crypto' +import { createHash } from 'crypto' import logger from '../../../lib/logger' -export default (provider, callback) => { +export default (provider, csrfToken, callback) => { const { callbackUrl } = provider const client = oAuthClient(provider) if (provider.version && provider.version.startsWith('2.')) { @@ -10,7 +10,8 @@ export default (provider, callback) => { let url = client.getAuthorizeUrl({ redirect_uri: provider.callbackUrl, scope: provider.scope, - state: crypto.randomBytes(64).toString('hex') + // A hash of the NextAuth.js CSRF token is used as the state + state: createHash('sha256').update(csrfToken).digest('hex') }) // If the authorizationUrl specified in the config has query parameters on it diff --git a/src/server/pages/error.js b/src/server/pages/error.js index a04b691900..ad263c1628 100644 --- a/src/server/pages/error.js +++ b/src/server/pages/error.js @@ -1,12 +1,12 @@ import { h } from 'preact' // eslint-disable-line no-unused-vars import render from 'preact-render-to-string' -export default ({ site, error, baseUrl, res }) => { - const signinPageUrl = `${baseUrl}/signin` // @TODO Make sign in URL configurable +export default ({ baseUrl, basePath, error, res }) => { + const signinPageUrl = `${baseUrl}${basePath}/signin` // @TODO Make sign in URL configurable let statusCode = 200 let heading =

Error

- let message =

{site.replace(/^https?:\/\//, '')}

+ let message =

{baseUrl.replace(/^https?:\/\//, '')}

switch (error) { case 'Signin': diff --git a/src/server/pages/signin.js b/src/server/pages/signin.js index bb934ee943..9bea8f6dfa 100644 --- a/src/server/pages/signin.js +++ b/src/server/pages/signin.js @@ -2,7 +2,6 @@ import { h } from 'preact' // eslint-disable-line no-unused-vars import render from 'preact-render-to-string' export default ({ req, csrfToken, providers, callbackUrl }) => { - const withCallbackUrl = callbackUrl ? `?callbackUrl=${callbackUrl}` : '' const { email } = req.query // We only want to render providers @@ -24,7 +23,11 @@ export default ({ req, csrfToken, providers, callbackUrl }) => { {providersToRender.map((provider, i) =>
{provider.type === 'oauth' && - Sign in with {provider.name}} +
+ + {callbackUrl && } + +
} {(provider.type === 'email' || provider.type === 'credentials') && (i > 0) && providersToRender[i - 1].type !== 'email' && providersToRender[i - 1].type !== 'credentials' &&
} diff --git a/src/server/pages/signout.js b/src/server/pages/signout.js index 89a9663f66..5047b0587c 100644 --- a/src/server/pages/signout.js +++ b/src/server/pages/signout.js @@ -1,11 +1,11 @@ import { h } from 'preact' // eslint-disable-line no-unused-vars import render from 'preact-render-to-string' -export default ({ baseUrl, csrfToken }) => { +export default ({ baseUrl, basePath, csrfToken }) => { return render(

Are you sure you want to sign out?

-
+
diff --git a/src/server/pages/verify-request.js b/src/server/pages/verify-request.js index 848aae909a..6c9f50e776 100644 --- a/src/server/pages/verify-request.js +++ b/src/server/pages/verify-request.js @@ -1,12 +1,12 @@ import { h } from 'preact' // eslint-disable-line no-unused-vars import render from 'preact-render-to-string' -export default ({ site }) => { +export default ({ baseUrl }) => { return render(

Check your email

A sign in link has been sent to your email address.

-

{site.replace(/^https?:\/\//, '')}

+

{baseUrl.replace(/^https?:\/\//, '')}

) } diff --git a/src/server/routes/callback.js b/src/server/routes/callback.js index f5555d12e8..0ec1c2af54 100644 --- a/src/server/routes/callback.js +++ b/src/server/routes/callback.js @@ -10,15 +10,17 @@ export default async (req, res, options, done) => { provider: providerName, providers, adapter, - site, - secret, baseUrl, + basePath, + secret, cookies, callbackUrl, pages, jwt, events, - callbacks + callbacks, + csrfToken, + redirect } = options const provider = providers[providerName] const { type } = provider @@ -30,13 +32,11 @@ export default async (req, res, options, done) => { if (type === 'oauth') { try { - oAuthCallback(req, provider, async (error, profile, account, OAuthProfile) => { + oAuthCallback(req, provider, csrfToken, async (error, profile, account, OAuthProfile) => { try { if (error) { logger.error('CALLBACK_OAUTH_ERROR', error) - res.status(302).setHeader('Location', `${baseUrl}/error?error=oAuthCallback`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=oAuthCallback`) } // Make it easier to debug when adding a new provider @@ -51,18 +51,14 @@ export default async (req, res, options, done) => { // should at least be visible to developers what happened if it is an // error with the provider. if (!profile) { - res.status(302).setHeader('Location', `${baseUrl}/signin`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/signin`) } // Check if user is allowed to sign in - const signinCallbackResponse = await callbacks.signin(profile, account, OAuthProfile) + const signInCallbackResponse = await callbacks.signIn(profile, account, OAuthProfile) - if (signinCallbackResponse === false) { - res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`) - res.end() - return done() + if (signInCallbackResponse === false) { + return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`) } // Sign user in @@ -73,7 +69,7 @@ export default async (req, res, options, done) => { const jwtPayload = await callbacks.jwt(defaultJwtPayload, OAuthProfile) // Sign and encrypt token - const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge }) + const newEncodedJwt = await jwt.encode({ secret: jwt.secret, key: jwt.key, token: jwtPayload, maxAge: sessionMaxAge, encryption: jwt.encryption }) // Set cookie expiry date const cookieExpires = new Date() @@ -85,48 +81,38 @@ export default async (req, res, options, done) => { cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options }) } - await dispatchEvent(events.signin, { user, account, isNewUser }) + await dispatchEvent(events.signIn, { user, account, isNewUser }) // Handle first logins on new accounts // e.g. option to send users to a new account landing page on initial login // Note that the callback URL is preserved, so the journey can still be resumed if (isNewUser && pages.newUser) { - res.status(302).setHeader('Location', pages.newUser) - res.end() - return done() + return redirect(pages.newUser) } // Callback URL is already verified at this point, so safe to use if specified - res.status(302).setHeader('Location', callbackUrl || site) - res.end() - return done() + return redirect(callbackUrl || baseUrl) } catch (error) { if (error.name === 'AccountNotLinkedError') { // If the email on the account is already linked, but nto with this oAuth account - res.status(302).setHeader('Location', `${baseUrl}/error?error=OAuthAccountNotLinked`) + return redirect(`${baseUrl}${basePath}/error?error=OAuthAccountNotLinked`) } else if (error.name === 'CreateUserError') { - res.status(302).setHeader('Location', `${baseUrl}/error?error=OAuthCreateAccount`) + return redirect(`${baseUrl}${basePath}/error?error=OAuthCreateAccount`) } else { logger.error('OAUTH_CALLBACK_HANDLER_ERROR', error) - res.status(302).setHeader('Location', `${baseUrl}/error?error=Callback`) + return redirect(`${baseUrl}${basePath}/error?error=Callback`) } - res.end() - return done() } }) } catch (error) { logger.error('OAUTH_CALLBACK_ERROR', error) - res.status(302).setHeader('Location', `${baseUrl}/error?error=Callback`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=Callback`) } } else if (type === 'email') { try { if (!adapter) { logger.error('EMAIL_REQUIRES_ADAPTER_ERROR') - res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=Configuration`) } const { getVerificationRequest, deleteVerificationRequest, getUserByEmail } = await adapter.getAdapter(options) @@ -136,9 +122,7 @@ export default async (req, res, options, done) => { // Verify email and verification token exist in database const invite = await getVerificationRequest(email, verificationToken, secret, provider) if (!invite) { - res.status(302).setHeader('Location', `${baseUrl}/error?error=Verification`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=Verification`) } // If verification token is valid, delete verification request token from @@ -150,12 +134,10 @@ export default async (req, res, options, done) => { const account = { id: provider.id, type: 'email', providerAccountId: email } // Check if user is allowed to sign in - const signinCallbackResponse = await callbacks.signin(profile, account, null) + const signInCallbackResponse = await callbacks.signIn(profile, account, null) - if (signinCallbackResponse === false) { - res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`) - res.end() - return done() + if (signInCallbackResponse === false) { + return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`) } // Sign user in @@ -166,7 +148,7 @@ export default async (req, res, options, done) => { const jwtPayload = await callbacks.jwt(defaultJwtPayload) // Sign and encrypt token - const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge }) + const newEncodedJwt = await jwt.encode({ secret: jwt.secret, key: jwt.key, token: jwtPayload, maxAge: sessionMaxAge, encryption: jwt.encryption }) // Set cookie expiry date const cookieExpires = new Date() @@ -178,49 +160,38 @@ export default async (req, res, options, done) => { cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options }) } - await dispatchEvent(events.signin, { user, account, isNewUser }) + await dispatchEvent(events.signIn, { user, account, isNewUser }) // Handle first logins on new accounts // e.g. option to send users to a new account landing page on initial login // Note that the callback URL is preserved, so the journey can still be resumed if (isNewUser && pages.newUser) { - res.status(302).setHeader('Location', pages.newUser) - res.end() - return done() + return redirect(pages.newUser) } // Callback URL is already verified at this point, so safe to use if specified if (callbackUrl) { - res.status(302).setHeader('Location', callbackUrl) - res.end() + return redirect(callbackUrl) } else { - res.status(302).setHeader('Location', site) - res.end() + return redirect(baseUrl) } - return done() } catch (error) { if (error.name === 'CreateUserError') { - res.status(302).setHeader('Location', `${baseUrl}/error?error=EmailCreateAccount`) + return redirect(`${baseUrl}${basePath}/error?error=EmailCreateAccount`) } else { - res.status(302).setHeader('Location', `${baseUrl}/error?error=Callback`) logger.error('CALLBACK_EMAIL_ERROR', error) + return redirect(`${baseUrl}${basePath}/error?error=Callback`) } - res.end() - return done() } } else if (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') - res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=Configuration`) } if (!provider.authorize) { logger.error('CALLBACK_CREDENTIALS_HANDLER_ERROR', 'Must define an authorize() handler to use credentials authentication provider') - res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=Configuration`) } const credentials = req.body @@ -230,9 +201,7 @@ export default async (req, res, options, done) => { try { userObjectReturnedFromAuthorizeHandler = await provider.authorize(credentials) } catch (error) { - res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=Configuration`) } const user = userObjectReturnedFromAuthorizeHandler @@ -240,24 +209,20 @@ export default async (req, res, options, done) => { // If no user is returned, credentials are not valid if (!user) { - res.status(302).setHeader('Location', `${baseUrl}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=CredentialsSignin&provider=${encodeURIComponent(provider.id)}`) } - const signinCallbackResponse = await callbacks.signin(user, account, credentials) + const signInCallbackResponse = await callbacks.signIn(user, account, credentials) - if (signinCallbackResponse === false) { - res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`) - res.end() - return done() + if (signInCallbackResponse === false) { + return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`) } const defaultJwtPayload = { user, account } const jwtPayload = await callbacks.jwt(defaultJwtPayload) // Sign and encrypt token - const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge }) + const newEncodedJwt = await jwt.encode({ secret: jwt.secret, key: jwt.key, token: jwtPayload, maxAge: sessionMaxAge, encryption: jwt.encryption }) // Set cookie expiry date const cookieExpires = new Date() @@ -265,17 +230,9 @@ export default async (req, res, options, done) => { cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options }) - await dispatchEvent(events.signin, { user, account }) + await dispatchEvent(events.signIn, { user, account }) - if (callbackUrl) { - res.status(302).setHeader('Location', callbackUrl) - res.end() - } else { - res.status(302).setHeader('Location', site) - res.end() - } - - return done() + return redirect(callbackUrl || baseUrl) } else { res.status(500).end(`Error: Callback for provider type ${type} not supported`) return done() diff --git a/src/server/routes/session.js b/src/server/routes/session.js index 807d9e2923..7f91c612a0 100644 --- a/src/server/routes/session.js +++ b/src/server/routes/session.js @@ -19,7 +19,7 @@ export default async (req, res, options, done) => { if (useJwtSession) { try { // Decrypt and verify token - const decodedJwt = await jwt.decode({ secret: jwt.secret, token: sessionToken, maxAge: sessionMaxAge }) + const decodedJwt = await jwt.decode({ secret: jwt.secret, key: jwt.key, token: sessionToken, maxAge: sessionMaxAge, encryption: jwt.encryption }) // Generate new session expiry date const sessionExpiresDate = new Date() @@ -45,7 +45,7 @@ export default async (req, res, options, done) => { response = sessionPayload // Refresh JWT expiry by re-signing it, with an updated expiry date - const newEncodedJwt = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge }) + const newEncodedJwt = await jwt.encode({ secret: jwt.secret, key: jwt.key, token: jwtPayload, maxAge: sessionMaxAge, encryption: jwt.encryption }) // Set cookie, to also update expiry date on cookie cookie.set(res, cookies.sessionToken.name, newEncodedJwt, { expires: sessionExpires, ...cookies.sessionToken.options }) diff --git a/src/server/routes/signin.js b/src/server/routes/signin.js index 9eb32b7165..d6da8378b7 100644 --- a/src/server/routes/signin.js +++ b/src/server/routes/signin.js @@ -8,9 +8,11 @@ export default async (req, res, options, done) => { provider: providerName, providers, baseUrl, - csrfTokenVerified, + basePath, adapter, - callbacks + callbacks, + csrfToken, + redirect } = options const provider = providers[providerName] const { type } = provider @@ -20,29 +22,19 @@ export default async (req, res, options, done) => { return done() } - if (type === 'oauth') { - oAuthSignin(provider, (error, oAuthSigninUrl) => { + if (type === 'oauth' && req.method === 'POST') { + oAuthSignin(provider, csrfToken, (error, oAuthSigninUrl) => { if (error) { logger.error('SIGNIN_OAUTH_ERROR', error) - res - .status(302) - .setHeader('Location', `${baseUrl}/error?error=oAuthSignin`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=oAuthSignin`) } - res.status(302).setHeader('Location', oAuthSigninUrl) - res.end() - return done() + return redirect(oAuthSigninUrl) }) } else if (type === 'email' && req.method === 'POST') { if (!adapter) { logger.error('EMAIL_REQUIRES_ADAPTER_ERROR') - res - .status(302) - .setHeader('Location', `${baseUrl}/error?error=Configuration`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=Configuration`) } const { getUserByEmail } = await adapter.getAdapter(options) @@ -58,54 +50,23 @@ export default async (req, res, options, done) => { const account = { id: provider.id, type: 'email', providerAccountId: email } // Check if user is allowed to sign in - const signinCallbackResponse = await callbacks.signin(profile, account) + const signinCallbackResponse = await callbacks.signIn(profile, account) if (signinCallbackResponse === false) { - res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`) - res.end() - return done() - } - - // If CSRF token not verified, send the user to sign in page, which will - // display a new form with a valid token so that submitting it should work. - // - // Note: Adds ?csrf=true query string param to URL for debugging/tracking - if (!csrfTokenVerified) { - res - .status(302) - .setHeader( - 'Location', - `${baseUrl}/signin?email=${encodeURIComponent(email)}&csrf=true` - ) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=AccessDenied`) } try { await emailSignin(email, provider, options) } catch (error) { logger.error('SIGNIN_EMAIL_ERROR', error) - res - .status(302) - .setHeader('Location', `${baseUrl}/error?error=EmailSignin`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/error?error=EmailSignin`) } - res - .status(302) - .setHeader( - 'Location', - `${baseUrl}/verify-request?provider=${encodeURIComponent( - provider.id - )}&type=${encodeURIComponent(provider.type)}` - ) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/verify-request?provider=${encodeURIComponent( + provider.id + )}&type=${encodeURIComponent(provider.type)}`) } else { - // If provider not supported, redirect to sign in page - res.status(302).setHeader('Location', `${baseUrl}/signin`) - res.end() - return done() + return redirect(`${baseUrl}${basePath}/signin`) } } diff --git a/src/server/routes/signout.js b/src/server/routes/signout.js index 868e10108d..1fbc88c4b1 100644 --- a/src/server/routes/signout.js +++ b/src/server/routes/signout.js @@ -4,28 +4,16 @@ import logger from '../../lib/logger' import dispatchEvent from '../lib/dispatch-event' export default async (req, res, options, done) => { - const { adapter, cookies, events, jwt, callbackUrl, csrfTokenVerified, baseUrl } = options + const { adapter, cookies, events, jwt, callbackUrl, redirect } = options const sessionMaxAge = options.session.maxAge const useJwtSession = options.session.jwt const sessionToken = req.cookies[cookies.sessionToken.name] - if (!csrfTokenVerified) { - // If a csrfToken was not verified with this request, send the user to - // the signout page, as they should have a valid one now and clicking - // the signout button should work. - // - // Note: Adds ?csrf=true query string param to URL for debugging/tracking. - // @TODO Add support for custom signin URLs - res.status(302).setHeader('Location', `${baseUrl}/signout?csrf=true`) - res.end() - return done() - } - if (useJwtSession) { // Dispatch signout event try { - const decodedJwt = await jwt.decode({ secret: jwt.secret, token: sessionToken, maxAge: sessionMaxAge }) - await dispatchEvent(events.signout, decodedJwt) + const decodedJwt = await jwt.decode({ secret: jwt.secret, key: jwt.key, token: sessionToken, maxAge: sessionMaxAge, encryption: jwt.encryption }) + await dispatchEvent(events.signOut, decodedJwt) } catch (error) { // Do nothing if decoding the JWT fails } @@ -36,7 +24,7 @@ export default async (req, res, options, done) => { try { // Dispatch signout event const session = await getSession(sessionToken) - await dispatchEvent(events.signout, session) + await dispatchEvent(events.signOut, session) } catch (error) { // Do nothing if looking up the session fails } @@ -56,7 +44,5 @@ export default async (req, res, options, done) => { maxAge: 0 }) - res.status(302).setHeader('Location', callbackUrl) - res.end() - return done() + return redirect(callbackUrl) } diff --git a/src/server/routes/tokens.js b/src/server/routes/tokens.js new file mode 100644 index 0000000000..5efc9438e5 --- /dev/null +++ b/src/server/routes/tokens.js @@ -0,0 +1,74 @@ +import cookie from '../lib/cookie' +import logger from '../../lib/logger' + +export default async (req, res, options, done) => { + const { query } = req + const { + nextauth, + tokenType = nextauth[2] + } = query + const providerName = options ? options.provider : undefined + + const { cookies, adapter } = options + const useJwtSession = options.session.jwt + const sessionToken = req.cookies[cookies.sessionToken.name] + + if (!sessionToken) { + res.setHeader('Content-Type', 'application/json') + res.json({}) + return done() + } + + let response = {} + if (!useJwtSession) { + if (req.method === 'GET') { + try { + const { getSession, updateSession, getAccounts, getAccount } = await adapter.getAdapter(options) + const session = await getSession(sessionToken) + if (session) { + // Trigger update to session object to update session expiry + await updateSession(session) + + if (!providerName) { + response = await getAccounts(session.userId) + } else { + const account = await getAccount(session.userId, providerName) + if (account) { + switch (tokenType) { + case undefined: + case 'access': + response = { + type: 'access', + token: account.accessToken + } + break + case 'refresh': + response = { + type: 'refresh', + token: account.refreshToken + } + break + default: + res.status(404).end() + return done() + } + } else { + res.status(404).end() + return done() + } + } + } else if (sessionToken) { + // If sessionToken was found set but it's not valid for a session then + // remove the sessionToken cookie from browser. + cookie.set(res, cookies.sessionToken.name, '', { ...cookies.sessionToken.options, maxAge: 0 }) + } + } catch (error) { + logger.error('TOKEN_ERROR', error) + } + } + } + + res.setHeader('Content-Type', 'application/json') + res.json(response) + return done() +} diff --git a/www/docs/configuration/callbacks.md b/www/docs/configuration/callbacks.md index 19cbd31f5b..765c4b942e 100644 --- a/www/docs/configuration/callbacks.md +++ b/www/docs/configuration/callbacks.md @@ -13,62 +13,61 @@ You can specify a handler for any of the callbacks below. ```js title="pages/api/auth/[...nextauth.js]" callbacks: { - signin: async (profile, account, metadata) => { }, + signIn: async (profile, account, metadata) => { }, redirect: async (url, baseUrl) => { }, session: async (session, token) => { }, - jwt: async (token, oAuthProfile) => { } + jwt: async (token, profile) => { } } ``` The documentation below shows how to implement each callback and their default behaviour. -## Signin +## Sign In Use the signin callback to control if a user is allowed to sign in or not. -This is triggered before sign in flow completes, so the user profile may be a -user object (with an ID) or it may be just their name and email address, -depending on the sign in flow and if they have an account already. - -When using email sign in, this method is triggered both when the user requests -to sign in and again when they activate the link in the sign in email. - -```js -/** - * @param {object} profile User profile (e.g. user id, name, email) - * @param {object} account Account used to sign in (e.g. OAuth account) - * @param {object} metadata Provider specific metadata (e.g. OAuth Profile) - * @return {boolean|object} Return `true` (or a modified JWT) to allow sign in - * Return `false` to deny access - */ -const signin = async (profile, account, metadata) => { - const isAllowedToSignIn = true - if (isAllowedToSignIn) { - return Promise.resolve(true) - } else { - return Promise.resolve(false) +This is triggered before sign in flow completes, so the user profile may be a user object (with an ID) or it may be just their name and email address, depending on the sign in flow and if they have an account already. + +When using email sign in, this method is triggered both when the user requests to sign in and again when they activate the link in the sign in email. + +```js title="pages/api/auth/[...nextauth.js]" +callbacks: { + /** + * @param {object} profile User profile (e.g. user id, name, email) + * @param {object} account Account used to sign in (e.g. OAuth account) + * @param {object} metadata Provider specific metadata (e.g. OAuth Profile) + * @return {boolean|object} Return `true` (or a modified JWT) to allow sign in + * Return `false` to deny access + */ + signIn: async (profile, account, metadata) => { + const isAllowedToSignIn = true + if (isAllowedToSignIn) { + return Promise.resolve(true) + } else { + return Promise.resolve(false) + } } } ``` ## Redirect -The redirect callback is called anytime the user is redirected to a callback URL -(e.g. on signin or signout). - -By default, for security, only Callback URLs on the same URL as the site are -allowed, you can use the redirect callback to customise that behaviour. - -```js -/** - * @param {string} url URL provided as callback URL by the client - * @param {string} baseUrl Default base URL of site (can be used as fallback) - * @return {string} URL the client will be redirect to - */ -const redirect = async (url, baseUrl) => { - return url.startsWith(baseUrl) - ? Promise.resolve(url) - : Promise.resolve(baseUrl) +The redirect callback is called anytime the user is redirected to a callback URL (e.g. on signin or signout). + +By default, for security, only Callback URLs on the same URL as the site are allowed, you can use the redirect callback to customise that behaviour. + +```js title="pages/api/auth/[...nextauth.js]" +callbacks: { + /** + * @param {string} url URL provided as callback URL by the client + * @param {string} baseUrl Default base URL of site (can be used as fallback) + * @return {string} URL the client will be redirect to + */ + redirect: async (url, baseUrl) => { + return url.startsWith(baseUrl) + ? Promise.resolve(url) + : Promise.resolve(baseUrl) + } } ``` @@ -78,20 +77,21 @@ The session callback is called whenever a session is checked. e.g. `getSession()`, `useSession()`, `/api/auth/session` (etc) -If JSON Web Tokens are enabled, you can also access the decrypted token and use -this method to pass information from the encoded token back to the client. +If JSON Web Tokens are enabled, you can also access the decrypted token and use this method to pass information from the encoded token back to the client. The JWT callback is invoked before the session() callback is called, so anything you add to the JWT will be immediately available in the session callback. -```js -/** - * @param {object} session Session object - * @param {object} token JSON Web Token (if enabled) - * @return {object} Session that will be returned to the client - */ -const session = async (session, token) => { - return Promise.resolve(session) +```js title="pages/api/auth/[...nextauth.js]" +callbacks: { + /** + * @param {object} session Session object + * @param {object} token JSON Web Token (if enabled) + * @return {object} Session that will be returned to the client + */ + session: async (session, token) => { + return Promise.resolve(session) + } } ``` @@ -104,16 +104,19 @@ e.g. `/api/auth/signin`, `getSession()`, `useSession()`, `/api/auth/session` (et * The JWT expiry time is updated / extended whenever a session is accessed. -* On sign in, the raw profile object returned by the provider is also passed as a parameter. -It is not available on subsequent calls. You can take advantage of this to persist additional data from the profile in the JWT. - -```js -/** - * @param {object} token Decrypted JSON Web Token - * @param {object} profile Profile - only available on sign in - * @return {object} JSON Web Token that will be saved - */ -const jwt = async (token, profile) => { - return Promise.resolve(token) +* On sign in, the raw profile for a user returned by the provider is also passed as a parameter. + +The raw profile is not available after the initial callback that is triggered when the user signs in. You can take advantage of it beomg present on the first callback, to persist any additional data you need from the users profile in the JWT. + +```js title="pages/api/auth/[...nextauth.js]" +callbacks: { + /** + * @param {object} token Decrypted JSON Web Token + * @param {object} profile Profile - only available on sign in + * @return {object} JSON Web Token that will be saved + */ + jwt: async (token, profile) => { + return Promise.resolve(token) + } } ``` diff --git a/www/docs/configuration/events.md b/www/docs/configuration/events.md index be91d0cbaf..03d713094e 100644 --- a/www/docs/configuration/events.md +++ b/www/docs/configuration/events.md @@ -3,7 +3,7 @@ id: events title: Events --- -Events are asynchronous functions that do not return a response, they are useful for audit logging. +Events are asynchronous functions that do not return a response, they are useful for audit logs / reporting. ### Example @@ -11,8 +11,8 @@ You can specify a handler for any of these events below - e.g. for debugging or ```js title="pages/api/auth/[...nextauth.js]" events: { - signin: async (message) => { /* on successful sign in */ }, - signout: async (message) => { /* on signout */ }, + signIn: async (message) => { /* on successful sign in */ }, + signOut: async (message) => { /* on signout */ }, createUser: async (message) => { /* user created */ }, linkAccount: async (message) => { /* account linked to a user */ }, session: async (message) => { /* session is active */ }, @@ -20,4 +20,4 @@ events: { } ``` -The content of the message object varies depending on the flow (e.g. OAuth or Email authentication flow, JWT or database sessions, etc), but typically contains a user object and/or contents of the JSON Web Token and other information relevent to the event. +The content of the message object varies depending on the flow (e.g. OAuth or Email authentication flow, JWT or database sessions, etc) but typically contains a user object and/or contents of the JSON Web Token and other information relevent to the event. diff --git a/www/docs/configuration/options.md b/www/docs/configuration/options.md index 2500b8f7db..fb10c9a6e9 100644 --- a/www/docs/configuration/options.md +++ b/www/docs/configuration/options.md @@ -3,27 +3,30 @@ id: options title: Options --- -Options are passed to NextAuth.js when initializing it in an API route. +## Environment Variables -:::note -The only *required* options are **site** and **providers**. -::: +### NEXTAUTH_URL -## Options +When deploying to production, set the `NEXTAUTH_URL` environment variable to the canonical URL of your site. -### site +``` +NEXTAUTH_URL=https://example.com +``` -* **Default value**: `empty string` -* **Required**: *Yes* +If your Next.js application uses a custom base path, specify the route to the API endpoint in full. -#### Description +_e.g. `NEXTAUTH_URL=https://example.com/custom-route/api/auth`_ -The fully qualified URL for the root of your site. - -e.g. `http://localhost:3000` or `https://www.example.com` +:::tip +To set environment variables on Vercel, you can use the [dashboard](https://vercel.com/dashboard) or the `now env` command. +::: --- +## Options + +Options are passed to NextAuth.js when initializing it in an API route. + ### providers * **Default value**: `[]` @@ -110,27 +113,28 @@ session: { #### Description -JSON Web Tokens are only used if JWT sessions are enabled with `session: { jwt: true }` (see `session` documentation). +JSON Web Tokens are can be used for session tokens (instead of database sessions) if enabled with `session: { jwt: true }` option is set. They are enabled by default if you have not specified a database. -The `jwt` object and all properties on it are optional. +You can use JSON Web Tokens for session data in conjuction with a database for user data, or you can use JSON Web Tokens without a database. -When enabled, JSON Web Tokens is signed with `HMAC SHA256` and encrypted with symmetric `AES`. - -Using JWT to store sessions is often faster, cheaper and more scaleable relying on a database. - -Default values for this option are shown below: +Options and values for JSON Web Tokens: ```js jwt: { - // secret: 'my-secret-123', // Secret auto-generated if not specified. + // JWT secret and keys use the NextAuth.js secret if not specified + // secret: 'my-secret-123', // Secret used for signing tokens + // key: 'my-key-abc', // Key used for encrypting tokens (defaults to secret) - // By default the JSON Web Token is signed with SHA256 and encrypted with AES. + // Encryption scheme to use when encrypting a token. + // Specify a value of false to sign but not encrypt tokens. + // encryption: 'AES', // One of [ 'AES', false ] (default 'AES') + + // By default tokens are signed with HMAC-SHA256 and encrypted with AES. // // You can define your own encode/decode functions for signing + encryption if - // you want to override the default behaviour (or to add/remove information - // from the JWT when it is encoded). - // encode: async ({ secret, key, token, maxAge }) => {}, - // decode: async ({ secret, key, token, maxAge }) => {}, + // you want to override the default behaviour. + // encode: async ({ secret, key, token, maxAge, encryption }) => {}, + // decode: async ({ secret, key, token, maxAge, encryption }) => {}, } ``` @@ -155,28 +159,45 @@ An example JSON WebToken contains an encrypted payload like this: accessToken: '931400799b4a980715bb55af1bb8e01d92316956', accessTokenExpires: null }, - isNewUser: true, // Is set to true if is first sign in + isNewUser: true, // When using a database, is set to true if first sign in iat: 1591150735, // Issued at exp: 4183150735 // Expires in } ``` -You can use the built-in `getJwt()` helper method to read the token, like this: +#### JWT Helper + +You can use the built-in `getToken()` helper method to verify and decrypt the token, like this: ```js import jwt from 'next-auth/jwt' const secret = process.env.JWT_SECRET -export default async (req, res) => { - // Automatically decrypts and verifies JWT - const token = await jwt.getJwt({ req, secret }) - res.end(JSON.stringify(token, null, 2)) +export default async (req, res) => { + const token = await jwt.getToken({ req, secret }) + console.log('JSON Web Token', token) + res.end() } ``` +The getToken() helper supports the following options: + +* req - The request object (required) +* secret - The secret used for signing tokens (required) +* key - The key used for decrypting tokens (optional) +* encryption - One of [ 'AES', false ] (optional, default 'AES') +* secureCookie - Boolean. Uses secure prefixed cookie if true (optional) +* cookieName - Use custom session token cookie name (optional) + +_For convenience, this helper function is also able to read and decode tokens passed in an HTTP Bearer header._ + +:::note +The helper function will attempt to determine if it should use the secure prefixed cookie automatically (e.g. `true` in production, `false` in development instances). You can set either `secureCookie` explicitly or specify the cookie name explicitly if you need to. +::: + :::note -The JWT is stored in the Session Token cookie – the same cookie used for database sessions. +The JWT is stored in the Session Token cookie, the same cookie used for tokens when using a database session. ::: --- @@ -223,10 +244,10 @@ You can specify a handler for any of the callbacks below. ```js callbacks: { - signin: async (profile, account, metadata) => { }, + signIn: async (profile, account, metadata) => { }, redirect: async (url, baseUrl) => { }, session: async (session, token) => { }, - jwt: async (token) => => { } + jwt: async (token, profile) => => { } } ``` @@ -249,8 +270,8 @@ The content of the message object varies depending on the flow (e.g. OAuth or Em ```js events: { - signin: async (message) => { /* on successful sign in */ }, - signout: async (message) => { /* on signout */ }, + signIn: async (message) => { /* on successful sign in */ }, + signOut: async (message) => { /* on signout */ }, createUser: async (message) => { /* user created */ }, linkAccount: async (message) => { /* account linked to a user */ }, session: async (message) => { /* session is active */ }, @@ -260,61 +281,39 @@ events: { --- -### debug +### adapter -* **Default value**: `false` +* **Default value**: *Adapater.Default()* * **Required**: *No* #### Description -Set debug to `true` to enable debug messages for authentication and database operations. +By default NextAuth.js uses a database adapter that uses TypeORM and supports MySQL, MariaDB, Postgres and MongoDB and SQLite databases. An alternative adapter that uses Prisma, which currently supports MySQL, MariaDB and Postgres, is also included. ---- - -## Advanced Options +You can use the `adapter` option to use the Prisma adapter - or pass in your own adapter if you want to use a database that is not supported by one of the built-in adapters. +See the [adapter documentation](/schemas/adapters) for more information. -:::warning -Advanced options are passed the same way as basic options, but may have complex implications or side effects. You should try to avoid using advanced options unless you are very comfortable using them. +:::note +If the `adapter` option is specified it overrides the `database` option, only specify one or the other. ::: --- -### basePath +### debug -* **Default value**: `/api/auth` +* **Default value**: `false` * **Required**: *No* #### Description -This option allows you to specify a different base path if you don't want to use `/api/auth` for some reason. - -If you set this option you **must** also configure it along with the `site` property in `pages/_app.js` - -```js title="pages/_app.js" -import { config } from 'next-auth/client' -config({ - site: process.env.SITE, // e.g. 'http://localhost:3000' - basePath: process.env.BASE_PATH // e.g. '/api/some-other-route-name' -}) -``` +Set debug to `true` to enable debug messages for authentication and database operations. --- -### adapter - -* **Default value**: *Adapater.Default()* -* **Required**: *No* - -#### Description - -A custom provider is an advanced option intended for use only you need to use NextAuth.js with a database configuration that is not supported by the default `database` adapter. - -See the [adapter documentation](/schemas/adapters) for more information. +## Advanced Options -:::note -If the `adapter` option is specified it overrides the `database` option. -::: +Advanced options are passed the same way as basic options, but may have complex implications or side effects. You should try to avoid using advanced options unless you are very comfortable using them. --- @@ -336,7 +335,7 @@ Properties on any custom `cookies` that are specified override this option. ::: :::warning -Setting this option to *false* in production is a security risk and may allow sessions to hijacked. +Setting this option to *false* in production is a security risk and may allow sessions to hijacked if used in production. It is intended to support development and testing. Using this option is not recommended. ::: --- @@ -388,5 +387,5 @@ cookies: { ``` :::warning -Changing the cookie options may introduce security flaws into your application and may break NextAuth.js integration now or in a future update. Using this option is not recommended. +Using a custom cookie policy may introduce security flaws into your application and is intended as an option for advanced users who understand the implications. Using this option is not recommended. ::: diff --git a/www/docs/configuration/pages.md b/www/docs/configuration/pages.md index 04a9e54629..9659c51a67 100644 --- a/www/docs/configuration/pages.md +++ b/www/docs/configuration/pages.md @@ -7,15 +7,15 @@ NextAuth.js automatically creates simple, unbranded authentication pages for han The options displayed on the sign up page are automatically generated based on the providers specified in the options passed to NextAuth.js. -### Example +### Configuration To add a custom login page, for example. You can use the `pages` option: ```javascript title="pages/api/auth/[...nextauth].js" ... pages: { - signin: '/auth/signin', - signout: '/auth/signout', + signIn: '/auth/signin', + signOut: '/auth/signout', error: '/auth/error', // Error code passed in query string as ?error= verifyRequest: '/auth/verify-request', // (used for check email message) newUser: null // If set, new users will be directed here on first sign in @@ -23,67 +23,46 @@ To add a custom login page, for example. You can use the `pages` option: ... ``` -## Sign in +## Examples -### OAuth sign in page +### OAuth Sign in In order to get the available authentication providers and the URLs to use for them, you can make a request to the API endpoint `/api/auth/providers`: ```jsx title="pages/auth/signin" import React from 'react' -import { providers, signin } from 'next-auth/client' +import { providers, signIn } from 'next-auth/client' export default ({ providers }) => { return ( <> {Object.values(providers).map(provider => ( -

- e.preventDefault()}> - - -

+
+ +
))} ) } -export async function getServerSideProps (context) { +export async function getInitalProps(context) { return { - props: { - providers: await providers(context) - } + providers: await providers(context) } } ``` -:::tip -The **signin()** method automatically sets the callback URL to the current page. Using a link as a fallback means it sign in can work even without client side JavaScript. -::: - -### Email sign in page +### Email Sign in If you create a custom sign in form for email sign in, you will need to submit both fields for the **email** address and **csrfToken** from **/api/auth/csrf** in a POST request to **/api/auth/signin/email**. -This is easier if you use the build in `signin()` function, as it sets the CSRF token automatically. - -:::tip -To create a sign in page that works on clients with and without client side JavaScript, you can use both the **signin()** method and the **csrfToken()** method -::: - ```jsx title="pages/auth/email-signin" import React from 'react' -import { csrfToken, signin } from 'next-auth/client' +import { csrfToken } from 'next-auth/client' export default ({ csrfToken }) => { return ( -
{ - e.preventDefault() - signin('email', { email: document.getElementById('email').value }) - }} - > +