-
-
Save s-kris/fbb9e5d7ba5e9bb3a5f7bd11f3c42b96 to your computer and use it in GitHub Desktop.
// followed from next-auth repo: https://github.com/nextauthjs/next-auth | |
// create faunadb server key | |
// create collections: users, accounts, sessions, verificationRequests | |
// create required indexes | |
import faunadb, { query as q } from 'faunadb'; | |
import { v4 as uuidv4 } from 'uuid'; | |
import { createHash, randomBytes } from 'crypto'; | |
const INDEX_USERS_ID = 'index_users_id'; | |
const INDEX_USERS_EMAIL = 'index_users_email'; | |
const INDEX_USERS_ACCOUNT_ID_PROVIDER_ID = 'index_accounts_providerId_providerAccountId'; | |
const INDEX_VERIFICATION_REQUESTS_TOKEN = 'index_verificationRequests_token'; | |
const INDEX_SESSIONS_ID = 'index_sessions_id'; | |
const INDEX_SESSIONS_SESSION_TOKEN = 'index_sessions_sessionToken'; | |
const serverClient = new faunadb.Client({ secret: process.env.FAUNADB_SERVER_KEY }); | |
function faunaWrapper(faunaQ, errorTag) { | |
try { | |
return serverClient.query(faunaQ); | |
} catch (error) { | |
console.error(errorTag, error); | |
return Promise.reject(new Error(errorTag, error)); | |
} | |
} | |
const Adapter = (config, options = {}) => { | |
async function getAdapter(appOptions) { | |
if (appOptions && (!appOptions.session || !appOptions.session.maxAge)) { | |
console.log('no default options for session'); | |
} | |
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 | |
: 24 * 60 * 60 * 1000; | |
async function createUser(profile) { | |
console.log('-----------createUser------------'); | |
// console.log(profile); | |
return faunaWrapper( | |
q.Select( | |
'data', | |
q.Create(q.Collection('users'), { | |
data: { | |
...profile, | |
emailVerified: profile.emailVerified | |
? profile.emailVerified.toISOString() | |
: null, | |
id: uuidv4(), | |
createdAt: Date.now(), | |
updatedAt: Date.now(), | |
}, | |
}), | |
), | |
'CREATE_USER_ERROR', | |
); | |
} | |
async function getUser(id) { | |
console.log('-----------getUser------------'); | |
// console.log(id); | |
return faunaWrapper( | |
q.Select('data', q.Get(q.Match(q.Index(INDEX_USERS_ID), id))), | |
'GET_USER_BY_ID_ERROR', | |
); | |
} | |
async function getUserByEmail(email) { | |
console.log('-----------getUserByEmail------------'); | |
// console.log(email); | |
return faunaWrapper( | |
q.Let( | |
{ | |
ref: q.Match(q.Index(INDEX_USERS_EMAIL), email), | |
}, | |
q.If(q.Exists(q.Var('ref')), q.Select('data', q.Get(q.Var('ref'))), null), | |
), | |
'GET_USER_BY_EMAIL_ERROR', | |
); | |
} | |
async function getUserByProviderAccountId(providerId, providerAccountId) { | |
console.log('-----------getUserByProviderAccountId------------'); | |
// console.log(providerId, providerAccountId); | |
return faunaWrapper( | |
q.Let( | |
{ | |
ref: q.Match(q.Index(INDEX_USERS_ACCOUNT_ID_PROVIDER_ID), [ | |
providerId, | |
providerAccountId, | |
]), | |
}, | |
q.If( | |
q.Exists(q.Var('ref')), | |
q.Select( | |
'data', | |
q.Get( | |
q.Match( | |
q.Index(INDEX_USERS_ID), | |
q.Select('userId', q.Select('data', q.Get(q.Var('ref')))), | |
), | |
), | |
), | |
null, | |
), | |
), | |
'GET_USER_BY_PROVIDER_ACCOUNT_ID_ERROR', | |
); | |
} | |
async function updateUser(user) { | |
console.log('-----------updateUser------------'); | |
// console.log(user); | |
return faunaWrapper( | |
q.Select( | |
'data', | |
q.Update(q.Select('ref', q.Get(q.Match(q.Index(INDEX_USERS_ID), user.id))), { | |
data: { | |
...user, | |
updatedAt: Date.now(), | |
emailVerified: user.emailVerified | |
? user.emailVerified.toISOString() | |
: null, | |
}, | |
}), | |
), | |
'UPDATE_USER_ERROR', | |
); | |
} | |
async function linkAccount( | |
userId, | |
providerId, | |
providerType, | |
providerAccountId, | |
refreshToken, | |
accessToken, | |
accessTokenExpires, | |
) { | |
console.log('-----------linkAccount------------'); | |
return faunaWrapper( | |
q.Select( | |
'data', | |
q.Create(q.Collection('accounts'), { | |
data: { | |
userId, | |
providerId, | |
providerType, | |
providerAccountId, | |
refreshToken, | |
accessToken, | |
accessTokenExpires, | |
id: uuidv4(), | |
createdAt: Date.now(), | |
updatedAt: Date.now(), | |
}, | |
}), | |
), | |
'LINK_ACCOUNT_ERROR', | |
); | |
} | |
async function createSession(user) { | |
console.log('-----------createSession------------'); | |
// console.log(user); | |
let expires = null; | |
if (sessionMaxAge) { | |
const dateExpires = new Date(); | |
dateExpires.setTime(dateExpires.getTime() + sessionMaxAge); | |
expires = dateExpires.toISOString(); | |
} | |
return faunaWrapper( | |
q.Select( | |
'data', | |
q.Create(q.Collection('sessions'), { | |
data: { | |
expires, | |
userId: user.id, | |
sessionToken: randomBytes(32).toString('hex'), | |
accessToken: randomBytes(32).toString('hex'), | |
id: uuidv4(), | |
createdAt: Date.now(), | |
updatedAt: Date.now(), | |
}, | |
}), | |
), | |
'CREATE_SESSION_ERROR', | |
); | |
} | |
async function getSession(sessionToken) { | |
console.log('-----------getSession------------'); | |
// console.log(sessionToken); | |
const session = await serverClient.query( | |
q.Let( | |
{ | |
ref: q.Match(q.Index(INDEX_SESSIONS_SESSION_TOKEN), sessionToken), | |
}, | |
q.If(q.Exists(q.Var('ref')), q.Select('data', q.Get(q.Var('ref'))), null), | |
), | |
); | |
// Check session has not expired (do not return it if it has) | |
if (session && session.expires && new Date() > session.expires) { | |
await serverClient.query( | |
q.Delete( | |
q.Select( | |
'ref', | |
q.Get(q.Match(q.Index(INDEX_SESSIONS_SESSION_TOKEN), sessionToken)), | |
), | |
), | |
); | |
return null; | |
} | |
return session; | |
} | |
async function updateSession(session, force) { | |
console.log('-----------updateSession------------'); | |
// console.log(session, force); | |
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 faunaWrapper( | |
q.Update(q.Select('ref', q.Get(q.Match(q.Index(INDEX_SESSIONS_ID), id))), { | |
data: { | |
expires, | |
updatedAt: Date.now(), | |
}, | |
}), | |
'UPDATE_SESSION_ERROR', | |
); | |
} | |
async function deleteSession(sessionToken) { | |
console.log('-----------deleteSession------------'); | |
// console.log(sessionToken); | |
return faunaWrapper( | |
q.Delete( | |
q.Select( | |
'ref', | |
q.Get(q.Match(q.Index(INDEX_SESSIONS_SESSION_TOKEN), sessionToken)), | |
), | |
), | |
'DELETE_SESSION_ERROR', | |
); | |
} | |
async function createVerificationRequest(identifier, url, token, secret, provider) { | |
console.log('-----------createVerificationRequest------------'); | |
// console.log((identifier, url, token, secret, provider)); | |
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 serverClient.query( | |
q.Create(q.Collection('verificationRequests'), { | |
data: { | |
identifier, | |
token: hashedToken, | |
expires, | |
id: uuidv4(), | |
createdAt: Date.now(), | |
updatedAt: Date.now(), | |
}, | |
}), | |
); | |
// 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) { | |
return Promise.reject(new Error('CREATE_VERIFICATION_REQUEST_ERROR', error)); | |
} | |
} | |
async function getVerificationRequest(identifier, token, secret, provider) { | |
console.log('-----------getVerificationRequest------------'); | |
// console.log((identifier, token, secret, provider)); | |
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 serverClient.query( | |
q.Let( | |
{ | |
ref: q.Match(q.Index(INDEX_VERIFICATION_REQUESTS_TOKEN), hashedToken), | |
}, | |
q.If(q.Exists(q.Var('ref')), q.Select('data', q.Get(q.Var('ref'))), null), | |
), | |
); | |
if ( | |
verificationRequest && | |
verificationRequest.expires && | |
new Date() > verificationRequest.expires | |
) { | |
// Delete verification entry so it cannot be used again | |
await serverClient.query( | |
q.Delete( | |
q.Select( | |
'ref', | |
q.Get( | |
q.Match( | |
q.Index(INDEX_VERIFICATION_REQUESTS_TOKEN), | |
hashedToken, | |
), | |
), | |
), | |
), | |
); | |
return null; | |
} | |
return verificationRequest; | |
} catch (error) { | |
console.error('GET_VERIFICATION_REQUEST_ERROR', error); | |
return Promise.reject(new Error('GET_VERIFICATION_REQUEST_ERROR', error)); | |
} | |
} | |
async function deleteVerificationRequest(identifier, token, secret, provider) { | |
console.log('-----------deleteVerificationRequest------------'); | |
try { | |
// Delete verification entry so it cannot be used again | |
const hashedToken = createHash('sha256').update(`${token}${secret}`).digest('hex'); | |
await serverClient.query( | |
q.Delete( | |
q.Select( | |
'ref', | |
q.Get(q.Match(q.Index(INDEX_VERIFICATION_REQUESTS_TOKEN), hashedToken)), | |
), | |
), | |
); | |
} catch (error) { | |
console.error('DELETE_VERIFICATION_REQUEST_ERROR', error); | |
return Promise.reject(new Error('DELETE_VERIFICATION_REQUEST_ERROR', error)); | |
} | |
} | |
return Promise.resolve({ | |
createUser, | |
getUser, | |
getUserByEmail, | |
getUserByProviderAccountId, | |
updateUser, | |
linkAccount, | |
createSession, | |
getSession, | |
updateSession, | |
deleteSession, | |
createVerificationRequest, | |
getVerificationRequest, | |
deleteVerificationRequest, | |
}); | |
} | |
return { | |
getAdapter, | |
}; | |
}; | |
export default { | |
Adapter, | |
}; |
Can you share debug log?
Here's the log with masked sensitive info
-----------getSession------------
[next-auth][error][session_error] [Unauthorized: unauthorized] {
name: 'Unauthorized',
message: 'unauthorized',
description: 'Unauthorized',
requestResult: RequestResult {
method: 'POST',
path: '',
query: null,
requestRaw: '{"let":[{"ref":{"match":{"index":"index_sessions_sessionToken"},"terms":"eyJhbGciOiJIUzUxMiJ9.************.67kw24HFARy8VjBPo_hyF7q2gybL98ZCstKzWHHmzVBSlNfQB2v1dUIgJAW9u4mLPdnJoookys3DxtR8bFBdZw"}}],"in":{"if":{"exists":{"var":"ref"}},"then":{"select":"data","from":{"get":{"var":"ref"}}},"else":null}}',
requestContent: Expr { raw: [Object] },
responseRaw: '{"errors":[{"code":"unauthorized","description":"Unauthorized"}]}',
responseContent: { errors: [Array] },
statusCode: 401,
responseHeaders: {
connection: 'keep-alive',
'content-length': '65',
'content-type': 'application/json;charset=utf-8',
date: 'Mon, 21 Sep 2020 18:16:18 GMT',
'www-authenticate': 'Basic realm="Unauthorized"',
'x-faunadb-build': '20.09.00.rc1-b2459b7',
'x-txn-time': '1600712177939529'
},
startTime: 1600712176476,
endTime: 1600712177191
}
}
https://next-auth.js.org/errors#session_error
-----------getSession------------
[next-auth][error][session_error] [Unauthorized: unauthorized] {
name: 'Unauthorized',
message: 'unauthorized',
description: 'Unauthorized',
requestResult: RequestResult {
method: 'POST',
path: '',
query: null,
requestRaw: '{"let":[{"ref":{"match":{"index":"index_sessions_sessionToken"},"terms":"eyJhbGciOiJIUzUxMiJ9.************.67kw24HFARy8VjBPo_hyF7q2gybL98ZCstKzWHHmzVBSlNfQB2v1dUIgJAW9u4mLPdnJoookys3DxtR8bFBdZw"}}],"in":{"if":{"exists":{"var":"ref"}},"then":{"select":"data","from":{"get":{"var":"ref"}}},"else":null}}',
requestContent: Expr { raw: [Object] },
responseRaw: '{"errors":[{"code":"unauthorized","description":"Unauthorized"}]}',
responseContent: { errors: [Array] },
statusCode: 401,
responseHeaders: {
connection: 'keep-alive',
'content-length': '65',
'content-type': 'application/json;charset=utf-8',
date: 'Mon, 21 Sep 2020 18:16:18 GMT',
'www-authenticate': 'Basic realm="Unauthorized"',
'x-faunadb-build': '20.09.00.rc1-b2459b7',
'x-txn-time': '1600712178188334'
},
startTime: 1600712177320,
endTime: 1600712177446
}
}
https://next-auth.js.org/errors#session_error
[next-auth][debug][profile_data] {
sub: 'auth0|************',
nickname: 'u',
name: 'u@ub.com',
picture: 'https://s.gravatar.com/avatar/************?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fu.png',
updated_at: '2020-09-19T16:51:30.437Z',
email: 'u@************.com',
email_verified: false
}
[next-auth][debug][oauth_callback_response] {
profile: {
name: 'u',
email: 'u@************.com',
image: 'https://s.gravatar.com/avatar/************?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fu.png'
},
account: {
provider: 'auth0',
type: 'oauth',
id: 'auth0|************',
refreshToken: undefined,
accessToken: 'zTL0XNl4CiF-************',
accessTokenExpires: null
},
OAuthProfile: {
sub: 'auth0|******',
nickname: 'u',
name: 'u@*******.com',
picture: 'https://s.gravatar.com/avatar/********?s=480&r=pg&d=https%3A%2F%2Fcdn.auth0.com%2Favatars%2Fu.png',
updated_at: '2020-09-19T16:51:30.437Z',
email: 'u@*******.com',
email_verified: false
}
}
-----------getUserByProviderAccountId------------
[next-auth][error][oauth_callback_handler_error] [Unauthorized: unauthorized] {
name: 'Unauthorized',
message: 'unauthorized',
description: 'Unauthorized',
requestResult: RequestResult {
method: 'POST',
path: '',
query: null,
requestRaw: '{"let":[{"ref":{"match":{"index":"index_accounts_providerId_providerAccountId"},"terms":["auth0","auth0|************"]}}],"in":{"if":{"exists":{"var":"ref"}},"then":{"select":"data","from":{"get":{"match":{"index":"index_users_id"},"terms":{"select":"userId","from":{"select":"data","from":{"get":{"var":"ref"}}}}}}},"else":null}}',
requestContent: Expr { raw: [Object] },
responseRaw: '{"errors":[{"code":"unauthorized","description":"Unauthorized"}]}',
responseContent: { errors: [Array] },
statusCode: 401,
responseHeaders: {
connection: 'keep-alive',
'content-length': '65',
'content-type': 'application/json;charset=utf-8',
date: 'Mon, 21 Sep 2020 18:16:25 GMT',
'www-authenticate': 'Basic realm="Unauthorized"',
'x-faunadb-build': '20.09.00.rc1-b2459b7',
'x-txn-time': '1600712184980623'
},
startTime: 1600712184119,
endTime: 1600712184362
}
}
https://next-auth.js.org/errors#oauth_callback_handler_error
This is unauthorized error.
Did you setup server key to connect to faunadb correctly?
Yup i did. It's the same key i use save page contents in the database.
Can you provide admin key and try now? If it works, create a new serverkey for that database and try.
Yay, it works now. Both methods you suggested seem to have fixed the issue. I don't know why the first server key didn't work .
Thanks man for the code and help.
Edit: Seems like session from both useSession&getSession returns null and each time i login with the same user, a new document is being created for that particular user again!
It would be great if you had a working demo that'd demonstrate its effective usage.
Perhaps a primitive question but I'm new to NextAuth but how exactly is this imported into a next project?
Perhaps a primitive question but I'm new to NextAuth but how exactly is this imported into a next project?
@s-kris So i modified your code a bit, mainly the indexes.
Update:
@justinwhall improved code at https://gist.github.com/waptik/b6cd259cd81f1ae90506c1452706807c
Then follow instructions mentions in the tweet that @s-kris shared
Awesome. I got there before you all got back to me mostly. It's working with facebook Oauth but is creating a new user for every sign-on. If I make the index unique I get description: 'document is not unique.',
.
Awesome. I got there before you all got back to me mostly. It's working with facebook Oauth but is creating a new user for every sign-on. If I make the index unique I get
description: 'document is not unique.',
.
Yeah, it creates new documents for both users
and accounts
collections, right? i also faced similar issues. What i did was delete all related collections and indexes and started over with the code snippet i posted. Also here is how my [...nextauth].js file look now:
...
adapter: FaunaAdapter.Adapter(),
secret: process.env.SESSION_COOKIE_SECRET,
session: {
jwt: true,
},
jwt: {
secret: process.env.JWT_SECRET, // i added it after seeing the tweet
},
...
Thanks for this! Just what I needed!
@justinwhall @waptik I'm not able to reproduce the bug? Can you confirm it?
@stefl you're welcome :)
@justinwhall @waptik I'm not able to reproduce the bug? Can you confirm it?
@stefl you're welcome :)
I can confirm that some documents(users) are not unique but i managed to fix it on my.
The initial workaround is at https://gist.github.com/waptik/b6cd259cd81f1ae90506c1452706807c and i'm using it here https://github.com/waptik/locate-me-gh/blob/location-page/utils/nextauth/faunaAdapter.ts .
I don't remember what caused it though.
Okay. I'm not able to reproduce the issue. I have re-tested with new project.
sure no problem. But i can guess that it had to do with how the collections and indexes were created. Yeah now that i think about it, it surely had to do with how the indexes were created but not your code.
Thanks for clarifying :)
Hi, is it necessary to add uuid4 id into the data?
why not make use of fauna generated ref id instead of add a new one?
Hi, is it necessary to add uuid4 id into the data?
why not make use of fauna generated ref id instead of add a new one?
It is not necessary to use uuid. But with this approach it is easier to fetch the unique id than fetching fauna's ref id.
So you can decide to not use uuid but you'll have to modify the entire source to suit you needs.
Hi, is it necessary to add uuid4 id into the data?
why not make use of fauna generated ref id instead of add a new one?It is not necessary to use uuid. But with this approach it is easier to fetch the unique id than fetching fauna's ref id.
So you can decide to not use uuid but you'll have to modify the entire source to suit you needs.
hm... yes I think so.
but in terms of performance, indexing id or ref id which will be the best? or it is similar
Hi, is it necessary to add uuid4 id into the data?
why not make use of fauna generated ref id instead of add a new one?It is not necessary to use uuid. But with this approach it is easier to fetch the unique id than fetching fauna's ref id.
So you can decide to not use uuid but you'll have to modify the entire source to suit you needs.hm... yes I think so.
but in terms of performance, indexing id or ref id which will be the best? or it is similar
It is not at all necessary to use uuid in the document. This is something I did to keep my fauna data readily movable and deployable to other db. I'm building a redundancy db with auto switchover in cases of emergency. Probably I should mention this approach in comments.
Regarding performance, once you index the field, the performance should be same given that fauna works purely on referencing documents.
Hi, is it necessary to add uuid4 id into the data?
why not make use of fauna generated ref id instead of add a new one?It is not necessary to use uuid. But with this approach it is easier to fetch the unique id than fetching fauna's ref id.
So you can decide to not use uuid but you'll have to modify the entire source to suit you needs.hm... yes I think so.
but in terms of performance, indexing id or ref id which will be the best? or it is similarIt is not at all necessary to use uuid in the document. This is something I did to keep my fauna data readily movable and deployable to other db. I'm building a redundancy db with auto switchover in cases of emergency. Probably I should mention this approach in comments.
Regarding performance, once you index the field, the performance should be same given that fauna works purely on referencing documents.
make sense, thanks
Hi. Thanks for this solution. I have been meaning to use easily use auth0 for authentication and faunadb to store users but couldn't find any until i saw your pr on next-auth. So i use auth0 as provider and it works great but after using this code in my project, the authentication callback doesn't work anymore. I followed the instaructions and created all required collections and indexes.
Here is how i'm using it:
Screenshot of error