Skip to content

Instantly share code, notes, and snippets.

@colllin
Last active February 21, 2024 14:55
Show Gist options
  • Star 28 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • 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;
@cbioley
Copy link

cbioley 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