diff --git a/jwt.js b/jwt.js new file mode 100644 index 0000000000..01ca6ac72a --- /dev/null +++ b/jwt.js @@ -0,0 +1 @@ +module.exports = require('./dist/lib/jwt').default diff --git a/package-lock.json b/package-lock.json index 15f773da8c..b6f5bb37db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "next-auth", - "version": "2.0.0-beta.61", + "version": "2.0.0-beta.67", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -2044,6 +2044,11 @@ "which": "^1.2.9" } }, + "crypto-js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz", + "integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==" + }, "css-color-names": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", diff --git a/package.json b/package.json index 93d700af70..837268c144 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-auth", - "version": "2.0.0-beta.62", + "version": "2.0.0-beta.67", "description": "An authentication library for Next.js", "repository": "https://github.com/iaincollins/next-auth.git", "author": "Iain Collins ", @@ -30,6 +30,7 @@ ], "license": "ISC", "dependencies": { + "crypto-js": "^4.0.0", "jsonwebtoken": "^8.5.1", "jwt-decode": "^2.2.0", "nodemailer": "^6.4.6", diff --git a/src/adapters/example/index.js b/src/adapters/example/index.js new file mode 100644 index 0000000000..547bcb8bdb --- /dev/null +++ b/src/adapters/example/index.js @@ -0,0 +1,117 @@ +const Adapter = (config, options = {}) => { + async function getAdapter (appOptions) { + // Display debug output if debug option enabled + function _debug (...args) { + if (appOptions.debug) { + console.log('[next-auth][debug]', ...args) + } + } + + async function createUser (profile) { + _debug('createUser', profile) + return null + } + + async function getUser (id) { + _debug('getUser', id) + return null + } + + async function getUserByEmail (email) { + _debug('getUserByEmail', email) + return null + } + + async function getUserByProviderAccountId (providerId, providerAccountId) { + _debug('getUserByProviderAccountId', providerId, providerAccountId) + return null + } + + async function getUserByCredentials (credentials) { + _debug('getUserByCredentials', credentials) + return null + } + + async function updateUser (user) { + _debug('updateUser', user) + return null + } + + async function deleteUser (userId) { + _debug('deleteUser', userId) + return null + } + + async function linkAccount (userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) { + _debug('linkAccount', userId, providerId, providerType, providerAccountId, refreshToken, accessToken, accessTokenExpires) + return null + } + + async function unlinkAccount (userId, providerId, providerAccountId) { + _debug('unlinkAccount', userId, providerId, providerAccountId) + return null + } + + async function createSession (user) { + _debug('createSession', user) + return null + } + + async function getSession (sessionToken) { + _debug('getSession', sessionToken) + return null + } + + async function updateSession (session, force) { + _debug('updateSession', session) + return null + } + + async function deleteSession (sessionToken) { + _debug('deleteSession', sessionToken) + return null + } + + async function createVerificationRequest (identifer, url, token, secret, provider) { + _debug('createVerificationRequest', identifer) + return null + } + + async function getVerificationRequest (identifer, token, secret, provider) { + _debug('getVerificationRequest', identifer, token) + return null + } + + async function deleteVerificationRequest (identifer, token, secret, provider) { + _debug('deleteVerification', identifer, token) + return null + } + + return Promise.resolve({ + createUser, + getUser, + getUserByEmail, + getUserByProviderAccountId, + getUserByCredentials, + 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 b8ff4a1528..1b6edee63c 100644 --- a/src/adapters/typeorm/index.js +++ b/src/adapters/typeorm/index.js @@ -1,4 +1,3 @@ -import 'reflect-metadata' import { createConnection, getConnection, getManager, EntitySchema } from 'typeorm' import { createHash } from 'crypto' @@ -42,12 +41,12 @@ const Adapter = (config, options = {}) => { } // Load models / schemas (check for custom models / schemas first) - const Account = options.Account ? options.Account.model : Models.Account.model - const AccountSchema = options.Account ? options.Account.schema : Models.Account.schema - const User = options.User ? options.User.model : Models.User.model const UserSchema = options.User ? options.User.schema : Models.User.schema + const Account = options.Account ? options.Account.model : Models.Account.model + const AccountSchema = options.Account ? options.Account.schema : Models.Account.schema + const Session = options.Session ? options.Session.model : Models.Session.model const SessionSchema = options.Session ? options.Session.schema : Models.Session.schema @@ -78,9 +77,9 @@ const Adapter = (config, options = {}) => { // // @TODO Look at refactoring to see if there is a better way to do this that // doesn't rely on hard coding this transformation on a per property basis + UserSchema.columns.id.objectId = true AccountSchema.columns.id.objectId = true AccountSchema.columns.userId.type = 'objectId' - UserSchema.columns.id.objectId = true SessionSchema.columns.id.objectId = true SessionSchema.columns.userId.type = 'objectId' VerificationRequestSchema.columns.id.objectId = true @@ -92,9 +91,13 @@ const Adapter = (config, options = {}) => { // @TODO Refactor to apply automatically to all `timestamp` properties if the // database is MySQL. if (config.type === 'sqlite') { + UserSchema.columns.created.type = 'datetime' AccountSchema.columns.accessTokenExpires.type = 'datetime' - SessionSchema.columns.sessionExpires.type = 'datetime' + AccountSchema.columns.created.type = 'datetime' + SessionSchema.columns.expires.type = 'datetime' + SessionSchema.columns.created.type = 'datetime' VerificationRequestSchema.columns.expires.type = 'datetime' + VerificationRequestSchema.columns.created.type = 'datetime' } // Parse config (uses options) @@ -102,8 +105,8 @@ const Adapter = (config, options = {}) => { name: 'default', autoLoadEntities: true, entities: [ - new EntitySchema(AccountSchema), new EntitySchema(UserSchema), + new EntitySchema(AccountSchema), new EntitySchema(SessionSchema), new EntitySchema(VerificationRequestSchema) ], @@ -149,7 +152,7 @@ const Adapter = (config, options = {}) => { // Display debug output if debug option enabled function _debug (...args) { if (appOptions.debug) { - console.log('[NextAuth.js][DEBUG]', ...args) + console.log('[next-auth][debug]', ...args) } } @@ -164,6 +167,9 @@ const Adapter = (config, options = {}) => { ObjectId = mongodb.ObjectId } + const sessionMaxAge = appOptions.session.maxAge * 1000 + const sessionUpdateAge = appOptions.session.updateAge * 1000 + async function createUser (profile) { _debug('createUser', profile) try { @@ -184,7 +190,7 @@ const Adapter = (config, options = {}) => { // an ObjectId and we need to turn it into an ObjectId. // // In all other scenarios it is already an ObjectId, because it will have - // come from another MongoDB query. + // come from another MongoDB query. if (ObjectId && !(id instanceof ObjectId)) { id = ObjectId(id) } @@ -260,7 +266,6 @@ const Adapter = (config, options = {}) => { async function createSession (user) { _debug('createSession', user) try { - const { sessionMaxAge } = appOptions let expires = null if (sessionMaxAge) { const dateExpires = new Date() @@ -268,7 +273,7 @@ const Adapter = (config, options = {}) => { expires = dateExpires.toISOString() } - const session = new Session(user.id, null, expires) + const session = new Session(user.id, expires) return getManager().save(session) } catch (error) { @@ -283,7 +288,7 @@ const Adapter = (config, options = {}) => { const session = await connection.getRepository(Session).findOne({ sessionToken }) // Check session has not expired (do not return it if it has) - if (session && session.sessionExpires && new Date() > new Date(session.sessionExpires)) { + if (session && session.expires && new Date() > new Date(session.expires)) { // @TODO Delete old sessions from database return null } @@ -298,16 +303,14 @@ const Adapter = (config, options = {}) => { async function updateSession (session, force) { _debug('updateSession', session) try { - const { sessionMaxAge, sessionUpdateAge } = appOptions - - if (sessionMaxAge && (sessionUpdateAge || sessionUpdateAge === 0) && session.sessionExpires) { + 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.sessionExpires) + const dateSessionIsDueToBeUpdated = new Date(session.expires) dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() - sessionMaxAge) dateSessionIsDueToBeUpdated.setTime(dateSessionIsDueToBeUpdated.getTime() + sessionUpdateAge) @@ -316,12 +319,12 @@ const Adapter = (config, options = {}) => { if (new Date() > dateSessionIsDueToBeUpdated) { const newExpiryDate = new Date() newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge) - session.sessionExpires = newExpiryDate.toISOString() + session.expires = newExpiryDate.toISOString() } else if (!force) { return null } } else { - // If sessionMaxAge, sessionUpdateAge or session.sessionExpires are + // 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 } } @@ -346,8 +349,8 @@ const Adapter = (config, options = {}) => { async function createVerificationRequest (identifer, url, token, secret, provider) { _debug('createVerificationRequest', identifer) try { - const { site, verificationMaxAge } = appOptions - const { sendVerificationRequest } = provider + const { site } = 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. @@ -355,9 +358,9 @@ const Adapter = (config, options = {}) => { const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex') let expires = null - if (verificationMaxAge) { + if (maxAge) { const dateExpires = new Date() - dateExpires.setTime(dateExpires.getTime() + verificationMaxAge) + dateExpires.setTime(dateExpires.getTime() + (maxAge * 1000)) expires = dateExpires.toISOString() } diff --git a/src/adapters/typeorm/models/account.js b/src/adapters/typeorm/models/account.js index 5886d0244b..ffa60fe088 100644 --- a/src/adapters/typeorm/models/account.js +++ b/src/adapters/typeorm/models/account.js @@ -19,6 +19,9 @@ export class Account { this.refreshToken = refreshToken this.accessToken = accessToken this.accessTokenExpires = accessTokenExpires + + const dateCreated = new Date() + this.created = dateCreated.toISOString() } } @@ -59,6 +62,9 @@ export const AccountSchema = { accessTokenExpires: { type: 'timestamp', nullable: true + }, + created: { + type: 'timestamp' } } } diff --git a/src/adapters/typeorm/models/session.js b/src/adapters/typeorm/models/session.js index 9b07ee0a20..86ce865fe5 100644 --- a/src/adapters/typeorm/models/session.js +++ b/src/adapters/typeorm/models/session.js @@ -1,11 +1,14 @@ import { randomBytes } from 'crypto' export class Session { - constructor (userId, sessionToken, sessionExpires, accessToken) { + constructor (userId, expires, sessionToken, accessToken) { this.userId = userId + this.expires = expires this.sessionToken = sessionToken || randomBytes(32).toString('hex') - this.sessionExpires = sessionExpires this.accessToken = accessToken || randomBytes(32).toString('hex') + + const dateCreated = new Date() + this.created = dateCreated.toISOString() } } @@ -21,16 +24,19 @@ export const SessionSchema = { userId: { type: 'int' }, + expires: { + type: 'timestamp' + }, sessionToken: { type: 'varchar', unique: true }, - sessionExpires: { - type: 'timestamp' - }, accessToken: { type: 'varchar', unique: true + }, + created: { + type: 'timestamp' } } } diff --git a/src/adapters/typeorm/models/user.js b/src/adapters/typeorm/models/user.js index 62554ecca4..35a2870ac9 100644 --- a/src/adapters/typeorm/models/user.js +++ b/src/adapters/typeorm/models/user.js @@ -3,6 +3,9 @@ export class User { this.name = name this.email = email this.image = image + + const dateCreated = new Date() + this.created = dateCreated.toISOString() } } @@ -26,6 +29,9 @@ export const UserSchema = { image: { type: 'varchar', nullable: true + }, + created: { + type: 'timestamp' } } } diff --git a/src/adapters/typeorm/models/verification-request.js b/src/adapters/typeorm/models/verification-request.js index e99b32b20b..abadf52c11 100644 --- a/src/adapters/typeorm/models/verification-request.js +++ b/src/adapters/typeorm/models/verification-request.js @@ -3,6 +3,9 @@ export class VerificationRequest { this.identifer = identifer this.token = token this.expires = expires + + const dateCreated = new Date() + this.created = dateCreated.toISOString() } } @@ -24,6 +27,9 @@ export const VerificationRequestSchema = { }, expires: { type: 'timestamp' + }, + created: { + type: 'timestamp' } } } diff --git a/src/client/index.js b/src/client/index.js index 85f5236023..f8c8acf466 100644 --- a/src/client/index.js +++ b/src/client/index.js @@ -181,9 +181,14 @@ const _encodedForm = (formData) => { } export default { + // For legacy reasons, some methods are exported with more than one name + // @TODO Deprecate these methods? session: getSession, providers: getProviders, csrfToken: getCsrfToken, + getSession, + getProviders, + getCsrfToken, useSession, Provider, signin, diff --git a/src/lib/jwt.js b/src/lib/jwt.js new file mode 100644 index 0000000000..402f462264 --- /dev/null +++ b/src/lib/jwt.js @@ -0,0 +1,26 @@ +import jwt from 'jsonwebtoken' +import CryptoJS from 'crypto-js' + +const encode = async ({ secret, key = secret, token = {}, maxAge }) => { + // 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 decode = async ({ secret, key = secret, token, maxAge }) => { + 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 +} + +export default { + encode, + decode +} diff --git a/src/providers/email.js b/src/providers/email.js index a9204d178b..d228045a35 100644 --- a/src/providers/email.js +++ b/src/providers/email.js @@ -15,6 +15,7 @@ export default (options) => { } }, from: 'NextAuth ', + maxAge: 24 * 60 * 60, // How long email links should be valid for sendVerificationRequest, ...options } diff --git a/src/server/index.js b/src/server/index.js index 4e9d7edac0..326026c755 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -1,4 +1,5 @@ import { createHash, randomBytes } from 'crypto' +import jwt from '../lib/jwt' import cookie from './lib/cookie' import callbackUrlHandler from './lib/callback-url-handler' import parseProviders from './lib/providers' @@ -50,15 +51,14 @@ export default async (req, res, userSuppliedOptions) => { } else if (userSuppliedOptions.database) { // If database URI or config object is provided, use it (simple usage) adapter = adapters.Default(userSuppliedOptions.database) - } else { - // @TODO Add link to documentation - console.error('Error:\n', - 'NextAuth requires a \'database\' or \'adapter\' option to be specified.\n', - 'See documentation for details https://next-auth.js.org') - pages.render(req, res, 'error', { site, error: 'Configuration', baseUrl }, done) - return done() } + // Secret used salt cookies and tokens (e.g. for CSRF protection). + // If no secret option is specified then it creates one on the fly + // based on options passed here. A options contains unique data, such as + // oAuth provider secrets and database credentials it should be sufficent. + const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify(userSuppliedOptions)).digest('hex') + // Use secure cookies if the site uses HTTPS // This being conditional allows cookies to work non-HTTPS development URLs // Honour secure cookie option, which sets 'secure' and also adds '__Secure-' @@ -112,11 +112,29 @@ export default async (req, res, userSuppliedOptions) => { ...userSuppliedOptions.cookies } - // Secret used salt cookies and tokens (e.g. for CSRF protection). - // If no secret option is specified then it creates one on the fly - // based on options passed here. A options contains unique data, such as - // oAuth provider secrets and database credentials it should be sufficent. - const secret = userSuppliedOptions.secret || createHash('sha256').update(JSON.stringify(userSuppliedOptions)).digest('hex') + // Session options + const sessionOptions = { + jwt: false, + maxAge: 30 * 24 * 60 * 60, // Sessions expire after 30 days of being idle + updateAge: 24 * 60 * 60, // Sessions updated only if session is greater than this value (0 = always, 24*60*60 = every 24 hours) + get: async (session) => session, + ...userSuppliedOptions.session + } + + // JWT options + const jwtOptions = { + secret, + key: secret, + set: async (token) => token, + encode: jwt.encode, + decode: jwt.decode, + ...userSuppliedOptions.jwt + } + + // If no adapter specified, force use of JSON Web Tokens (stateless) + if (!adapter) { + sessionOptions.jwt = true + } // Ensure CSRF Token cookie is set for any subsequent requests. // Used as part of the strateigy for mitigation for CSRF tokens. @@ -173,13 +191,16 @@ export default async (req, res, userSuppliedOptions) => { // except for the options with special handling above const options = { // Defaults options can be overidden - jwt: false, // Use JSON Web Token (JWT) for session, instead of database - jwtSecret: secret, // Use default secret unless explicitly specified - sessionMaxAge: 30 * 24 * 60 * 60 * 1000, // Sessions expire after 30 days of being idle - sessionUpdateAge: 24 * 60 * 60 * 1000, // Sessions updated only if session is greater than this value (0 = always, 24*60*60*1000 = every 24 hours) - verificationMaxAge: 24 * 60 * 60 * 1000, // Email/passwordless links expire after 24 hours debug: false, // Enable debug messages to be displayed pages: {}, // Custom pages (e.g. sign in, sign out, errors) + allowSignin: async (user, account) => true, // Return true if user / account is allowed to sign in (false if not) + allowCallbackUrl: async (url, site) => { + if (url.startsWith(site)) { + return Promise.resolve(url) + } else { + return Promise.resolve(site) + } + }, // Custom options override defaults ...userSuppliedOptions, // These computed settings can values in userSuppliedOptions but override them @@ -195,6 +216,8 @@ export default async (req, res, userSuppliedOptions) => { csrfToken, csrfTokenVerified, providers: parseProviders(userSuppliedOptions.providers, baseUrl), + session: sessionOptions, + jwt: jwtOptions, callbackUrl: site } diff --git a/src/server/lib/callback-handler.js b/src/server/lib/callback-handler.js index be9db38479..7750419078 100644 --- a/src/server/lib/callback-handler.js +++ b/src/server/lib/callback-handler.js @@ -8,8 +8,6 @@ // All verification (e.g. oAuth flows or email address verificaiton flows) are // done prior to this handler being called to avoid additonal complexity in this // handler. -import { randomBytes } from 'crypto' -import jwt from 'jsonwebtoken' import { AccountNotLinkedError, InvalidProfile } from '../../lib/errors' export default async (sessionToken, profile, providerAccount, options) => { @@ -18,7 +16,20 @@ export default async (sessionToken, profile, providerAccount, options) => { if (!profile) { throw new Error('Missing profile') } if (!providerAccount || !providerAccount.id || !providerAccount.type) { throw new Error('Missing or invalid provider account') } - const { adapter, jwt: useJwt, jwtSecret, sessionMaxAge } = options + const { adapter, jwt } = options + + const useJwtSession = options.session.jwt + const sessionMaxAge = options.session.maxAge + + // If no adapter is configured then we don't have a database and cannot + // persist data; in this mode we just return a dummy session object. + if (!adapter) { + return { + user: profile, + account: providerAccount, + session: {} + } + } const { createUser, @@ -38,11 +49,10 @@ export default async (sessionToken, profile, providerAccount, options) => { let isNewUser = false if (sessionToken) { - if (useJwt) { + if (useJwtSession) { try { - const token = jwt.verify(sessionToken, jwtSecret, { maxAge: sessionMaxAge }) - session = token.nextauth || null - if (session && session.user && session.user.id) { + session = await jwt.decode({ secret: jwt.secret, token: sessionToken, maxAge: sessionMaxAge }) + if (session && session.user) { user = await getUser(session.user.id) isSignedIn = !!user } @@ -78,7 +88,7 @@ export default async (sessionToken, profile, providerAccount, options) => { // Delete existing session if they are currently signed in as another user. // This will switch user accounts for the session in cases where the user was // already logged in with a different account. - if (!useJwt) { + if (!useJwtSession) { await deleteSession(sessionToken) } @@ -96,7 +106,7 @@ export default async (sessionToken, profile, providerAccount, options) => { } // Create new session - session = useJwt ? await createJwtSession(user, sessionMaxAge) : await createSession(user) + session = useJwtSession ? {} : await createSession(user) return { session, @@ -127,7 +137,7 @@ export default async (sessionToken, profile, providerAccount, options) => { } else { // If there is no active session, but the account being signed in with is already // associated with a valid user then create session to sign the user in. - session = useJwt ? await createJwtSession(userByProviderAccountId, sessionMaxAge) : await createSession(userByProviderAccountId) + session = useJwtSession ? {} : await createSession(userByProviderAccountId) return { session, user: userByProviderAccountId, @@ -208,7 +218,7 @@ export default async (sessionToken, profile, providerAccount, options) => { providerAccount.accessTokenExpires ) - session = useJwt ? await createJwtSession(user, sessionMaxAge) : await createSession(user) + session = useJwtSession ? {} : await createSession(user) isNewUser = true return { session, @@ -224,14 +234,3 @@ export default async (sessionToken, profile, providerAccount, options) => { return Promise.reject(error) } } - -const createJwtSession = async (user, sessionMaxAge) => { - const expiryDate = new Date() - expiryDate.setTime(expiryDate.getTime() + sessionMaxAge) - const sessionExpires = expiryDate.toISOString() - return Promise.resolve({ - user, - sessionExpires, - accessToken: randomBytes(32).toString('hex') - }) -} diff --git a/src/server/lib/callback-url-handler.js b/src/server/lib/callback-url-handler.js index 883d56ca8b..ace4d8c00d 100644 --- a/src/server/lib/callback-url-handler.js +++ b/src/server/lib/callback-url-handler.js @@ -3,11 +3,7 @@ import cookie from '../lib/cookie' export default async (req, res, options) => { const { query } = req const { body } = req - const { cookies, site, defaultCallbackUrl } = options - - // The callbackUrlHandler function is used to validate callback URLs, so you - // can easily allow (or deny) specific callback URLs. - const callbackUrlHandler = options.callbackUrlHandler || callbackUrlHandlerDefaultFunction + const { cookies, site, defaultCallbackUrl, allowCallbackUrl } = options // Handle preserving and validating callback URLs // If no defaultCallbackUrl option specified, default to the homepage for the site @@ -18,10 +14,10 @@ export default async (req, res, options) => { 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 callbackUrlHandler(callbackUrlParamValue, options) + callbackUrl = await allowCallbackUrl(callbackUrlParamValue, site) } else if (callbackUrlCookieValue) { // If no callbackUrl specified, try using the value from the cookie if allowed - callbackUrl = await callbackUrlHandler(callbackUrlCookieValue, options) + callbackUrl = await allowCallbackUrl(callbackUrlCookieValue, site) } // Save callback URL in a cookie so that can be used for subsequent requests in signin/signout/callback flow @@ -29,13 +25,3 @@ export default async (req, res, options) => { return Promise.resolve(callbackUrl) } - -// Default handler for callbackUrlHandler(url) checks the protocol and host -// matches the site name, if not then returns the canonical site url. -const callbackUrlHandlerDefaultFunction = (url, options) => { - if (url.startsWith(options.site)) { - return Promise.resolve(url) - } else { - return Promise.resolve(options.site) - } -} diff --git a/src/server/lib/signin/email.js b/src/server/lib/signin/email.js index d385582358..cfff245b5b 100644 --- a/src/server/lib/signin/email.js +++ b/src/server/lib/signin/email.js @@ -3,6 +3,7 @@ import { randomBytes } from 'crypto' export default async (email, provider, options) => { try { const { baseUrl, adapter } = options + const { createVerificationRequest } = await adapter.getAdapter(options) // Prefer provider specific secret, but use default secret if none specified diff --git a/src/server/pages/error.js b/src/server/pages/error.js index 24d9f2973d..26aa8ac3f6 100644 --- a/src/server/pages/error.js +++ b/src/server/pages/error.js @@ -29,7 +29,7 @@ export default ({ site, error, baseUrl }) => { message =
-

You might have signed in before with a different account.

+

An account associated with your email address already exists.

Sign in the same account you used originally to confirm your identity.

Sign in

@@ -66,19 +66,30 @@ export default ({ site, error, baseUrl }) => { message =
-

There is a problem with the NextAuth server configuration.

-

Check the server logs for details.

+

There is a problem with the server configuration.

+

Check the server logs for more information.

break + case 'AccessDenied': + heading =

Access Denied

+ message = +
+
+

Your account does not have permission to sign in.

+

Sign in

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

Sign in failed

+ heading =

Unable to sign in

message =
-

The link you followed may have been used already or may have expired.

+

The sign in link is no longer valid.

+

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

Sign in

diff --git a/src/server/pages/index.js b/src/server/pages/index.js index 50c6ab9b14..dcbe85ee90 100644 --- a/src/server/pages/index.js +++ b/src/server/pages/index.js @@ -29,7 +29,7 @@ function render (req, res, page, props, done) { } res.setHeader('Content-Type', 'text/html') - res.send(`
${html}
`) + res.send(`
${html}
`) done() } diff --git a/src/server/routes/callback.js b/src/server/routes/callback.js index 7f8c81f02f..5804a28f80 100644 --- a/src/server/routes/callback.js +++ b/src/server/routes/callback.js @@ -1,10 +1,9 @@ // Handle callbacks from login services -import jwt from 'jsonwebtoken' -import OAuthCallback from '../lib/oauth/callback' +import oAuthCallback from '../lib/oauth/callback' import callbackHandler from '../lib/callback-handler' import cookie from '../lib/cookie' -// @TODO Refactor OAuthCallback to return promise instead of using a callback and reduce duplicate code +// @TODO Refactor oAuthCallback to return promise instead of using a callback and reduce duplicate code export default async (req, res, options, done) => { const { provider: providerName, @@ -16,50 +15,56 @@ export default async (req, res, options, done) => { cookies, callbackUrl, pages, - sessionMaxAge, - jwt: useJwt, - jwtSecret + jwt, + allowSignin } = options const provider = providers[providerName] const { type } = provider - const { getVerificationRequest, deleteVerificationRequest } = await adapter.getAdapter(options) + const useJwtSession = options.session.jwt + const sessionMaxAge = options.session.maxAge // Get session ID (if set) const sessionToken = req.cookies[cookies.sessionToken.name] if (type === 'oauth') { - OAuthCallback(req, provider, async (error, oauthAccount) => { + oAuthCallback(req, provider, async (error, oauthAccount) => { if (error) { console.error('OAUTH_CALLBACK_ERROR', error) - res.status(302).setHeader('Location', `${baseUrl}/error?error=OAuthCallback`) + res.status(302).setHeader('Location', `${baseUrl}/error?error=oAuthCallback`) res.end() return done() } const { profile, account } = await oauthAccount + // Check allowSignin() allows this account to sign in + if (!await allowSignin(profile, account)) { + res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`) + res.end() + return done() + } + try { - const { session, isNewUser } = await callbackHandler(sessionToken, profile, account, options) - - if (useJwt) { - // Store session in JWT cookie - const token = jwt.sign( - { - nextauth: { - ...session, - account, - isNewUser - } - }, - jwtSecret, - { - expiresIn: sessionMaxAge - } - ) - cookie.set(res, cookies.sessionToken.name, token, { expires: session.sessionExpires || null, ...cookies.sessionToken.options }) + const { user, session, isNewUser } = await callbackHandler(sessionToken, profile, account, options) + + if (useJwtSession) { + const jwtPayload = await jwt.set({ + user, + account, + isNewUser + }) + + // Sign and encrypt token + const token = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge }) + + // Set cookie expiry date + const cookieExpires = new Date() + cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000)) + + cookie.set(res, cookies.sessionToken.name, token, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options }) } else { // Save Session Token in cookie - cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.sessionExpires || null, ...cookies.sessionToken.options }) + cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options }) } // Handle first logins on new accounts @@ -99,9 +104,28 @@ export default async (req, res, options, done) => { }) } else if (type === 'email') { try { + if (!adapter) { + console.error('EMAIL_REQUIRES_ADAPTER_ERROR') + res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`) + res.end() + return done() + } + + const { getVerificationRequest, deleteVerificationRequest } = await adapter.getAdapter(options) const token = req.query.token const email = req.query.email ? req.query.email.toLowerCase() : null + // Create the an `account` object with `id` and `type` properties as they + // are expected by the `callbackHandler` function and in the JWT. + const emailProviderAccount = { id: provider.id, type: 'email' } + + // Check allowSignin() allows this account to sign in + if (!await allowSignin({ email }, emailProviderAccount)) { + res.status(302).setHeader('Location', `${baseUrl}/error?error=AccessDenied`) + res.end() + return done() + } + // Verify email and token match email verification record in database const invite = await getVerificationRequest(email, token, secret, provider) if (!invite) { @@ -110,34 +134,33 @@ export default async (req, res, options, done) => { return done() } - // If token is valid, delete email verification record in database… + // If token is valid, delete email verification record in database await deleteVerificationRequest(email, token, secret, provider) - // …lastly, invoke callbackHandler to go through sign up flow. - // (Will create new account if they don't have one, or sign them into - // an existing account if they do have one.) - const dummyProviderAccount = { id: provider.id, type: 'email' } - const { session, isNewUser } = await callbackHandler(sessionToken, { email }, dummyProviderAccount, options) - - if (useJwt) { - // Store session in JWT cookie - const token = jwt.sign( - { - nextauth: { - ...session, - account: dummyProviderAccount, - isNewUser - } - }, - jwtSecret, - { - expiresIn: sessionMaxAge - } - ) - cookie.set(res, cookies.sessionToken.name, token, { expires: session.sessionExpires || null, ...cookies.sessionToken.options }) + // Invoke callbackHandler to go through sign up flow + // + // This will create new new account if they don't have one, or sign them + // into an existing account if they do have one. + const { user, session, isNewUser } = await callbackHandler(sessionToken, { email }, emailProviderAccount, options) + + if (useJwtSession) { + const jwtPayload = await jwt.set({ + user, + account: emailProviderAccount, + isNewUser + }) + + // Sign and encrypt token + const token = await jwt.encode({ secret: jwt.secret, token: jwtPayload, maxAge: sessionMaxAge }) + + // Set cookie expiry date + const cookieExpires = new Date() + cookieExpires.setTime(cookieExpires.getTime() + (sessionMaxAge * 1000)) + + cookie.set(res, cookies.sessionToken.name, token, { expires: cookieExpires.toISOString(), ...cookies.sessionToken.options }) } else { // Save Session Token in cookie - cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.sessionExpires || null, ...cookies.sessionToken.options }) + cookie.set(res, cookies.sessionToken.name, session.sessionToken, { expires: session.expires || null, ...cookies.sessionToken.options }) } // Handle first logins on new accounts diff --git a/src/server/routes/providers.js b/src/server/routes/providers.js index 86d5fa35c8..f8a84d52e3 100644 --- a/src/server/routes/providers.js +++ b/src/server/routes/providers.js @@ -7,6 +7,7 @@ export default (req, res, options, done) => { const result = {} Object.entries(providers).map(([provider, providerConfig]) => { result[provider] = { + id: provider, name: providerConfig.name, type: providerConfig.type, signinUrl: providerConfig.signinUrl, diff --git a/src/server/routes/session.js b/src/server/routes/session.js index 48e5918916..b2eb0c9be3 100644 --- a/src/server/routes/session.js +++ b/src/server/routes/session.js @@ -1,10 +1,11 @@ // Return a session object (without any private fields) for Single Page App clients -import jwt from 'jsonwebtoken' import cookie from '../lib/cookie' export default async (req, res, options, done) => { - const { cookies, adapter, sessionMaxAge, jwt: useJwt, jwtSecret, debug } = options - const { getUser, getSession, updateSession } = await adapter.getAdapter(options) + const { cookies, adapter, jwt } = options + const useJwtSession = options.session.jwt + const sessionMaxAge = options.session.maxAge + const getSessionResponse = options.session.get const sessionToken = req.cookies[cookies.sessionToken.name] if (!sessionToken) { @@ -14,42 +15,35 @@ export default async (req, res, options, done) => { } let response = {} - if (useJwt) { + if (useJwtSession) { try { - const token = jwt.verify(sessionToken, jwtSecret, { maxAge: sessionMaxAge }) + // Decrypt and verify token + const token = await jwt.decode({ secret: jwt.secret, token: sessionToken, maxAge: sessionMaxAge }) - if (debug) { - console.log('[NextAuth.js][DEBUG][JWT]', token) - } - - // Update Session Expiry inside token (human readable, exposed to UI) - const newExpiryDate = new Date() - newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge) - token.nextauth.sessionExpires = newExpiryDate.toISOString() + // Refresh JWT expiry by re-signing it, with updated expiry date + const newToken = await jwt.encode({ secret: jwt.secret, token: await jwt.set(token), maxAge: sessionMaxAge }) - // Update Session Expiry in JWT… - token.exp = sessionMaxAge + // Set cookie expiry date + const sessionExpiresDate = new Date() + sessionExpiresDate.setTime(sessionExpiresDate.getTime() + (sessionMaxAge * 1000)) + const sessionExpires = sessionExpiresDate.toISOString() - // Create new signed JWT (to replace existing one) - const newToken = jwt.sign(token, jwtSecret) + // Set cookie, to also update expiry date on cookie + cookie.set(res, cookies.sessionToken.name, newToken, { expires: sessionExpires, ...cookies.sessionToken.options }) // Only expose a limited subset of information to the client as needed // for presentation purposes (e.g. "you are logged in as…"). // // @TODO Should support `async seralizeUser({ user, function })` style // middleware function to allow response to be customized. - response = { + response = await getSessionResponse({ user: { - name: token.nextauth.user.name, - email: token.nextauth.user.email, - image: token.nextauth.user.image + name: token.user && token.user.name ? token.user.name : null, + email: token.user && token.user.email ? token.user.email : null, + image: token.user && token.user.image ? token.user.image : null }, - accessToken: token.nextauth.accessToken, - expires: token.nextauth.sessionExpires - } - - // Set cookie again to also update expiry on cookie - cookie.set(res, cookies.sessionToken.name, newToken, { expires: token.nextauth.sessionExpires, ...cookies.sessionToken.options }) + expires: sessionExpires + }) } catch (error) { // If JWT not verifiable, make sure the cookie for it is removed and return empty object console.error('JWT_SESSION_ERROR', error) @@ -57,9 +51,10 @@ export default async (req, res, options, done) => { } } else { try { + const { getUser, getSession, updateSession } = await adapter.getAdapter(options) const session = await getSession(sessionToken) if (session) { - // Trigger update to session object to update sessionExpires + // Trigger update to session object to update session expiry await updateSession(session) const user = await getUser(session.userId) @@ -69,18 +64,18 @@ export default async (req, res, options, done) => { // // @TODO Should support `async seralizeUser({ user, function })` style // middleware function to allow response to be customized. - response = { + response = await getSessionResponse({ user: { name: user.name, email: user.email, image: user.image }, accessToken: session.accessToken, - expires: session.sessionExpires - } + expires: session.expires + }) // Set cookie again to update expiry - cookie.set(res, cookies.sessionToken.name, sessionToken, { expires: session.sessionExpires, ...cookies.sessionToken.options }) + cookie.set(res, cookies.sessionToken.name, sessionToken, { expires: session.expires, ...cookies.sessionToken.options }) } else if (sessionToken) { // If sessionToken was found set but it's not valid for a session then // remove the sessionToken cookie from browser. diff --git a/src/server/routes/signin.js b/src/server/routes/signin.js index 5f7d7ca8dd..b7b9193026 100644 --- a/src/server/routes/signin.js +++ b/src/server/routes/signin.js @@ -1,9 +1,16 @@ // Handle requests to /api/auth/signin -import OAuthSignin from '../lib/signin/oauth' +import oAuthSignin from '../lib/signin/oauth' import emailSignin from '../lib/signin/email' export default async (req, res, options, done) => { - const { provider: providerName, providers, baseUrl, csrfTokenVerified } = options + const { + provider: providerName, + providers, + baseUrl, + csrfTokenVerified, + adapter, + allowSignin + } = options const provider = providers[providerName] const { type } = provider @@ -13,10 +20,10 @@ export default async (req, res, options, done) => { } if (type === 'oauth') { - OAuthSignin(provider, (error, oAuthSigninUrl) => { + oAuthSignin(provider, (error, oAuthSigninUrl) => { if (error) { console.error('OAUTH_SIGNIN_ERROR', error) - res.status(302).setHeader('Location', `${baseUrl}/error?error=OAuthSignin`) + res.status(302).setHeader('Location', `${baseUrl}/error?error=oAuthSignin`) res.end() return done() } @@ -26,6 +33,13 @@ export default async (req, res, options, done) => { return done() }) } else if (type === 'email' && req.method === 'POST') { + if (!adapter) { + console.error('EMAIL_REQUIRES_ADAPTER_ERROR') + res.status(302).setHeader('Location', `${baseUrl}/error?error=Configuration`) + res.end() + return done() + } + // This works like oAuth signin but instead of returning a secure link // to the browser, it sends it via email to verify the user and then // redirects the browser to a page telling the user to follow the link @@ -36,6 +50,13 @@ export default async (req, res, options, done) => { // account is created for them if they don't have one already. const email = req.body.email ? req.body.email.toLowerCase() : null + // Check allowSignin() allows this account to sign in + if (!await allowSignin({ email }, { id: provider.id, type: 'email' })) { + 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. // diff --git a/src/server/routes/signout.js b/src/server/routes/signout.js index 97fd34d8d8..516a7be662 100644 --- a/src/server/routes/signout.js +++ b/src/server/routes/signout.js @@ -7,10 +7,11 @@ export default async (req, res, options, done) => { cookies, callbackUrl, csrfTokenVerified, - baseUrl, - jwt: useJwt + baseUrl } = options + const useJwtSession = options.session.jwt + 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 @@ -23,8 +24,8 @@ export default async (req, res, options, done) => { return done() } - // Don't need to update the database if is using JWT instead of session DB - if (!useJwt) { + // Don't need to update the database if using JWT instead of session database + if (!useJwtSession) { // Use Session Token and get session from database const { deleteSession } = await adapter.getAdapter(options) const sessionToken = req.cookies[cookies.sessionToken.name] @@ -33,7 +34,7 @@ export default async (req, res, options, done) => { // Remove session from database await deleteSession(sessionToken) } catch (error) { - // Log error and continue + // If error, log it but continue console.error('SIGNOUT_ERROR', error) } } diff --git a/www/docs/getting-started/client.md b/www/docs/getting-started/client.md index 98a4df1a40..a141d37b0c 100644 --- a/www/docs/getting-started/client.md +++ b/www/docs/getting-started/client.md @@ -8,12 +8,12 @@ The NextAuth.js client library makes it easy to interact with sessions from Reac Some of the methods can be called both client side and server side. :::note -When using any of the client API methods server side, [context](https://nextjs.org/docs/api-reference/data-fetching/getInitialProps#context-object) must be passed as an argument. The documentation for **session()** has an example. +When using any of the client API methods server side, [context](https://nextjs.org/docs/api-reference/data-fetching/getInitialProps#context-object) must be passed as an argument. The documentation for **getSession()** has an example. ::: --- -### useSession() +## useSession() * Client Side: **Yes** * Server Side: No @@ -37,12 +37,12 @@ export default () => { --- -### session() +## getSession() * Client Side: **Yes** * Server Side: **Yes** -NextAuth.js also provides a `session()` method which can be called client or server side to return a session. +NextAuth.js also provides a `getSession()` method which can be called client or server side to return a session. It calls `/api/auth/session` and returns a promise with a session object, or null if no session exists. @@ -60,13 +60,13 @@ A session object looks like this: } ``` -You can call `session()` inside a function to check if a user is signed in, or use it for server side rendered pages that supporting signing in without requiring client side JavaScript. +You can call `getSession()` inside a function to check if a user is signed in, or use it for server side rendered pages that supporting signing in without requiring client side JavaScript. :::info Note that because it exposed to the client it does not contain sensitive information such as the Session Token or OAuth service related tokens. It includes enough information (e.g name, email) to display information on a page about the user who is signed in, and an Access Token that can be used to identify the session without exposing the Session Token itself. ::: -Because it is a Universal method, you can use `session()` in both client and server side functions, such as `getInitialProps()` in Next.js: +Because it is a Universal method, you can use `getSession()` in both client and server side functions, such as `getInitialProps()` in Next.js: ```jsx title="/pages/index.js" import { session } from 'next-auth/client' @@ -84,14 +84,14 @@ const Page = ({ session }) => (

Page.getInitialProps = async (context) => { return { - session: await session(context) + session: await getSession(context) } } export default Page ``` -#### Using session() in API routes +#### Using getSession() in API routes You can also get the session object in Next.js API routes: @@ -99,7 +99,7 @@ You can also get the session object in Next.js API routes: import { session } from 'next-auth/client' export default (req, res) => { - const session = await session({ req }) + const session = await getSession({ req }) if (session) { // Signed in @@ -120,12 +120,36 @@ export default (req, res) => { ``` :::note -When calling `session()` server side, you must pass the request object - e.g. `session({req})` - or you can the pass entire `context` object as it contains the `req` object. +When calling `getSession()` server side, you must pass the request object - e.g. `getSession({req})` - or you can the pass entire `context` object as it contains the `req` object. ::: --- -### signin(provider, { options }) +## getProviders() + +* Client Side: **Yes** +* Server Side: **Yes** + +The `getProviders()` method returns the list of providers currently configured for sign in. + +It calls `/api/auth/providers` and returns a with a list of the currently configured authentication providers. + +It can be use useful if you are creating a dynamic custom sign in page. + +--- + +## getCsrfToken() + +* Client Side: **Yes** +* Server Side: **Yes** + +The `getCsrfToken()` method returns the current Cross Site Request Forgery (CSRF Token) required to make POST requests (e.g. for signing in and signing out). It calls `/api/auth/csrf`. + +You likely only need to use this if you are not using the built-in `signin()` and `signout()` methods. + +--- + +## signin(provider, { options }) * Client Side: **Yes** * Server Side: No @@ -172,7 +196,7 @@ To also support signing in from clients that do not have client side JavaScript, --- -### signout() +## signout() * Client Side: **Yes** * Server Side: No @@ -191,31 +215,7 @@ export default () => ( --- -### providers() - -* Client Side: **Yes** -* Server Side: **Yes** - -The `providers()` method returns the list of providers currently configured for sign in. - -It calls `/api/auth/providers` and returns a with a list of the currently configured authentication providers. - -It can be use useful if you are creating a dynamic custom sign in page. - ---- - -### csrfToken() - -* Client Side: **Yes** -* Server Side: **Yes** - -The `csrfToken()` method returns the current Cross Site Request Forgery (CSRF Token) required to make POST requests (e.g. for signing in and signing out). It calls `/api/auth/csrf`. - -You likely only need to use this if you are not using the built-in `signin()` and `signout()` methods. - ---- - -### Provider +## Provider Using the supplied React `` allows instances of `useSession()` to share the session object across components, buy using [React Context](https://reactjs.org/docs/context.html) under the hood. diff --git a/www/docs/getting-started/example.md b/www/docs/getting-started/example.md index bf6f607489..6d7defbd50 100644 --- a/www/docs/getting-started/example.md +++ b/www/docs/getting-started/example.md @@ -23,13 +23,20 @@ import Providers from 'next-auth/providers' const options = { site: process.env.SITE || 'http://localhost:3000', + + // specify one or more authentication providers providers: [ Providers.GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }), ], + + // database is optional, but required to persist accounts in a database database: process.env.DATABASE_URL + + // control who can sign in - allows access control even without a database + allowSignin: async (user, account) => { return true } } export default (req, res) => NextAuth(req, res, options) diff --git a/www/docs/getting-started/introduction.md b/www/docs/getting-started/introduction.md index c1f8036df8..2e6bdc1745 100644 --- a/www/docs/getting-started/introduction.md +++ b/www/docs/getting-started/introduction.md @@ -26,6 +26,7 @@ It is designed from the ground up to support Next.js and Serverless. * Supports Bring Your Own Database (BYOD) and can be used with any database * Built-in support for for [MySQL, MariaDB, Postgres, MongoDB and SQLite](/options/database) * Works great with databases from popular hosting providers +* Can also be used *without a database* (e.g. OAuth + JWT) ### Secure by default diff --git a/www/docs/options/adapter.md b/www/docs/options/adapter.md index f2deb672b1..f17dd975da 100644 --- a/www/docs/options/adapter.md +++ b/www/docs/options/adapter.md @@ -1,13 +1,15 @@ --- id: adapter -title: Database Adapters +title: Custom Adapters --- An "*Adapter*" in NextAuth.js is the thing that connects your application to whatever database or backend system you want to use to store data for user accounts, sessions, etc. +:::tip *The **adapter** option is currently considered advanced usage intended for use by NextAuth.js contributors.* +::: -## TypeORM (Default) +## TypeORM (default adapter) NextAuth.js comes with a default adapter that uses [TypeORM](https://typeorm.io/) so that it can be used with many different databases without any further configuration, you simply add the database driver you want to use to your project and tell NextAuth.js to use it. @@ -43,10 +45,178 @@ database: { } ``` -## Creating your own adapter +## Custom adapters Using a custom adapter you can connect to any database backend or even several different databases. -As an example, one has already been created by a community member to use a [Prisma](https://www.prisma.io/) backend. +Creating a custom adapter is considerable undertaking and will require some trial and error and some reverse engineering as it is not currently well documented. The hope and expectation is to grow both the number of included (and third party) adapters over time. -How to write your own adapter is not yet covered by this documentation and we are not able to provide support for it at this time, but if you want to figure it out on your own the API should be relatively stable. \ No newline at end of file +An adapter in NextAuth.js is a function which returns an async `getAdapter()` method, which in turn returns a Promise with a list of functions used to handle operations such as creating user, linking a user and an OAuth account or handling reading and writing sessions. + +It uses this approach to allow database connection logic to live in the `getAdapter()` method. By calling the function just before an action needs to happen, it is possible to check database connection status and handle connecting / reconnecting to a database as required. + +### Required methods + +These methods are required for all sign in flows: + +* createUser +* getUser +* getUserByEmail +* getUserByProviderAccountId +* linkAccount +* createSession +* getSession +* updateSession +* deleteSession + +These methods are required to support email / passwordless sign in: + +* createVerificationRequest +* getVerificationRequest +* deleteVerificationRequest + +### Unimplemented methods + +These methods will be required in a future release, but are not yet invoked: + +* getUserByCredentials +* updateUser +* deleteUser +* unlinkAccount + +### Example code + +An example of adapter structure is shown below: + +```js +const Adapter = (config, options = {}) => { + + async function getAdapter (appOptions) { + + async function createUser (profile) { + return null + } + + async function getUser (id) { + return null + } + + async function getUserByEmail (email) { + return null + } + + async function getUserByProviderAccountId ( + providerId, + providerAccountId + ) { + return null + } + + async function getUserByCredentials (credentials) { + return null + } + + async function updateUser (user) { + return null + } + + async function deleteUser (userId) { + return null + } + + async function linkAccount ( + userId, + providerId, + providerType, + providerAccountId, + refreshToken, + accessToken, + accessTokenExpires + ) { + return null + } + + async function unlinkAccount ( + userId, + providerId, + providerAccountId + ) { + return null + } + + async function createSession (user) { + return null + } + + async function getSession (sessionToken) { + return null + } + + async function updateSession ( + session, + force + ) { + return null + } + + async function deleteSession (sessionToken) { + return null + } + + async function createVerificationRequest ( + identifer, + url, + token, + secret, + provider + ) { + return null + } + + async function getVerificationRequest ( + identifer, + token, + secret, + provider + ) { + return null + } + + async function deleteVerificationRequest ( + identifer, + token, + secret, + provider + ) { + return null + } + + return Promise.resolve({ + createUser, + getUser, + getUserByEmail, + getUserByProviderAccountId, + getUserByCredentials, + updateUser, + deleteUser, + linkAccount, + unlinkAccount, + createSession, + getSession, + updateSession, + deleteSession, + createVerificationRequest, + getVerificationRequest, + deleteVerificationRequest + }) + } + + return { + getAdapter + } +} + +export default { + Adapter +} +``` \ No newline at end of file diff --git a/www/docs/options/advanced-options.md b/www/docs/options/advanced-options.md index 938c4461ea..f877e94a9e 100644 --- a/www/docs/options/advanced-options.md +++ b/www/docs/options/advanced-options.md @@ -3,11 +3,11 @@ id: advanced-options title: Advanced Options --- -Advanced options are passed the same way as basic options, but have more complex behaviour or potentially complex implications if they are used. - -Most advanced options are not recommended, not supported and may change in future releases. +:::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. +::: -## Options +--- ### basePath @@ -28,39 +28,9 @@ module.exports = { } ``` -This is required because the NextAuth.js API route is a seperate codepath to the NextAuth.js Client. As long as you specify this option in an environment variable, the client will be able to pick up any subsequent configuration from the server. - ---- - -### callbackUrlHandler - -* **Default value**: `function` -* **Required**: *No* - -#### Description - -To ensure site security, byt default `callbackUrlHandler` only allows callbackUrls for signup and signout to be at the same site as the one being signed into. - -e.g. if the sign in URL was `https://example.com/api/auth/signin`: - -* ✅ `https://example.com/path/to/page` -* ❌ `http://example.com/path/to/page` -* ❌ `https://subdomain.example.com/path/to/page` -* ❌ `https://example.com:8080/path/to/page` +This is required because the NextAuth.js API route is a seperate codepath to the NextAuth.js Client. -If the URL is not allowed, the callback URL will be set to whatever the `site` option is (e.g.`https://example.com/`) - -```js -const callbackUrlHandler = async (url, options) => { - if (url.startsWith(options.site)) { - return Promise.resolve(url) - } else { - return Promise.resolve(options.site) - } -} -``` - -If you want to support signing in to sites across other domains, you can pass your own function to `callbackUrlHandler` to customise this behaviour. +As long as you also specify this option in an environment variable, the client will be able to pick up any subsequent configuration from the server, but if you do not set in both it the NextAuth.js Client will not work. --- @@ -71,7 +41,7 @@ If you want to support signing in to sites across other domains, you can pass yo #### Description -A custom provider is an advanced option to use only if you need to use NextAuth.js with a database configuration that is not supported by the default `database` option. +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](/options/adapter) for more information. @@ -79,10 +49,6 @@ See the [adapter documentation](/options/adapter) for more information. If the `adapter` option is specified it overrides the `database` option. ::: -:::info -This option is not yet well documented. We are not currently able to provide support for this option. In future the goal is to provide first party and third party adapters to integrate with a wide range of platforms. -::: - --- ### useSecureCookies @@ -117,7 +83,7 @@ Setting this option to *false* in production is a security risk and may allow se You can override the default cookie names and options for any of the cookies used by NextAuth.js. -This is an advanced option and using it is not recommended. +This is an advanced option and using it is not recommended as you may break authentication or introduce security flaws into your application. You can specify one or more cookies with custom properties, but if you specify custom options for a cookie you must provided all the options for it. You will also likely want to create condtional behaviour to support local development (e.g. setting `secure: false` and not using cookie prefixes on localhost URLs). @@ -164,5 +130,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 currently recommended. +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. ::: \ No newline at end of file diff --git a/www/docs/options/basic-options.md b/www/docs/options/basic-options.md index 05ff47a7fd..13cf61d5c2 100644 --- a/www/docs/options/basic-options.md +++ b/www/docs/options/basic-options.md @@ -2,13 +2,15 @@ id: basic-options title: Basic Options --- -Configuration options are passed to NextAuth.js when initalizing in your API route. -The only *required* options are **site**, **providers** and **database**. +:::note +* Configuration options are passed to NextAuth.js when initalizing it in an API route. +* The only *required* options are **site** and **providers**. +::: -## Options +--- -### site +## site * **Default value**: `empty string` * **Required**: *Yes* @@ -21,7 +23,7 @@ e.g. `http://localhost:3000` or `https://www.example.com` --- -### providers +## providers * **Default value**: `[]` * **Required**: *Yes* @@ -34,10 +36,10 @@ See the [providers documentation](/options/providers) for a list of supported pr --- -### database +## database * **Default value**: `null` -* **Required**: *Yes* +* **Required**: *No* (Except by Email provider) #### Description @@ -49,11 +51,15 @@ The default database provider is also compatible with other ANSI SQL compatible NextAuth.js can can be used with any database by specifying a custom `adapter` option. +:::tip +The Email provider currently requires a database to be configured. +::: + --- -### secret +## secret -* **Default value**: *SHA Hash of Options* +* **Default value**: `string` (*SHA hash of the "options" object*) * **Required**: *No* (but strongly recommended) #### Description @@ -62,132 +68,210 @@ A random string used to hash tokens and sign cookies (e.g. SHA hash). If not provided will be auto-generated based on hash of all your provided options. -The default behaviour is secure, but volatile, and it is strongly recommended you explicitly specify a value for your secret to avoid invalidating any tokens when the automatically generated hash changes. +The default behaviour is secure, but volatile, and it is strongly recommended you explicitly specify a value for secret to avoid invalidating any tokens when the automatically generated hash changes. --- -### jwt +## session -* **Default value**: `false` +* **Default value**: `object` * **Required**: *No* -#### Description - -If set to `true` will use client side JSON Web Token instead of the `session` table in the database. - -The JWT is signed with `HMAC SHA256` and includes the user profile, the provider account they signed in with and the session expiry (which is also set on the cookie and on the JWT expiry property). +#### Description -From a NextAuth.js perspective it works just like a database based session, but is faster and cheaper to run! +The `session` object and all properties on it are optional. -This option works great combined with serverless and a cloud database for persisting users accounts. +Default values for this option are shown below: -``` -{ - nextauth: { - user: { - name: 'Iain Collins', - email: 'me@iaincollins.com', - image: 'https://example.com/image.jpg', - id: 1 - }, - sessionExpires: '2020-07-03T02:18:55.574Z', - accessToken: '540c2f7669e4e72a1b0a167cc81d34a488f9cf2018fd9f7e5fc0639fa0ee3241', - account: { - provider: 'google', - type: 'oauth', - id: 3218529, - refreshToken: 'cc0d32d79145091cd6cd8979f0a6d6b67d490899', - accessToken: '931400799b4a980715bb55af1bb8e01d92316956', - accessTokenExpires: null - }, - isNewUser: true - }, - iat: 1591150735, - exp: 4183150735 +```js +session: { + // Use JSON Web Tokens for session instead of database sessions. + // This option can be used with or without a database for users/accounts. + // Note: `jwt` is automatically set to `true` if no database is specified. + jwt: false, + + // Seconds - How long until an idle session expires and is no longer valid. + maxAge: 30 * 24 * 60 * 60, // 30 days + + // Seconds - Throttle how frequently to write to database to extend a session. + // Use it to limit write operations. Set to 0 to always update the database. + // Note: This option is ignored if using JSON Web Tokens + updateAge: 24 * 60 * 60, // 24 hours + + // Easily add custom properties to response from `/api/auth/session`. + // Note: This should not return any sensitive information. + /* + get: async (session) => { + session.customSessionProperty = "ABC123" + return session + } + */ } ``` -:::tip -Enable the debug option with **debug: true** to view contents of the decoded JWT on the console. -::: +--- -:::note -The JWT is stored in the Session Token cookie – the same cookie used for database sessions. -::: +## jwt ---- +* **Default value**: `object` +* **Required**: *No* -### jwtSecret +#### Description -* **Default value**: *set to contents of `secret` by default* -* **Required**: *No* (but strongly recommended if using JWT) +The `jwt` object and all properties on it are optional. -#### Description +When enabled, JSON Web Tokens is signed with `HMAC SHA256` and encrypted with symmetric `AES`. -A secret key used to sign JWT tokens. This should be specified if using JSON Web Tokens. +Using JWT to store sessions is often faster, cheaper and more scaleable relying on a database. -If not set defaults to the value of `secret` - which is auto-generated if not defined. +Default values for this option are shown below: ---- +```js +jwt: { + // secret: 'my-secret-123', // Secret auto-generated if not specified. + + // Custom encode/decode functions for signing + encryption can be specified. + // if you want to override what is in the JWT or how it is signed. + // encode: async ({ secret, key, token, maxAge }) => {}, + // decode: async ({ secret, key, token, maxAge }) => {}, + + // Easily add custom to the JWT. It is updated every time it is accessed. + // This encrypted and signed by default and may contain sensitive information + // as long as a reasonable secret is defined. + /* + set: async (token) => { + token.customJwtProperty = "ABC123" + return token + } + */ +} +``` -### sessionMaxAge +An example JSON WebToken contains an encrypted payload like this: -* **Default value**: `30*24*60*60*1000` (30 days) -* **Required**: *No* +```js +{ + user: { + name: 'Iain Collins', + email: 'me@iaincollins.com', + image: 'https://example.com/image.jpg', + id: 1 // User ID will note be specified if used without a database + }, + // The account object stores details for the authentication provider account + // that was used to sign in. It only contains exactly one account, even the + // user is linked to multiple provider accounts in a database. + account: { + provider: 'google', + type: 'oauth', + id: 3218529, + refreshToken: 'cc0d32d79145091cd6cd8979f0a6d6b67d490899', + accessToken: '931400799b4a980715bb55af1bb8e01d92316956', + accessTokenExpires: null + }, + isNewUser: true, // Is set to true if is first sign in + iat: 1591150735, // Issued at + exp: 4183150735 // Expires in +} +``` -#### Description +You can use the built-in JWT decode/encode function from a custom API route, like this: -How long sessions can be idle before they expire and the user has to sign in again. +```js +import jwt from 'next-auth/jwt' + +export default async (req, res) => { + // Load encrypted (and signed) token from cookie + const useSecureCookiePrefix = process.env.NODE_ENV === 'production' + const cookieName = `${ useSecureCookiePrefix ? '__Secure-' : '' }next-auth.session-token` + const sessionToken = req.cookies[cookieName] + + if (!sessionToken) { return res.end(`No JWT token`) } + + try { + // Decrypt and verify token + const token = await jwt.decode({ secret: 'my-secret-123', token: sessionToken }) + res.end(`Contents of JWT:\n${JSON.stringify(token, null, 2)}`) + } catch (error) { + console.error(error) + res.end('Unable to decode JWT') + } +} +``` -:::tip -If using JSON Web Tokens you may wish to set **sessionMaxAge** to a shorter value, as unlike a database session, the sessions cannot be 'expired' server side to force someone to be sign out. +:::note +The JWT is stored in the Session Token cookie – the same cookie used for database sessions. ::: --- -### sessionUpdateAge +## allowSignin -* **Default value**: `24*60*60*1000` (24 hours) +* **Default value**: `function` * **Required**: *No* #### Description -How frequently the session expiry should be updated in the database. +The `allowSignin` option allows you to define a function that determines if a user / account can sign in. -It effectively throttles database writes for sessions, which can improve performance and reduce costs. +The function is passed a user and account object associated with the the provider the client has used. -It should always be less than `sessionMaxAge`. +If the function returns `true` the user can sign in, if it returns `false` an access denied message is displayed. -*For example:* +This works with all providers (OAuth and Email) and both with and without databases. -If `sessionMaxAge` specifies a session is valid for up to 30 days and `sessionUpdateAge` is set to 24 hours, and a session was last updated less than 24 hours ago, then the session expiry time on the active session would not be updated. +It is useful to control access to dashboards/admin pages without requiring a user database. -However, if a session that was active was last updated more than 24 hours ago, then the session expiry time *would* be updated in the session database. +Example: -:::tip -If you have a short session max age (e.g. < 24 hours) or if you want to be able to track what sessions have been active recently by querying the session database, you can set **sessionUpdateAge** to **0** to create a rolling session that always extends the session expiry any time a session is active. -::: +```js +allowSignin: async (user, account) => { + // Return true if user / account is allowed to sign in. + // Return false to display an access denied message. + return true +} +``` -:::note -If using JSON Web Tokens **sessionUpdateAge** is ignored – they are always updated when accessed. +:::warning +You should not rely on the email address alone unless that provider is trusted and has supplied a verified email address. e.g. The built-in Google provider is configured to only return verified email addresses. ::: --- -### verificationMaxAge +## allowCallbackUrl -* **Default value**: `24*60*60*1000` (24 hours) +* **Default value**: `function` * **Required**: *No* #### Description -How long links in verification emails are valid for. +By default `allowCallbackUrl` only allows callbackUrls for signup and signout to be at the same site as the one being signed into. + +e.g. if the sign in URL was `https://example.com/api/auth/signin` the following logic would apply: + +* ✅ `https://example.com/path/to/page` +* ❌ `http://example.com/path/to/page` +* ❌ `https://subdomain.example.com/path/to/page` +* ❌ `https://example.com:8080/path/to/page` + +If the URL is not allowed, the callback URL will be set to whatever the `site` option is set to. + +The default function looks like this: + +```js +allowCallbackUrl: async (url, site) => { + if (url.startsWith(site)) { + return Promise.resolve(url) + } else { + return Promise.resolve(site) + } +} +``` -These are used for passwordless sign in via email. +If you want to support signing in to sites across other domains or hostnames you can pass your own function to `allowCallbackUrl` to customise the behaviour. --- -### pages +## pages * **Default value**: `{}` * **Required**: *No* @@ -214,7 +298,7 @@ See the documentation for the [pages option](/options/pages) for more informatio --- -### debug +## debug * **Default value**: `false` * **Required**: *No* diff --git a/www/src/css/custom.css b/www/src/css/custom.css index eb0bb1df6d..b7fb528fa4 100644 --- a/www/src/css/custom.css +++ b/www/src/css/custom.css @@ -27,6 +27,12 @@ html[data-theme='dark'] { background: #191A1B; } +@media screen and (max-width: 360px) { + html { + font-size: 0.8rem; + } +} + a, .navbar .navbar__link { font-weight: 600; @@ -47,13 +53,24 @@ a, border-radius: 2rem; } +.navbar { + box-shadow: 0 0 .5rem rgba(0,0,0,.2) +} + + .navbar__title { font-size: 1.2rem; margin-left: .2rem; } -.navbar { - box-shadow: 0 0 .5rem rgba(0,0,0,.2) +.navbar__logo { + display: none; +} + +@media screen and (min-width: 376px) { + .navbar__logo { + display: inline; + } } .hero { @@ -92,11 +109,6 @@ a, font-style: italic; } -.home-main h2 { - line-height: 3.5rem; - margin: 0.5rem 0; -} - .home-main .code { padding: 0; height: 100%; diff --git a/www/src/pages/index.js b/www/src/pages/index.js index 171279c0e3..a6fab3561c 100644 --- a/www/src/pages/index.js +++ b/www/src/pages/index.js @@ -16,10 +16,10 @@ const features = [

  • Designed for Next.js and Serverless
  • - Supports Bring Your Own Database
    + Bring Your Own Database – or no database!
    (MySQL, MariaDB, Postgres, MongoDB…)
  • -
  • Use database sessions or JSON Web Tokens
  • +
  • JSON Web Tokens or Session Database
) }, @@ -62,7 +62,7 @@ function Feature ({ imageUrl, title, description }) {
)} -

{title}

+

{title}

{description}

) @@ -81,12 +81,7 @@ function Home () { alt='Shield with key icon' className={styles.heroLogo} /> -
+

{siteConfig.title}

{siteConfig.tagline}

@@ -113,6 +108,9 @@ function Home () {
+

+ Full stack open source authentication +

{features.map((props, idx) => ( @@ -216,8 +214,7 @@ const options = { from: '' }), ], - database: process.env.DATABASE_URL, - jwt: true // Enables JSON Web Tokens + database: process.env.DATABASE_URL // Optional } export default (req, res) => NextAuth(req, res, options) diff --git a/www/src/pages/seo.js b/www/src/pages/seo.js index c45c623a6f..a4bf132b46 100644 --- a/www/src/pages/seo.js +++ b/www/src/pages/seo.js @@ -20,9 +20,9 @@ const Seo = () => { - - - + + + ) } diff --git a/www/src/pages/styles.module.css b/www/src/pages/styles.module.css index dd6ee9a318..89172c595f 100644 --- a/www/src/pages/styles.module.css +++ b/www/src/pages/styles.module.css @@ -18,16 +18,24 @@ } .heroLogo { - margin-bottom: -.5rem; + margin-bottom: .5rem; width: 6rem; } @media screen and (min-width: 668px) { .heroLogo { + margin-bottom: -.5rem; width: 8rem; } } +@media screen and (min-width: 668px) { + .heroText { + display: inline-block; + margin: 1rem 1.5rem 0 1.5rem; + } +} + .buttons { display: flex; align-items: center; @@ -43,10 +51,21 @@ display: flex; align-items: center; padding-top: 2rem; - margin: 2rem auto; + margin: 2rem auto 4rem auto; width: 100%; } +.features h2 { + font-size: 2rem; + margin: 2rem 0 4rem 0; +} + +.features h3 { + font-size: 1.5rem; + line-height: 3.5rem; + margin: 1rem 0 0 0; +} + .features ul { list-style: none; padding: 0;