Skip to content

Instantly share code, notes, and snippets.

@colllin
Last active February 21, 2024 14:55
Show Gist options
  • Save colllin/fd7a40bb4f0f16603e68db0e6621369f to your computer and use it in GitHub Desktop.
Save colllin/fd7a40bb4f0f16603e68db0e6621369f to your computer and use it in GitHub Desktop.
FaunaDB User Token Expiration (for ABAC)

Auth0 + FaunaDB ABAC integration: How to expire Fauna user secrets.

Fauna doesn't (yet?) provide guaranteed expiration/TTL for ABAC tokens, so we need to implement it ourselves if we care about it.

What's in the box?

3 javascript functions, each of which can be imported into your project or run from the command-line using node path/to/script.js arg1 arg2 ... argN:

  1. deploy-schema.js: a javascript function for creating supporting collections and indexes in your Fauna database.
    • Intended to be called once per Fauna database. Safe to call it multiple times (will not cause harm).
    • Needs to be called with a Fauna SERVER secret prior to using the other provided functions.
    • Logic
      • args: fauna_server_secret
      • returns: a Promise
      • Creates a collection named auth0_token_exchanges.
        • This collection will store one document for each user secret issued in exchange for an Auth0 token.
        • The documents will contain this data:
          • token_ref: the Ref of the Fauna user token which was issued
          • expires_at: the Time at which this token should expire
          • meta: any other data passed into the custom_metadata argument to exchange-jwt-for-secret.js
            • For example, you might want to log the decoded JWT payload or the entire JWT, which could be useful for your own indexing/querying/auditing/debugging purposes.
            • We exclude any identifying data by default to avoid unintentionally storing any sensitive user data which may be governed by HIPAA, etc.
      • Creates an index named auth0_token_exchanges_by_expiration which indexes the documents by data.jwt_payload.exp.
  2. exchange-jwt-for-secret.js: verifies Auth0 JWTs, looks-up the user in Fauna by auth0_id, creates an ABAC token for the user, records the token and JWT expiration time in Fauna, and returns the token secret.
    • This is intended to be served in an API endpoint that you create.
    • Clients should call this endpoint upon receiving a JWT to obtain a Fauna user secret.
    • Clients can then use this Fauna user secret to communicate directly with your Fauna database, e.g. for the native GraphQL endpoint.
    • Logic
      • args: auth0_jwt, custom_metadata, auth0_client_id, auth0_client_cert_pubkey, fauna_server_secret, fauna_index_users_by_auth0_id
      • returns: a Promise
      • Verifies the JWT (via Auth0 for safety). Rejects the promise if invalid or expired.
      • Looks up user by Auth0 user ID (from index named by fauna_user_index_auth0_id)
        • you need to setup this index prior to using this function.
      • Create a user token for the user (i.e. Login(), but via Create(Tokens(), ...)).
      • Create a document in the auth0_token_exchanges collection containing the ref of the user token (NOT the secret), the provided JWT, and the decoded JWT payload.
      • Return the user secret (by resolving the promise).
    • In my app's integration, I added some logic at the beginning to create the User document for this Auth0 ID if there isn't one already.
    function findOrCreateUserRef(index_users_by_auth0_id, auth0_id) {
        return q.Select(
            ['ref'],
            q.Let(
                {userMatch: q.Match(q.Index(index_users_by_auth0_id), auth0_id)},
                q.If(
                    q.Exists(q.Var('userMatch')),
                    q.Get(q.Var('userMatch')),
                    q.Create(q.Collection('users'), {
                        data: {
                            auth0_id: auth0_id
                        }
                    })
                )
            )
        )
    }
    
    
  3. delete-expired-tokens.js: deletes all expired tokens which were issued in exchange for Auth0 JWTs.
    • This is intended to be called in a cron, and can be called as often as desired. The lower limit is probably once/day and the upper limit is probably once every 5 minutes.
    • If you don't call this function in a cron, then the rest of this code is pointless, because your user tokens will never expire (which is the normal behavior without any of this code).
    • Logic
      • args: fauna_server_secret
      • returns: a Promise
      • Queries index auth0_token_exchanges_by_expiration for all documents which are past the expiration timestamp specified in data.jwt_payload.exp.
      • For each matching instance returned from auth0_token_exchanges, deletes the ABAC token referenced by data.token_ref.
      • Deletes each of the matching instances returned from auth0_token_exchanges.
