A utility for managing rule and db script definitions on an Auth0 tenant.
- Context
- Installation
- Usage
- Environment and Permissions
- Structure and compilation
- Defining Scripts
- Running Database Action Scripts against a local dev environment
- Automatic deploys
CEA uses Auth0 to provide authentication and authorization to a number of services (e.g. EA Funds, Giving What We Can, the EA Forum etc).
Auth0 provides a number of places where they call our code and allow us to perform custom modifications to their default behavior, such as Rules, and Database Action Scripts below.
The default method of creating and editing these scripts is to use Auth0's web-based UI. This makes it difficult to version them, and to ensure that they are kept in sync between production, staging, and local Auth0 tenants.
This repo allows us to write our scripts in an IDE, using TypeScript, and then automatically deploy them. π
When users log in, we run a number of rules (part of Auth0's login pipeline) that affect the final login state (e.g. what data is present in the user's access token or ID token, which permissions they are allowed to request etc.). From the docs:
Rules are JavaScript functions that execute when a user authenticates to your application. They run once the authentication process is complete, and you can use them to customize and extend Auth0's capabilities. For security reasons, your Rules code executes isolated from the code of other Auth0 tenants in a sandbox.
So long as not all of our users have logged in since we started using Auth0, we will still need to be able to authenticate them from our local databases. We can't do this just by sending Auth0 everyone's passwords, because we don't store passwords, we store hashes of passwords.
Example: Torble Dorp has an account on EA Funds, from 2019. He then clicks login on the modern site, and enters his username and password into Auth0's UI. Auth0 first checks for Torble Dorp in its own database. No Torble Dorp there. So then Auth0 calls out to our script. "Do these credentials log someone in?", it asks? Yes. Auth0 now takes over management of Torble Dorp's account.
We write two scripts for this to happen: Login, and Get User. Get User is used in password resets, and just takes an email as an argument. The scripts are managed separately from Rules, but otherwise follow a lot of the same, uh, rules.
- Clone the repository
(
git clone https://github.com/centre-for-effective-altruism/auth0-rules.git
) cd
into the directory (cd auth0-rules
)- Install dependencies (
yarn
) - Build the CLI scripts and rule definitions (
yarn build
)
Sync with Auth0 using yarn cli [rules|db|login]
Usage: yarn cli rules [options] [command]
Options:
-h, --help display help for command
Commands:
deploy Deploy rules to the Auth0 tenant
diff Diff locally defined rules against those on the
Auth0 tenant
help [command] display help for command
yarn cli [rules|db|login] deploy
Deploys all scripts in the manifest to Auth0.
Rules that don't match with those that are already on the Auth0 tenant will be created, those that do will be updated. Rules that are defined on Auth0 but that aren't in the manifest will be left alone, and will run before all rules that are in the manifest.
yarn cli [rules|db|login] diff
Diffs locally defined scripts against those defined on the Auth0 tenant.
The output will look something like:
[[ Changed rules ]]
- Add Scopes to ID Token:
-------------------------
function addScopesToIdToken(user, context, callback) {
const requiredApplications = [
- // Something else
+ // Giving What We Can
"abc123def456",
];
// only run if our application is on the list
if (requiredApplications.includes(context.clientID)) {
- const namespace = "https://parfit-staging.effectivealtruism.org";
+ const namespace = "https://parfit.effectivealtruism.org";
context.idToken[`${namespace}/scope`] = context.accessToken.scope;
}
callback(null, user, context);
}
[[ Up-to-date rules ]]
3 rules defined in the manifest are identical to those that exist on the Auth0 tenant:
- Add Default Role To All Users
- Filter scopes
- Log Context
[[ Missing rules ]]
1 rules defined in the manifest do not exist on the Auth0 tenant:
- Add email to access token
[[ Extra rules ]]
2 rules exist on the Auth0 tenant that are not included in the manifest:
- Simulate # Missing-Scope Issue
- auth0-account-link-extension
You'll need to ensure that the Auth0 tenant has a client application set up to
work with the CLI. The application should be a Machine-to-Machine
application, and needs the following permissions on the Auth0 Management API
.
Base permissions (required to manage rules via the API)
create:rules
read:rules
update:rules
read:connections
update:connections
Permissions used by specific rule generators
read:clients
read:roles
(Note that if you add additional scripts, for example in getData()
calls in
the manifest, you might need to give the application additional permissions.)
To connect to this application, the CLI expects you to have the following
variables in your shell environment. If you're developing locally, you can add
them to a .env
file in the project root.
AUTH0_DOMAIN=<your Auth0 tenant domain>
AUTH0_CLIENT_ID=<client ID for the Rules Managment client application >
AUTH0_CLIENT_SECRET=<client secret for the Rules Management client application>
Some TEMPLATE_DATA
values can also be defined in environment variables. For
instance, the current rules implementation can be configured with the
TOKEN_NAMESPACE
variable.
This repo consists of two main folders:
./scripts
β contains the actual script definitions that will run on Auth0./src
β contains the the CLI, and the manifest file that tells it which scripts to deploy to Auth0.
These folders are independent TypeScript projects, due to them each requiring different compilation options.
- The rule definitions are compiled as standalone
ES2020
scripts. This means that they will compile to Javascript that looks almost identical to the TypeScript source (though with type information removed) - The CLI scripts are compiled to
ES5
Javascript, for easier consumption by Node.js, as Node doesn't currently support ESModules syntax (e.g.import
).
Both the CLI and the script definitions are compiled to the ./dist
folder. You
can build both at once by running yarn build
(which is an alias for
yarn build:cli && yarn build:scripts
).
You can run yarn build:scripts:watch
to have TypeScript automatically compile
rules as you are developing them. If you need to edit the CLI itself (including
the manifest file), you should also run yarn build:cli:watch
.
There are two steps to writing an Auth0 script:
Defining the script itself (as a file in e.g. ./scripts/rules/src
)
- Registering the script in the manifest (
./src/manifests
)
Scripts are defined in ./scripts/[rules|db|login]/src
. Each script lives in
its own file. They are written as Typescript files (.ts
extension).
A basic rule looks like this:
import {
IAuth0RuleCallback,
IAuth0RuleContext,
IAuth0RuleUser,
} from '@tepez/auth0-rules-types'
async function myAwesomeRule(
user: IAuth0RuleUser<unknown, unknown>,
context: IAuth0RuleContext,
callback: IAuth0RuleCallback<unknown, unknown>
): Promise<void> {
// Do some fun things here
// Return the updated user/context back to the rules pipeline
callback(null, user, context)
}
Because the rules will be executed in the context of the Auth0 WebTask
environment, they can't use imports from other files. This means that the only
import
statements in a rule definition file should be TypeScript typings.
// These are type definitions, which will be removed at build time by the TS compiler
// This are fine to import in your rule file
import {
IAuth0RuleCallback,
IAuth0RuleContext,
IAuth0RuleUser,
} from '@tepez/auth0-rules-types'
// This will not work when run on Auth0 π¨
import { doSomething } from 'my-lib'
async function myAwesomeRule(
user: IAuth0RuleUser<unknown, unknown>,
context: IAuth0RuleContext,
callback: IAuth0RuleCallback<unknown, unknown>
): Promise<void> {
// doSomething() will not be available here π¨
const foo = doSomething(user.id)
// ...
return callback(null, user, context)
}
It is possible to use external libraries, but they need to be dynamically
require
d at runtime. For example:
// Only type definitions imported, so we're all OK here
import {
IAuth0RuleUser,
IAuth0RuleContext,
IAuth0RuleCallback,
} from '@tepez/auth0-rules-types'
async function addDefaultRole(
user: IAuth0RuleUser<unknown, unknown>,
context: IAuth0RuleContext,
callback: IAuth0RuleCallback<unknown, unknown>
): Promise<void> {
// Dynamic require works fine π
const ManagementClient = require('auth0@2.31.0').ManagementClient
const management = new ManagementClient({
domain: auth0.domain,
clientId: configuration.AUTH0_CLIENT_ID,
clientSecret: configuration.AUTH0_CLIENT_SECRET,
scope: 'read:users update:users read:roles',
})
// ...
}
For a full list of modules that can be dynamically required, see the Auth0 Extensions 'Can I Require' tool. Further discussion about using modules can be found in the Auth0 docs.
A manifest declares the scripts that will be deployed to Auth0. They are found
in ./src/manifest.ts
.
A manifest consists of an array of either RuleDefinitions
or
DBActionScriptDefinition
. Rule Definitions are objects with the following
properties:
name
(string): The name of the rule that will appear in the Auth0 UI. This is used to match against existing rules on Auth0 for the purpose of diffing and deploying, so it should be unique (and if you're adding a rule that already exists on Auth0 to this repo, you should use the name of the existing rule).file
(string): The filename (without extension) of the rule definition file matching this rule, in./rules/src
.enabled
(boolean): Whether this rule is enabled or notgetData
(function): [optional] A function that gets data from the Auth0 tenant that should be injected into the rule (see Templating below). Should return an object with keys corresponding to Handlebars template variables. Can beasync
.
Database Action Script Definitions are the same but without enabled
, as they
cannot be disabled.
Rules run in series. When deployed, the rules will be ordered as follows:
- Any rules that are on the Auth0 tenant, but that aren't defined in the manifest will run first, in their current order
- Rules defined in the manifest will run in the order that they are defined in the manifest
Sometimes, we need to inject variables that will be different on each Auth0
tenant. For example, maybe you only want a rule to apply to certain
applications, so you want to use a list of these application IDs into your
function β obviously these IDs will be different on different tenants. Instead
of hard-coding these values into the script code, you can instead inject a
TEMPLATE_DATA
global, that will be populated by data from the getData()
function in the script manifest.
The TEMPLATE_DATA
variable is declared as a TypeScript global with type
Record<string, any>
, so rule file will compile happily. It's a good idea to
type any assignments in your rule function:
function myGreatFunction() {
const whitelist: string[] = TEMPLATE_DATA.whitelist
// ... do stuff
}
When scripts are compiled, any rule that has a getData()
property on its
manifest will inject a TEMPLATE_DATA
variable into the top of the function
declaration:
function myGreatFunction() {
// Template data
const TEMPLATE_DATA = {
whitelist: ['abc123def456'],
}
const whitelist = TEMPLATE_DATA.whitelist
// ... do stuff
}
You can use any data type for the value of the keys in TEMPLATE_DATA
. Values
are passed to JSON.stringify()
for injection into the code.
getCommentValue()
Injecting values is all well and good, but a simple list of random object IDs is
difficult to read. If you have an array of IDs and you want to include
additional data for better readability, you can use the getCommentValue()
function to build your array of input data.
getCommentValue()
takes a single argument, which is an object that has a
value
property. If you pass an array of these objects as a property of
TEMPLATE_DATA
, it will be mapped into an array of just the values in each
object's respective value
property.
So, instead of ...
const TEMPLATE_DATA = {
whitelist: ['abc', 'def'],
}
... you instead see:
const TEMPLATE_DATA = {
whitelist: [
{
name: 'Something descriptive',
value: 'abc',
},
{
name: 'Another item',
value: 'def',
},
].map((item) => item.value),
}
See the example below for a full demonstration of how this works end-to-end.
Rule definition (e.g. ./rules/add-scopes-to-id-token.ts
).
import {
IAuth0RuleUser,
IAuth0RuleContext,
IAuth0RuleCallback,
} from '@tepez/auth0-rules-types'
function addScopesToIdToken(
user: IAuth0RuleUser<unknown, unknown>,
context: IAuth0RuleContext,
callback: IAuth0RuleCallback<unknown, unknown>
) {
const requiredApplications: string[] = TEMPLATE_DATA.whitelist
// only run if our application is on the list
if (requiredApplications.includes(context.clientID)) {
const namespace = 'https://parfit.effectivealtruism.org'
context.idToken[`${namespace}/scope`] = context.accessToken.scope
}
callback(null, user, context)
}
Manifest entry:
const MANIFEST = [
// Other rules...
{
name: 'Add Scopes to ID Token',
file: 'add-scopes-to-id-token',
enabled: true,
getData: async () => {
const applicationNames = ['Giving What We Can']
const Clients = await getAllClients()
const whitelist = Clients.filter(isValidClient)
.filter((Client) => applicationNames.includes(Client.name))
.map((Client) =>
getCommentValue({
applicationName: Client.name,
value: Client.client_id,
})
)
return { whitelist }
},
},
// More rules...
]
The getData
call will return the following object:
{
whitelist: [
{
applicationName: 'Giving What We Can',
value: 'abc123def456',
},
]
}
Which will be compiled into the following rule code:
function addScopesToIdToken(user, context, callback) {
// Template data
const TEMPLATE_DATA = {
whitelist: [
{
applicationName: 'Giving What We Can',
value: 'abc123def456',
},
].map((item) => item.value),
}
const requiredApplications = TEMPLATE_DATA.whitelist
// only run if our application is on the list
if (requiredApplications.includes(context.clientID)) {
const namespace = 'https://parfit.effectivealtruism.org'
context.idToken[`${namespace}/scope`] = context.accessToken.scope.join(' ')
}
callback(null, user, context)
}
If you want to test an updated Database Action Script you might wonder how to do so when you're local database isn't exactly easy to hit from Auth0's servers. It might be more trouble than it's worth and you should just test on staging. However it is not impossible to test locally.
First, make sure you have Packet Riot installed
(brew install packetriot
). You'll need a paid plan.
Next, set up two TCP ports to forward to your local databases.
pktriot tunnel tcp allocate
pktriot tunnel tcp forward --destination 127.0.0.1 --dstport 5432 --port 22996
Where 5432 is the postgres port number, and 22996 is the port number it randomly generated after the first command.
Then you'll need to update the connection environment variables in the Auth0 UI to reflect your packet riot host.
You can technically have more than one database of Auth0 usernames and passwords. It's pretty rare that you'd actually want to do so however. In our case, we were faced with an issue where the original database (called Username-Password-Authentication, like the others), was not set up with user migration enabled, and Auth0 strangely did not allow for us to enable it after the fact. So we use 'Forum-User-Migration', which was created specially for testing the ability to migrate Forum users.
To use this connection in your application, you'll need to update your application in the Auth0 UI, where you can select which connection it uses for username and password authentication.
We use GitHub actions to auto-deploy these rules to the relevant Auth0 tenant
when merging to master
or dev
.
To check that deployment worked, check the workflow run of Gitub Actions and verify that the diffs are as expected.