Skip to content

Instantly share code, notes, and snippets.

@drejohnson
Created January 24, 2021 04:50
Show Gist options
  • Save drejohnson/ae884cb2ded9ebc260a77056adbbb8e5 to your computer and use it in GitHub Desktop.
Save drejohnson/ae884cb2ded9ebc260a77056adbbb8e5 to your computer and use it in GitHub Desktop.
faunadb adapter for next-auth in typescript
import { query as q } from 'faunadb';
import { createHash, randomBytes } from 'crypto';
import type {
EmailSessionProvider,
Profile,
Session,
} from 'next-auth/adapters';
import type { AppOptions, User } from 'next-auth';
import { SessionProvider } from 'next-auth/client';
type CollectionsAndIndexes = {
User: string;
Account: string;
Session: string;
VerificationRequest: string;
};
type Config = {
faunaClient: any;
collections: CollectionsAndIndexes;
indexes: CollectionsAndIndexes;
};
type _User = User & {
id: string;
emailVerified: boolean;
};
type _Profile = Profile & {
emailVerified: boolean;
};
type _Session = Session & {
id: string;
};
const Adapter = (config: Config, options = {}) => {
const {
faunaClient,
collections = {
User: 'users',
Account: 'accounts',
Session: 'sessions',
VerificationRequest: 'verification_requests',
},
indexes = {
Account: 'account_by_provider_account_id',
User: 'user_by_email',
Session: 'session_by_token',
VerificationRequest: 'verification_request_by_token',
},
} = config;
async function getAdapter(appOptions: AppOptions) {
function _debug(debugCode: string, ...args: any[]) {
// console.info(`fauna_${debugCode}`, ...args)
}
const defaultSessionMaxAge = 30 * 24 * 60 * 60 * 1000;
const sessionMaxAge = appOptions.session?.maxAge
? appOptions.session.maxAge * 1000
: defaultSessionMaxAge;
const sessionUpdateAge = appOptions.session?.updateAge
? appOptions.session.updateAge * 1000
: 0;
async function createUser(profile: _Profile) {
_debug('createUser', profile);
const timestamp = new Date().toISOString();
const FQL = q.Create(q.Collection(collections.User), {
data: {
name: profile.name,
email: profile.email,
image: profile.image,
emailVerified: profile.emailVerified ? profile.emailVerified : false,
createdAt: q.Time(timestamp),
updatedAt: q.Time(timestamp),
},
});
try {
const newUser = await faunaClient.query(FQL);
newUser.data.id = newUser.ref.id;
return newUser.data;
} catch (error) {
console.error('CREATE_USER', error);
return Promise.reject(new Error('CREATE_USER'));
}
}
async function getUser(id: string) {
_debug('getUser', id);
const FQL = q.Get(q.Ref(q.Collection(collections.User), id));
try {
const user = await faunaClient.query(FQL);
user.data.id = user.ref.id;
return user.data;
} catch (error) {
console.error('GET_USER', error);
return Promise.reject(new Error('GET_USER'));
}
}
async function getUserByEmail(email: string) {
_debug('getUserByEmail', email);
if (!email) {
return null;
}
const FQL = q.Let(
{
ref: q.Match(q.Index(indexes.User), email),
},
q.If(q.Exists(q.Var('ref')), q.Get(q.Var('ref')), null)
);
try {
const user = await faunaClient.query(FQL);
if (user == null) {
return null;
}
user.data.id = user.ref.id;
return user.data;
} catch (error) {
console.error('GET_USER_BY_EMAIL', error);
return Promise.reject(new Error('GET_USER_BY_EMAIL'));
}
}
async function getUserByProviderAccountId(
providerId: string,
providerAccountId: string
) {
_debug('getUserByProviderAccountId', providerId, providerAccountId);
const FQL = q.Let(
{
ref: q.Match(q.Index(indexes.Account), [
providerId,
providerAccountId,
]),
},
q.If(
q.Exists(q.Var('ref')),
q.Get(
q.Ref(
q.Collection(collections.User),
q.Select(['data', 'userId'], q.Get(q.Var('ref')))
)
),
null
)
);
try {
const user = await faunaClient.query(FQL);
if (user == null) {
return null;
}
user.data.id = user.ref.id;
return user.data;
} catch (error) {
console.error('GET_USER_BY_PROVIDER_ACCOUNT_ID', error);
return Promise.reject(new Error('GET_USER_BY_PROVIDER_ACCOUNT_ID'));
}
}
async function updateUser(user: _User) {
_debug('updateUser', user);
const timestamp = new Date().toISOString();
const FQL = q.Update(q.Ref(q.Collection(collections.User), user.id), {
data: {
name: user.name,
email: user.email,
image: user.image,
emailVerified: user.emailVerified ? user.emailVerified : false,
updatedAt: q.Time(timestamp),
},
});
try {
const user = await faunaClient.query(FQL);
user.data.id = user.ref.id;
return user.data;
} catch (error) {
console.error('UPDATE_USER_ERROR', error);
return Promise.reject(new Error('UPDATE_USER_ERROR'));
}
}
async function deleteUser(userId: string) {
_debug('deleteUser', userId);
const FQL = q.Delete(q.Ref(q.Collection(collections.User), userId));
try {
await faunaClient.query(FQL);
} catch (error) {
console.error('DELETE_USER_ERROR', error);
return Promise.reject(new Error('DELETE_USER_ERROR'));
}
}
async function linkAccount(
userId: string,
providerId: string,
providerType: string,
providerAccountId: string,
refreshToken: string,
accessToken: string,
accessTokenExpires: number
) {
_debug(
'linkAccount',
userId,
providerId,
providerType,
providerAccountId,
refreshToken,
accessToken,
accessTokenExpires
);
try {
const timestamp = new Date().toISOString();
const account = await faunaClient.query(
q.Create(q.Collection(collections.Account), {
data: {
userId: userId,
providerId: providerId,
providerType: providerType,
providerAccountId: providerAccountId,
refreshToken: refreshToken,
accessToken: accessToken,
accessTokenExpires: accessTokenExpires,
createdAt: q.Time(timestamp),
updatedAt: q.Time(timestamp),
},
})
);
return account.data;
} catch (error) {
console.error('LINK_ACCOUNT_ERROR', error);
return Promise.reject(new Error('LINK_ACCOUNT_ERROR'));
}
}
async function unlinkAccount(
userId: string,
providerId: string,
providerAccountId: string
) {
_debug('unlinkAccount', userId, providerId, providerAccountId);
const FQL = q.Delete(
q.Select(
'ref',
q.Get(
q.Match(q.Index(indexes.Account), [providerId, providerAccountId])
)
)
);
try {
await faunaClient.query(FQL);
} catch (error) {
console.error('UNLINK_ACCOUNT_ERROR', error);
return Promise.reject(new Error('UNLINK_ACCOUNT_ERROR'));
}
}
async function createSession(user: _User) {
_debug('createSession', user);
let expires = null;
if (sessionMaxAge) {
const dateExpires = new Date();
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge);
expires = dateExpires.toISOString();
}
const timestamp = new Date().toISOString();
const FQL = q.Create(q.Collection(collections.Session), {
data: {
userId: user.id,
expires: q.Time(expires!),
sessionToken: randomBytes(32).toString('hex'),
accessToken: randomBytes(32).toString('hex'),
createdAt: q.Time(timestamp),
updatedAt: q.Time(timestamp),
},
});
try {
const session = await faunaClient.query(FQL);
session.data.id = session.ref.id;
return session.data;
} catch (error) {
console.error('CREATE_SESSION_ERROR', error);
return Promise.reject(new Error('CREATE_SESSION_ERROR'));
}
}
async function getSession(sessionToken: string) {
_debug('getSession', sessionToken);
try {
var sessionFQL = q.Get(q.Match(q.Index(indexes.Session), sessionToken));
const session = await faunaClient.query({
id: q.Select(['ref', 'id'], sessionFQL),
userId: q.Select(['data', 'userId'], sessionFQL),
expires: q.ToMillis(q.Select(['data', 'expires'], sessionFQL)),
sessionToken: q.Select(['data', 'sessionToken'], sessionFQL),
accessToken: q.Select(['data', 'accessToken'], sessionFQL),
createdAt: q.ToMillis(q.Select(['data', 'createdAt'], sessionFQL)),
updatedAt: q.ToMillis(q.Select(['data', 'updatedAt'], sessionFQL)),
});
// Check session has not expired (do not return it if it has)
if (session && session.expires && new Date() > session.expires) {
await _deleteSession(sessionToken);
return null;
}
return session;
} catch (error) {
console.error('GET_SESSION_ERROR', error);
return Promise.reject(new Error('GET_SESSION_ERROR'));
}
}
async function updateSession(session: _Session, force: boolean) {
_debug('updateSession', session);
try {
const shouldUpdate =
sessionMaxAge &&
(sessionUpdateAge || sessionUpdateAge === 0) &&
session.expires;
if (!shouldUpdate && !force) {
return null;
}
// 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
const currentDate = new Date();
if (currentDate < dateSessionIsDueToBeUpdated && !force) {
return null;
}
const newExpiryDate = new Date();
newExpiryDate.setTime(newExpiryDate.getTime() + sessionMaxAge);
const updatedSession = await faunaClient.query(
q.Update(q.Ref(q.Collection(collections.Session), session.id), {
data: {
expires: q.Time(newExpiryDate.toISOString()),
updatedAt: q.Time(new Date().toISOString()),
},
})
);
updatedSession.data.id = updatedSession.ref.id;
return updatedSession.data;
} catch (error) {
console.error('UPDATE_SESSION_ERROR', error);
return Promise.reject(new Error('UPDATE_SESSION_ERROR'));
}
}
async function _deleteSession(sessionToken: string) {
const FQL = q.Delete(
q.Select('ref', q.Get(q.Match(q.Index(indexes.Session), sessionToken)))
);
return faunaClient.query(FQL);
}
async function deleteSession(sessionToken: string) {
_debug('deleteSession', sessionToken);
try {
return await _deleteSession(sessionToken);
} catch (error) {
console.error('DELETE_SESSION_ERROR', error);
return Promise.reject(new Error('DELETE_SESSION_ERROR'));
}
}
async function createVerificationRequest(
identifier: string,
url: string,
token: string,
secret: string,
provider: EmailSessionProvider
) {
_debug('createVerificationRequest', identifier);
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();
}
const timestamp = new Date().toISOString();
const FQL = q.Create(q.Collection(collections.VerificationRequest), {
data: {
identifier: identifier,
token: hashedToken,
expires: expires === null ? null : q.Time(expires),
createdAt: q.Time(timestamp),
updatedAt: q.Time(timestamp),
},
});
try {
const verificationRequest = await faunaClient.query(FQL);
// 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.data;
} catch (error) {
console.error('CREATE_VERIFICATION_REQUEST_ERROR', error);
return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR'));
}
}
async function getVerificationRequest(
identifier: string,
token: string,
secret: string,
provider: SessionProvider
) {
_debug('getVerificationRequest', identifier, token);
const hashedToken = createHash('sha256')
.update(`${token}${secret}`)
.digest('hex');
const FQL = q.Let(
{
ref: q.Match(q.Index(indexes.VerificationRequest), hashedToken),
},
q.If(
q.Exists(q.Var('ref')),
{
ref: q.Var('ref'),
request: q.Select('data', q.Get(q.Var('ref'))),
},
null
)
);
try {
const { ref, request: verificationRequest } = await faunaClient.query(
FQL
);
const nowDate = Date.now();
if (
verificationRequest &&
verificationRequest.expires &&
verificationRequest.expires < nowDate
) {
// Delete the expired request so it cannot be used
await faunaClient.query(q.Delete(ref));
return null;
}
return verificationRequest;
} catch (error) {
console.error('GET_VERIFICATION_REQUEST_ERROR', error);
return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR'));
}
}
async function deleteVerificationRequest(
identifier: string,
token: string,
secret: string,
provider: SessionProvider
) {
_debug('deleteVerification', identifier, token);
const hashedToken = createHash('sha256')
.update(`${token}${secret}`)
.digest('hex');
const FQL = q.Delete(
q.Select(
'ref',
q.Get(q.Match(q.Index(indexes.VerificationRequest), hashedToken))
)
);
try {
await faunaClient.query(FQL);
} catch (error) {
console.error('DELETE_VERIFICATION_REQUEST_ERROR', error);
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR'));
}
}
return Promise.resolve({
createUser,
getUser,
getUserByEmail,
getUserByProviderAccountId,
updateUser,
deleteUser,
linkAccount,
unlinkAccount,
createSession,
getSession,
updateSession,
deleteSession,
createVerificationRequest,
getVerificationRequest,
deleteVerificationRequest,
});
}
return {
getAdapter,
};
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment