Skip to content

Commit

Permalink
Refactor JWT, Sessions and add allowSignin() method (nextauthjs#223)
Browse files Browse the repository at this point in the history
## Database

- [x] Databases are now optional - useful with OAuth + JWT if you only need access control
- [x] Updated documentation and added example code for custom database adapters

## JWT

- [x] JWT option is now an object that groups JWT related options together (was a boolean)
- [X] Refactored JWT lib and add AES encryption / decryption as well as signing / verification
- [x] Allows JWT encode/decode methods to be overridden as options
- [x] Contents of JWT can easily customised - without needing to use custom encode/decode
- [x] Exported JWT methods so they can be called from custom API routes
- [x] Updated documentation for new JWT options

## Sessions

- [x] All session options (eg. `maxAge`, `updateAge`) now grouped under single `session` option
- [x] Using JWT for sessions is now enabled from session object (`session.jwt: true`)
- [x] All options involving time now use seconds (instead of milliseconds) for consistency
- [x] Added option to customise the Session object that is returned from `/api/auth/session`
- [x] Update documentation for new Session options

## Other improvements

- [x] Added `allowSignin()` option to control what users / accounts are allowed to sign in
- [x] Refactored `callbackUrlHandler()` - this option  is now called `allowCallbackUrl()` 
- [x] Minor improvements to NextAuth.js client API methods
- [x] Minor to NextAuth.js API routes
- [x] Minor improvements to built-in error pages
- [x] Refactored database models
   All tables now include a `created` column for each row which contains the `datetime` of when the row (e.g. User / Account / Session) was created.
  Additionally, sessions now use the name 'expiry' for the expiry `datetime` value for consistency with other models.
  • Loading branch information
iaincollins authored Jun 8, 2020
1 parent 35123f0 commit 0d825bb
Show file tree
Hide file tree
Showing 33 changed files with 874 additions and 374 deletions.
1 change: 1 addition & 0 deletions jwt.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require('./dist/lib/jwt').default
7 changes: 6 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <[email protected]>",
Expand Down Expand Up @@ -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",
Expand Down
117 changes: 117 additions & 0 deletions src/adapters/example/index.js
Original file line number Diff line number Diff line change
@@ -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
}
47 changes: 25 additions & 22 deletions src/adapters/typeorm/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'reflect-metadata'
import { createConnection, getConnection, getManager, EntitySchema } from 'typeorm'
import { createHash } from 'crypto'

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -92,18 +91,22 @@ 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)
const defaultConfig = {
name: 'default',
autoLoadEntities: true,
entities: [
new EntitySchema(AccountSchema),
new EntitySchema(UserSchema),
new EntitySchema(AccountSchema),
new EntitySchema(SessionSchema),
new EntitySchema(VerificationRequestSchema)
],
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -260,15 +266,14 @@ const Adapter = (config, options = {}) => {
async function createSession (user) {
_debug('createSession', user)
try {
const { sessionMaxAge } = appOptions
let expires = null
if (sessionMaxAge) {
const dateExpires = new Date()
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge)
expires = dateExpires.toISOString()
}

const session = new Session(user.id, null, expires)
const session = new Session(user.id, expires)

return getManager().save(session)
} catch (error) {
Expand All @@ -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
}
Expand All @@ -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)

Expand All @@ -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 }
}
Expand All @@ -346,18 +349,18 @@ 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.
// @TODO Use bcrypt function here instead of simple salted hash
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()
}

Expand Down
6 changes: 6 additions & 0 deletions src/adapters/typeorm/models/account.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export class Account {
this.refreshToken = refreshToken
this.accessToken = accessToken
this.accessTokenExpires = accessTokenExpires

const dateCreated = new Date()
this.created = dateCreated.toISOString()
}
}

Expand Down Expand Up @@ -59,6 +62,9 @@ export const AccountSchema = {
accessTokenExpires: {
type: 'timestamp',
nullable: true
},
created: {
type: 'timestamp'
}
}
}
16 changes: 11 additions & 5 deletions src/adapters/typeorm/models/session.js
Original file line number Diff line number Diff line change
@@ -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()
}
}

Expand All @@ -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'
}
}
}
Loading

0 comments on commit 0d825bb

Please sign in to comment.