module.exports = {
COLLECTION_NAME: 'auth0_token_exchanges',
INDEX_NAME: 'auth0_token_exchanges_by_expiration',
}
const util = require('util')
const jwt = require('jsonwebtoken')
const faunadb = require('faunadb')
const uuidv4 = require('uuid/v4')
const config = require('./config.js')
const q = faunadb.query
function findExpiredTokens() {
return q.Map(
q.Paginate(
q.Match(q.Index(config.INDEX_NAME)),
{before: q.Time("now"), size: 200},
),
q.Lambda(
['expires_at', 'ref'],
q.Get(q.Var('ref')),
),
)
}
function deleteOnePageOfExpiredTokens() {
return q.Foreach(
findExpiredTokens(),
q.Lambda(
['auth0TokenExchange'],
q.Do(
q.Delete(
q.Select(
['data', 'token_ref'],
q.Var('auth0TokenExchange')
)
),
q.Delete(
q.Select(
['ref'],
q.Var('auth0TokenExchange')
)
)
)
)
)
}
/* idempotent function to create the necessary collections in your Fauna database */
async function deleteExpiredTokens(fauna_server_secret) {
const client = new faunadb.Client({
secret: fauna_server_secret
})
let page = null;
let count = 0;
try {
do {
page = await client.query(
deleteOnePageOfExpiredTokens()
)
count += page.data.length
} while (page.before)
} catch (e) {
if (e.message === 'unauthorized') {
e.message = 'unauthorized: missing or invalid fauna_server_secret, or not enough permissions';
throw e
} else {
throw e
}
}
return `Deleted ${count} expired tokens.`
}
if (require.main === module) {
deleteExpiredTokens.apply(this, process.argv.slice(2))
.then((result) => console.log(result))
.catch((error) => console.error(error))
}
module.exports = deleteExpiredTokens;
// Originally from:
// https://github.com/fauna/netlify-faunadb-todomvc/blob/master/scripts/bootstrap-fauna-database.js
const faunadb = require('faunadb')
const config = require('./config.js')
const q = faunadb.query
/* idempotent function to create the necessary collections in your Fauna database */
function ensureFaunaCollections(fauna_server_secret) {
if (!fauna_server_secret) {
console.log('Missing first argument: fauna_server_secret. Try with:')
console.log()
console.log('node ensure-fauna-collections.js fnYourFaunaSecretHere')
console.log()
console.log('You can create fauna DB keys here: https://dashboard.fauna.com/db/keys')
return false
}
const client = new faunadb.Client({
secret: fauna_server_secret
})
console.log('Creating the collections...')
return client.query(
q.CreateCollection({
name: config.COLLECTION_NAME,
})
)
.then(() => client.query(
q.CreateIndex({
name: config.INDEX_NAME,
source: q.Collection(config.COLLECTION_NAME),
values: [
{field: ['data', 'expires_at']},
{field: ['ref']},
],
})
))
.then(console.log.bind(console))
.catch((e) => {
if (e.message === 'instance already exists') {
console.log("collection already created... skipping. You're good to go!");
} else if (e.message === 'unauthorized') {
e.message = 'unauthorized: missing or invalid fauna_server_secret, or not enough permissions';
throw e
} else {
throw e
}
})
}
if (require.main === module) {
ensureFaunaCollections.apply(this, process.argv.slice(2))
.then((result) => console.log(result))
.catch((error) => console.error(error))
}
module.exports = ensureFaunaCollections;
const util = require('util')
const jwt = require('jsonwebtoken')
const faunadb = require('faunadb')
const config = require('./config.js')
const q = faunadb.query
function findUserRef(index, matchValues) {
return q.Select(
['ref'],
q.Get(
q.Match(
q.Index(index),
matchValues,
),
),
)
}
function loginUser(userRef) {
// This is *supposed* to work according to Fauna docs, but currently has a bug that requires credentials.
// return q.Login(userRef);
return q.Create(q.Tokens(), {instance: userRef});
}
/* idempotent function to create the necessary collections in your Fauna database */
function exchangeJwtForSecret(
auth0_jwt, // A JWT returned from a successful Auth0 login (or silentAuth or refresh)
custom_metadata, // Any custom metadata you wish to store in Fauna in the newly created `auth0_token_exchanges` record of this exchange.
auth0_client_id, // From your Auth0 Client Application > Settings > Client ID
auth0_client_cert_pubkey, // From your Auth0 Client Application > Settings > Advanced Settings > Certificates > Signing Certificate
fauna_server_secret, // From the associated Fauna database. Must be SERVER secret (or higher).
fauna_user_index_auth0_id, // The name of the index we can use to look up a user by Auth0 ID, e.g. `Match(Index('<your_index_name>'), '<an auth0 user ID>')`
) {
return util.promisify(jwt.verify)(auth0_jwt, auth0_client_cert_pubkey, {
algorithms: ['RS256'], // You could also use HS256 and your Client Secret rather than the public key.
audience: auth0_client_id,
clockTolerance: 15, // liberal — our goal here isn't to detect expired JWTs down to the ms.
}).then((jwtPayload) => {
const client = new faunadb.Client({
secret: fauna_server_secret
})
const jwt_auth0_id = jwtPayload.sub;
const jwt_exp_ms = jwtPayload.exp * 1000;
return client.query(
q.Let(
{
userRef: findUserRef(fauna_user_index_auth0_id, jwt_auth0_id),
userToken: loginUser(q.Var('userRef'))
},
q.Do(
q.Create(
q.Collection(config.COLLECTION_NAME),
{
data: {
token_ref: q.Select(['ref'], q.Var('userToken')),
expires_at: q.ToTime(jwt_exp_ms),
meta: custom_metadata,
}
}
),
q.Select(['secret'], q.Var('userToken')),
),
),
).catch((e) => { // Catch here so we don't accidentally swallow a failed JWT verification.
if (e.message === 'unauthorized') {
e.message = 'unauthorized: missing or invalid fauna_server_secret, or not enough permissions';
throw e
} else {
throw e
}
})
});
}
if (require.main === module) {
exchangeJwtForSecret.apply(this, process.argv.slice(2))
.then((result) => console.log(result))
.catch((error) => console.error(error))
}
module.exports = exchangeJwtForSecret;
@donaldboulton
Copy link

Save JWT tokens in local storage and expire them in local storage, why care what fauna knows about it. Your site is what should know.

@Bulletninja
Copy link

@donaldboulton I think he is trying to leverage a couple of things out of JWTs, one of them that they have an expiration, and use this to reasonably expire faunadb secrets.
Seems to me that a simpler version is possible using emails and expiring an hour into the future or something similar, but now your jwt expiration and secret expiration do not coincide.

On the other hand, if you are in an scenario like mine, with netlify identity (or if your provider's behavior and architecture coincide with mine), you'd always have access to your user metadata without an explicit expiration timestamp and matching by email would be an option. In any case we are still in charge of removing expired stuff.

This guy has a related article by the way: https://www.felix-gehring.de/articles/2020/01/28/using-faunadb-with-an-identity-provider/

@cyr-l
Copy link

cyr-l commented Jun 24, 2020

@Bulletninja Thank your for the link to the article! Exactly what I was looking for ☺️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment