Created
December 21, 2020 12:32
-
-
Save jonaswouters/5b93f29a24075ea0b929a988c3b12206 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createHash, randomBytes } from 'crypto' | |
const logger = { | |
error: (errorCode, ...text) => { | |
if (!console) { | |
return | |
} | |
if (text && text.length <= 1) { | |
text = text[0] || '' | |
} | |
console.error( | |
`[next-auth][error][${errorCode.toLowerCase()}]`, | |
text, | |
`\nhttps://next-auth.js.org/errors#${errorCode.toLowerCase()}` | |
) | |
}, | |
warn: (warnCode, ...text) => { | |
if (!console) { | |
return | |
} | |
if (text && text.length <= 1) { | |
text = text[0] || '' | |
} | |
console.warn( | |
`[next-auth][warn][${warnCode.toLowerCase()}]`, | |
text, | |
`\nhttps://next-auth.js.org/warnings#${warnCode.toLowerCase()}` | |
) | |
}, | |
debug: (debugCode, ...text) => { | |
if (!console) { | |
return | |
} | |
if (text && text.length <= 1) { | |
text = text[0] || '' | |
} | |
if (process && process.env && process.env._NEXTAUTH_DEBUG) { | |
console.log(`[next-auth][debug][${debugCode.toLowerCase()}]`, text) | |
} | |
} | |
} | |
class UnknownError extends Error { | |
constructor(message) { | |
super(message) | |
this.name = 'UnknownError' | |
this.message = message | |
} | |
toJSON() { | |
return { | |
error: { | |
name: this.name, | |
message: this.message | |
// stack: this.stack | |
} | |
} | |
} | |
} | |
class CreateUserError extends UnknownError { | |
constructor(message) { | |
super(message) | |
this.name = 'CreateUserError' | |
this.message = message | |
} | |
} | |
const Adapter = (config) => { | |
const { | |
prisma, | |
modelMapping = { | |
User: 'user', | |
Account: 'account', | |
Session: 'session', | |
VerificationRequest: 'verificationRequest' | |
} | |
} = config | |
const { User, Account, Session, VerificationRequest } = modelMapping | |
function getCompoundId(providerId, providerAccountId) { | |
return createHash('sha256') | |
.update(`${providerId}:${providerAccountId}`) | |
.digest('hex') | |
} | |
async function getAdapter(appOptions) { | |
function debug(debugCode, ...args) { | |
logger.debug(`PRISMA_${debugCode}`, ...args) | |
} | |
if (appOptions && (!appOptions.session || !appOptions.session.maxAge)) { | |
debug( | |
'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) { | |
debug('CREATE_USER', profile) | |
try { | |
return prisma[User].create({ | |
data: { | |
name: profile.name, | |
email: profile.email, | |
image: profile.image, | |
emailVerified: profile.emailVerified | |
? profile.emailVerified.toISOString() | |
: null | |
} | |
}) | |
} catch (error) { | |
logger.error('CREATE_USER_ERROR', error) | |
return Promise.reject(new CreateUserError(error)) | |
} | |
} | |
async function getUser(id) { | |
debug('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) { | |
debug('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) { | |
debug('GET_USER_BY_PROVIDER_ACCOUNT_ID', providerId, providerAccountId) | |
try { | |
const account = await prisma[Account].findOne({ | |
where: { compoundId: getCompoundId(providerId, providerAccountId) } | |
}) | |
if (!account) { | |
return null | |
} | |
return prisma[User].findOne({ where: { id: account.userId } }) | |
} 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) { | |
debug('UPDATE_USER', user) | |
try { | |
const { id, name, email, image, emailVerified } = user | |
return prisma[User].update({ | |
where: { id }, | |
data: { | |
name, | |
email, | |
image, | |
emailVerified: emailVerified ? emailVerified.toISOString() : null | |
} | |
}) | |
} catch (error) { | |
logger.error('UPDATE_USER_ERROR', error) | |
return Promise.reject(new Error('UPDATE_USER_ERROR', error)) | |
} | |
} | |
async function deleteUser(userId) { | |
debug('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 | |
) { | |
debug( | |
'LINK_ACCOUNT', | |
userId, | |
providerId, | |
providerType, | |
providerAccountId, | |
refreshToken, | |
accessToken, | |
accessTokenExpires | |
) | |
try { | |
return prisma[Account].create({ | |
data: { | |
accessToken, | |
refreshToken, | |
compoundId: getCompoundId(providerId, providerAccountId), | |
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) { | |
debug('UNLINK_ACCOUNT', userId, providerId, providerAccountId) | |
try { | |
return prisma[Account].delete({ | |
where: { compoundId: getCompoundId(providerId, providerAccountId) } | |
}) | |
} catch (error) { | |
logger.error('UNLINK_ACCOUNT_ERROR', error) | |
return Promise.reject(new Error('UNLINK_ACCOUNT_ERROR', error)) | |
} | |
} | |
async function createSession(user) { | |
debug('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 | |
} | |
}, | |
sessionToken: randomBytes(32).toString('hex'), | |
accessToken: randomBytes(32).toString('hex') | |
} | |
}) | |
} catch (error) { | |
logger.error('CREATE_SESSION_ERROR', error) | |
return Promise.reject(new Error('CREATE_SESSION_ERROR', error)) | |
} | |
} | |
async function getSession(sessionToken) { | |
debug('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) { | |
debug('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, expires } = session | |
return prisma[Session].update({ where: { id }, data: { expires } }) | |
} catch (error) { | |
logger.error('UPDATE_SESSION_ERROR', error) | |
return Promise.reject(new Error('UPDATE_SESSION_ERROR', error)) | |
} | |
} | |
async function deleteSession(sessionToken) { | |
debug('DELETE_SESSION', sessionToken) | |
try { | |
return prisma[Session].delete({ where: { sessionToken: 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 | |
) { | |
debug('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) { | |
debug('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 | |
) { | |
debug('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 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment