Action
The following Post Login action checks the user’s roles fromevent.authorization and sets them as a custom claim on the ID token, denying access when no roles are present.
- JavaScript
- TypeScript
test-an-action.js
/** @import {Event, PostLoginAPI} from "@auth0/actions/post-login/v3" */
const CUSTOM_CLAIM_NAMESPACE = 'https://example.com';
/**
* Handler that will be called during the execution of a PostLogin flow.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event, api) => {
const roles = event.authorization?.roles;
if (roles === undefined || roles.length === 0) {
api.access.deny('Restricted');
return;
}
api.idToken.setCustomClaim(`${CUSTOM_CLAIM_NAMESPACE}/roles`, roles);
}
test-an-action.ts
import type { PostLoginAPI, Event } from '@auth0/actions/post-login/v3';
const CUSTOM_CLAIM_NAMESPACE = 'https://example.com';
/**
* Handler that will be called during the execution of a PostLogin flow.
*
* @param {Event} event - Details about the user and the context in which they are logging in.
* @param {PostLoginAPI} api - Interface whose methods can be used to change the behavior of the login.
*/
exports.onExecutePostLogin = async (event: Event, api: PostLoginAPI) => {
const roles = event.authorization?.roles;
if (roles === undefined || roles.length === 0) {
api.access.deny('Restricted');
return;
}
api.idToken.setCustomClaim(`${CUSTOM_CLAIM_NAMESPACE}/roles`, roles);
};
Unit Test
The unit tests mock theevent and api objects to verify that admin users get the custom claim set, while users with no roles or a missing authorization object are denied access.
Jest
Jest
- JavaScript
- TypeScript
test-an-action.spec.js
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const path = require('path');
const DIRNAME = path.dirname('../../../');
const ACTION_PATH = path.resolve(DIRNAME, './src/test-an-action.js');
const CUSTOM_CLAIM_NAMESPACE = 'https://example.com';
describe('onExecutePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
jest.resetAllMocks();
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
jest.spyOn(api.access, 'deny');
jest.spyOn(api.idToken, 'setCustomClaim');
});
afterEach(() => {
jest.resetAllMocks();
});
it('Admin user', async () => {
event.authorization = { roles: ['admin'] };
await loader.execute('onExecutePostLogin', event, api);
expect(api.idToken.setCustomClaim).toHaveBeenCalledWith(
`${CUSTOM_CLAIM_NAMESPACE}/roles`,
['admin']
);
expect(api.access.deny).not.toHaveBeenCalled();
});
it('Restricted User - No authorization', async () => {
delete event.authorization;
await loader.execute('onExecutePostLogin', event, api);
expect(api.idToken.setCustomClaim).not.toHaveBeenCalled();
expect(api.access.deny).toHaveBeenCalledWith('Restricted');
});
it('Restricted User - No roles', async () => {
event.authorization = { roles: [] };
await loader.execute('onExecutePostLogin', event, api);
expect(api.idToken.setCustomClaim).not.toHaveBeenCalled();
expect(api.access.deny).toHaveBeenCalledWith('Restricted');
});
});
package.json
{
"name": "actions-npm-example-js-jest",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "module-usage.js",
"scripts": {
"test": "jest"
},
"devDependencies": {
"@auth0/actions": "^0.32.0",
"jest": "^30.4.2"
},
"jest": {
"testEnvironment": "node"
}
}
jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
test-an-action.test.ts
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const path = require('path');
const { compileActionModules } = require('./test-utils/load-compiled-action');
const DIRNAME = path.dirname('../../../');
const ACTION_PATH = path.resolve(DIRNAME, './src/test-an-action.ts');
const CUSTOM_CLAIM_NAMESPACE = 'https://example.com';
describe('onExecutePostLogin', () => {
let loader: any;
let event: any;
let api: any;
beforeEach(async () => {
jest.resetAllMocks();
const { compiledActionPath } = compileActionModules(ACTION_PATH);
loader = await loadAction(compiledActionPath);
[event, api] = getDefaultArguments();
jest.spyOn(api.access, 'deny');
jest.spyOn(api.idToken, 'setCustomClaim');
});
afterEach(() => {
jest.resetAllMocks();
});
it('Admin user', async () => {
event.authorization = { roles: ['admin'] };
await loader.execute('onExecutePostLogin', event, api);
expect(api.idToken.setCustomClaim).toHaveBeenCalledWith(
`${CUSTOM_CLAIM_NAMESPACE}/roles`,
['admin']
);
expect(api.access.deny).not.toHaveBeenCalled();
});
it('Restricted User - No authorization', async () => {
delete event.authorization;
await loader.execute('onExecutePostLogin', event, api);
expect(api.idToken.setCustomClaim).not.toHaveBeenCalled();
expect(api.access.deny).toHaveBeenCalledWith('Restricted');
});
it('Restricted User - No roles', async () => {
event.authorization = { roles: [] };
await loader.execute('onExecutePostLogin', event, api);
expect(api.idToken.setCustomClaim).not.toHaveBeenCalled();
expect(api.access.deny).toHaveBeenCalledWith('Restricted');
});
});
load-compiled-action.ts
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as ts from 'typescript';
export interface ModuleToCompile {
name: string;
filename: string;
}
function transpileToTemp(sourcePath: string): string {
const source = fs.readFileSync(sourcePath, 'utf8');
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
});
const tempPath = path.join(
os.tmpdir(),
`${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
);
fs.writeFileSync(tempPath, outputText);
return tempPath;
}
/**
* loadAction() (from @auth0/actions/*\/test) reads its target file from disk and
* runs it via vm.compileFunction, so it never goes through ts-node/Vitest's own
* TS transform. Action sources (and any actions:-registered modules) must be
* transpiled to plain JS on disk first.
*/
export function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
const compiledActionPath = transpileToTemp(actionPath);
const compiledModules = modules.map((m) => ({
name: m.name,
filename: transpileToTemp(m.filename),
}));
return { compiledActionPath, compiledModules };
}
package.json
{
"name": "actions-npm-example-ts-jest",
"version": "1.0.0",
"description": "Actions TS",
"main": "example.ts",
"scripts": {
"test": "jest"
},
"author": "John Doe",
"license": "ISC",
"devDependencies": {
"@auth0/actions": "^0.32.0",
"@types/jest": "^29.5.12",
"@types/node": "22.14.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.2",
"typescript": "^5.9.2"
}
}
jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"outDir": "dist",
"declaration": true,
"sourceMap": true,
"allowJs": true,
"checkJs": false,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"actions:*": [
"./src/*"
]
}
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"**/*.ts"
],
"ts-node": {
"transpileOnly": true
}
}
Mocha
Mocha
- JavaScript
- TypeScript
test-an-action.spec.js
const sinon = require('sinon');
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const path = require('path');
const DIRNAME = path.dirname('../../../');
const ACTION_PATH = path.resolve(DIRNAME, './src/test-an-action.js');
const CUSTOM_CLAIM_NAMESPACE = 'https://example.com';
describe('onExecutePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
sinon.spy(api.access, 'deny');
sinon.spy(api.idToken, 'setCustomClaim');
});
afterEach(() => {
sinon.restore();
});
it('Admin user', async () => {
event.authorization = { roles: ['admin'] };
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.calledWith(
api.idToken.setCustomClaim,
`${CUSTOM_CLAIM_NAMESPACE}/roles`,
['admin']
);
sinon.assert.notCalled(api.access.deny);
});
it('Restricted User - No authorization', async () => {
delete event.authorization;
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.notCalled(api.idToken.setCustomClaim);
sinon.assert.calledWith(api.access.deny, 'Restricted');
});
it('Restricted User - No roles', async () => {
event.authorization = { roles: [] };
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.notCalled(api.idToken.setCustomClaim);
sinon.assert.calledWith(api.access.deny, 'Restricted');
});
});
package.json
{
"name": "actions-npm-example-js-mocha",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "module-usage.js",
"scripts": {
"test": "mocha"
},
"devDependencies": {
"@auth0/actions": "^0.32.0",
"chai": "^4.5.0",
"mocha": "^11.0.0",
"sinon": "^19.0.0"
}
}
.mocharc.json
{
"spec": "src/**/*.spec.js"
}
jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
test-an-action.test.ts
import * as path from 'path';
import sinon from 'sinon';
import { compileActionModules } from './test-utils/load-compiled-action';
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const DIRNAME = path.dirname('../../../');
const ACTION_PATH = path.resolve(DIRNAME, './src/test-an-action.ts');
const CUSTOM_CLAIM_NAMESPACE = 'https://example.com';
describe('onExecutePostLogin', () => {
let loader: any;
let event: any;
let api: any;
beforeEach(async () => {
const { compiledActionPath } = compileActionModules(ACTION_PATH);
loader = await loadAction(compiledActionPath);
[event, api] = getDefaultArguments();
sinon.spy(api.access, 'deny');
sinon.spy(api.idToken, 'setCustomClaim');
});
afterEach(() => {
sinon.restore();
});
it('Admin user', async () => {
event.authorization = { roles: ['admin'] };
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.calledWith(
api.idToken.setCustomClaim,
`${CUSTOM_CLAIM_NAMESPACE}/roles`,
['admin']
);
sinon.assert.notCalled(api.access.deny);
});
it('Restricted User - No authorization', async () => {
delete event.authorization;
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.notCalled(api.idToken.setCustomClaim);
sinon.assert.calledWith(api.access.deny, 'Restricted');
});
it('Restricted User - No roles', async () => {
event.authorization = { roles: [] };
await loader.execute('onExecutePostLogin', event, api);
sinon.assert.notCalled(api.idToken.setCustomClaim);
sinon.assert.calledWith(api.access.deny, 'Restricted');
});
});
load-compiled-action.ts
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as ts from 'typescript';
export interface ModuleToCompile {
name: string;
filename: string;
}
function transpileToTemp(sourcePath: string): string {
const source = fs.readFileSync(sourcePath, 'utf8');
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
});
const tempPath = path.join(
os.tmpdir(),
`${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
);
fs.writeFileSync(tempPath, outputText);
return tempPath;
}
/**
* loadAction() (from @auth0/actions/*\/test) reads its target file from disk and
* runs it via vm.compileFunction, so it never goes through ts-node/Vitest's own
* TS transform. Action sources (and any actions:-registered modules) must be
* transpiled to plain JS on disk first.
*/
export function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
const compiledActionPath = transpileToTemp(actionPath);
const compiledModules = modules.map((m) => ({
name: m.name,
filename: transpileToTemp(m.filename),
}));
return { compiledActionPath, compiledModules };
}
package.json
{
"name": "actions-npm-example-ts-mocha",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"scripts": {
"test": "NODE_OPTIONS=--no-experimental-strip-types mocha"
},
"devDependencies": {
"@auth0/actions": "^0.32.0",
"@types/chai": "^4.3.16",
"@types/mocha": "^10.0.6",
"@types/node": "22.14.0",
"@types/sinon": "^17.0.3",
"chai": "^4.5.0",
"mocha": "^11.0.0",
"sinon": "^19.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.2"
}
}
.mocharc.json
{
"require": "ts-node/register",
"extension": ["ts"],
"spec": "src/**/*.test.ts"
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"outDir": "dist",
"declaration": true,
"sourceMap": true,
"allowJs": true,
"checkJs": false,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"actions:*": [
"./src/*"
]
}
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"**/*.ts"
],
"ts-node": {
"transpileOnly": true
}
}
Node.js Test Runner
Node.js Test Runner
- JavaScript
- TypeScript
test-an-action.spec.js
const assert = require('node:assert');
const { describe, it, beforeEach, afterEach, mock } = require('node:test');
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const path = require('path');
const DIRNAME = path.dirname('../../../');
const ACTION_PATH = path.resolve(DIRNAME, './src/test-an-action.js');
const CUSTOM_CLAIM_NAMESPACE = 'https://example.com';
describe('onExecutePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
loader = await loadAction(ACTION_PATH);
[event, api] = getDefaultArguments();
mock.method(api.access, 'deny');
mock.method(api.idToken, 'setCustomClaim');
});
afterEach(() => {
mock.reset();
});
it('Admin user', async () => {
event.authorization = { roles: ['admin'] };
await loader.execute('onExecutePostLogin', event, api);
assert.deepEqual(api.idToken.setCustomClaim.mock.calls[0].arguments, [
`${CUSTOM_CLAIM_NAMESPACE}/roles`,
['admin'],
]);
assert.strictEqual(api.access.deny.mock.calls.length, 0);
});
it('Restricted User - No authorization', async () => {
delete event.authorization;
await loader.execute('onExecutePostLogin', event, api);
assert.strictEqual(api.idToken.setCustomClaim.mock.calls.length, 0);
assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Restricted']);
});
it('Restricted User - No roles', async () => {
event.authorization = { roles: [] };
await loader.execute('onExecutePostLogin', event, api);
assert.strictEqual(api.idToken.setCustomClaim.mock.calls.length, 0);
assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Restricted']);
});
});
package.json
{
"name": "actions-npm-example-js-node-test",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"type": "commonjs",
"main": "module-usage.js",
"scripts": {
"test": "node --test src/*.spec.js"
},
"devDependencies": {
"@auth0/actions": "^0.32.0"
}
}
jsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"checkJs": false,
"baseUrl": ".",
"paths": {
"actions:*": [
"src/*"
]
}
},
"include": [
"src/**/*.js"
]
}
test-an-action.test.ts
const assert = require('node:assert');
const { describe, it, beforeEach, afterEach, mock } = require('node:test');
const { getDefaultArguments, loadAction } = require('@auth0/actions/post-login/v3/test');
const path = require('path');
const { compileActionModules } = require('./test-utils/load-compiled-action.ts');
const DIRNAME = path.dirname('../../../');
const ACTION_PATH = path.resolve(DIRNAME, './src/test-an-action.ts');
const CUSTOM_CLAIM_NAMESPACE = 'https://example.com';
describe('onExecutePostLogin', () => {
let loader;
let event;
let api;
beforeEach(async () => {
const { compiledActionPath } = compileActionModules(ACTION_PATH);
loader = await loadAction(compiledActionPath);
[event, api] = getDefaultArguments();
mock.method(api.access, 'deny');
mock.method(api.idToken, 'setCustomClaim');
});
afterEach(() => {
mock.reset();
});
it('Admin user', async () => {
event.authorization = { roles: ['admin'] };
await loader.execute('onExecutePostLogin', event, api);
assert.deepEqual(api.idToken.setCustomClaim.mock.calls[0].arguments, [
`${CUSTOM_CLAIM_NAMESPACE}/roles`,
['admin'],
]);
assert.strictEqual(api.access.deny.mock.calls.length, 0);
});
it('Restricted User - No authorization', async () => {
delete event.authorization;
await loader.execute('onExecutePostLogin', event, api);
assert.strictEqual(api.idToken.setCustomClaim.mock.calls.length, 0);
assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Restricted']);
});
it('Restricted User - No roles', async () => {
event.authorization = { roles: [] };
await loader.execute('onExecutePostLogin', event, api);
assert.strictEqual(api.idToken.setCustomClaim.mock.calls.length, 0);
assert.deepEqual(api.access.deny.mock.calls[0].arguments, ['Restricted']);
});
});
load-compiled-action.ts
const fs = require('fs');
const os = require('os');
const path = require('path');
const ts = require('typescript');
interface ModuleToCompile {
name: string;
filename: string;
}
function transpileToTemp(sourcePath: string): string {
const source = fs.readFileSync(sourcePath, 'utf8');
const { outputText } = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
});
const tempPath = path.join(
os.tmpdir(),
`${path.basename(sourcePath, path.extname(sourcePath))}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.js`,
);
fs.writeFileSync(tempPath, outputText);
return tempPath;
}
/**
* loadAction() (from @auth0/actions/*\/test) reads its target file from disk and
* runs it via vm.compileFunction, so it never goes through node's native TS type
* stripping. Action sources (and any actions:-registered modules) must be
* transpiled to plain JS on disk first.
*/
exports.compileActionModules = function compileActionModules(actionPath: string, modules: ModuleToCompile[] = []) {
const compiledActionPath = transpileToTemp(actionPath);
const compiledModules = modules.map((m: ModuleToCompile) => ({
name: m.name,
filename: transpileToTemp(m.filename),
}));
return { compiledActionPath, compiledModules };
};
package.json
{
"name": "actions-npm-example-ts-node-test",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"scripts": {
"test": "node --test src/*.test.ts"
},
"devDependencies": {
"@auth0/actions": "^0.32.0",
"@types/node": "22.14.0",
"typescript": "^5.9.2"
}
}
tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "NodeNext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"outDir": "dist",
"declaration": true,
"sourceMap": true,
"allowJs": true,
"checkJs": false,
"resolveJsonModule": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noEmit": true,
"paths": {
"actions:*": [
"./src/*"
]
}
},
"exclude": [
"node_modules",
"dist"
],
"include": [
"**/*.ts"
],
"ts-node": {
"transpileOnly": true
}
}