src/auth0/handlers/hooks.js
import DefaultHandler from './default';
import constants from '../../constants';
const ALLOWED_TRIGGER_IDS = [ 'credentials-exchange', 'pre-user-registration', 'post-user-registration', 'post-change-password', 'send-phone-message' ];
export const excludeSchema = {
type: 'array',
items: { type: 'string' }
};
export const schema = {
type: 'array',
items: {
type: 'object',
default: [],
properties: {
script: {
type: 'string',
description: 'A script that contains the hook\'s code',
default: ''
},
name: {
type: 'string',
description: 'The name of the hook. Can only contain alphanumeric characters, spaces and \'-\'. Can neither start nor end with \'-\' or spaces',
pattern: '^[^-\\s][a-zA-Z0-9-\\s]+[^-\\s]$'
},
enabled: {
type: 'boolean',
description: 'true if the hook is active, false otherwise',
default: false
},
triggerId: {
type: 'string',
description: 'The hooks\'s trigger ID',
enum: ALLOWED_TRIGGER_IDS
},
secrets: {
type: 'object',
description: 'List of key-value pairs containing secrets available to the hook.',
default: {}
},
dependencies: {
type: 'object',
default: {},
description: 'List of key-value pairs of NPM dependencies available to the hook.'
}
},
required: [ 'script', 'name', 'triggerId' ]
}
};
const getCertainHook = (hooks, name, triggerId) => {
let result = null;
hooks.forEach((hook) => {
if (hook.name === name && hook.triggerId === triggerId) {
result = hook;
}
});
return result;
};
const getActive = (hooks) => {
const result = {};
ALLOWED_TRIGGER_IDS.forEach((type) => {
result[type] = hooks.filter((h) => h.active && h.triggerId === type);
});
return result;
};
export default class HooksHandler extends DefaultHandler {
constructor(options) {
super({
...options,
type: 'hooks',
stripUpdateFields: [ 'id', 'triggerId' ]
});
}
objString(hook) {
return super.objString({ name: hook.name, triggerId: hook.triggerId });
}
async processSecrets(hooks) {
const allHooks = await this.getType(true);
const changes = {
create: [],
update: [],
del: []
};
hooks.forEach((hook) => {
const current = getCertainHook(allHooks, hook.name, hook.triggerId);
if (current) { // if the hook was deleted we don't care about its secrets
const oldSecrets = current.secrets || {};
const newSecrets = hook.secrets || {};
const create = {};
const update = {};
const del = [];
Object.keys(newSecrets).forEach((key) => {
if (!oldSecrets[key]) {
create[key] = newSecrets[key];
} else if (newSecrets[key] !== constants.HOOKS_HIDDEN_SECRET_VALUE) {
update[key] = newSecrets[key];
}
});
Object.keys(oldSecrets).forEach((key) => {
if (!newSecrets[key]) {
del.push(key);
}
});
if (Object.keys(create).length) changes.create.push({ hookId: current.id, secrets: create });
if (Object.keys(update).length) changes.update.push({ hookId: current.id, secrets: update });
if (del.length) changes.del.push({ hookId: current.id, secrets: del });
}
});
await Promise.all(changes.del.map(async (data) => {
await this.client.hooks.removeSecrets({ id: data.hookId }, data.secrets);
}));
await Promise.all(changes.update.map(async (data) => {
await this.client.hooks.updateSecrets({ id: data.hookId }, data.secrets);
}));
await Promise.all(changes.create.map(async (data) => {
await this.client.hooks.addSecrets({ id: data.hookId }, data.secrets);
}));
}
async getType(reload) {
if (this.existing && !reload) {
return this.existing;
}
// in case client version does not support hooks
if (!this.client.hooks || typeof this.client.hooks.getAll !== 'function') {
return [];
}
try {
const hooks = await this.client.hooks.getAll();
// hooks.getAll does not return code and secrets, we have to fetch hooks one-by-one
this.existing = await Promise.all(hooks.map((hook) => this.client.hooks.get({ id: hook.id })
.then((hookWithCode) => this.client.hooks.getSecrets({ id: hook.id })
.then((secrets) => ({ ...hookWithCode, secrets })))));
return this.existing;
} catch (err) {
if (err.statusCode === 404 || err.statusCode === 501) {
return [];
}
throw err;
}
}
async calcChanges(assets) {
const {
del, update, create, conflicts
} = await super.calcChanges(assets);
// strip secrets before hooks creating/updating, secrets have to be handled separately
const stripSecrets = (list) => list.map((item) => ({ ...item, secrets: undefined }));
return {
del,
update: stripSecrets(update),
create: stripSecrets(create),
conflicts: stripSecrets(conflicts)
};
}
async validate(assets) {
const { hooks } = assets;
// Do nothing if not set
if (!hooks) return;
const activeHooks = getActive(hooks);
ALLOWED_TRIGGER_IDS.forEach((type) => {
if (activeHooks[type].length > 1) { // There can be only one!
const conflict = activeHooks[type].map((h) => h.name).join(', ');
const err = new Error(`Only one active hook allowed for "${type}" extensibility point. Conflicting hooks: ${conflict}`);
err.status = 409;
throw err;
}
});
await super.validate(assets);
}
async processChanges(assets) {
const { hooks } = assets;
// Do nothing if not set
if (!hooks) return;
// Figure out what needs to be updated vs created
const changes = await this.calcChanges(assets);
await super.processChanges(assets, {
del: changes.del,
create: changes.create,
update: changes.update,
conflicts: changes.conflicts
});
await this.processSecrets(hooks);
}
}