Source: managers/webhooks.ts

/**
 * @fileoverview Manager for the Box Webhooks resource
 */

// -----------------------------------------------------------------------------
// Requirements
// -----------------------------------------------------------------------------

import urlPath from '../util/url-path';
import crypto from 'crypto';
import BoxClient from '../box-client';

// -----------------------------------------------------------------------------
// Typedefs
// -----------------------------------------------------------------------------

/**
 * A webhook trigger type constant
 * @typedef {string} WebhookTriggerType
 */

enum WebhookTriggerType {
	FILE_UPLOADED = 'FILE.UPLOADED',
	FILE_PREVIEWED = 'FILE.PREVIEWED',
	FILE_DOWNLOADED = 'FILE.DOWNLOADED',
	FILE_TRASHED = 'FILE.TRASHED',
	FILE_DELETED = 'FILE.DELETED',
	FILE_RESTORED = 'FILE.RESTORED',
	FILE_COPIED = 'FILE.COPIED',
	FILE_MOVED = 'FILE.MOVED',
	FILE_LOCKED = 'FILE.LOCKED',
	FILE_UNLOCKED = 'FILE.UNLOCKED',
	FILE_RENAMED = 'FILE.RENAMED',

	COMMENT_CREATED = 'COMMENT.CREATED',
	COMMENT_UPDATED = 'COMMENT.UPDATED',
	COMMENT_DELETED = 'COMMENT.DELETED',

	TASK_ASSIGNMENT_CREATED = 'TASK_ASSIGNMENT.CREATED',
	TASK_ASSIGNMENT_UPDATED = 'TASK_ASSIGNMENT.UPDATED',

	METADATA_INSTANCE_CREATED = 'METADATA_INSTANCE.CREATED',
	METADATA_INSTANCE_UPDATED = 'METADATA_INSTANCE.UPDATED',
	METADATA_INSTANCE_DELETED = 'METADATA_INSTANCE.DELETED',

	FOLDER_CREATED = 'FOLDER.CREATED',
	FOLDER_DOWNLOADED = 'FOLDER.DOWNLOADED',
	FOLDER_RESTORED = 'FOLDER.RESTORED',
	FOLDER_DELETED = 'FOLDER.DELETED',
	FOLDER_COPIED = 'FOLDER.COPIED',
	FOLDER_MOVED = 'FOLDER.MOVED',
	FOLDER_TRASHED = 'FOLDER.TRASHED',
	FOLDER_RENAMED = 'FOLDER.RENAMED',

	WEBHOOK_DELETED = 'WEBHOOK.DELETED',

	COLLABORATION_CREATED = 'COLLABORATION.CREATED',
	COLLABORATION_ACCEPTED = 'COLLABORATION.ACCEPTED',
	COLLABORATION_REJECTED = 'COLLABORATION.REJECTED',
	COLLABORATION_REMOVED = 'COLLABORATION.REMOVED',
	COLLABORATION_UPDATED = 'COLLABORATION.UPDATED',

	SHARED_LINK_DELETED = 'SHARED_LINK.DELETED',
	SHARED_LINK_CREATED = 'SHARED_LINK.CREATED',
	SHARED_LINK_UPDATED = 'SHARED_LINK.UPDATED',

	SIGN_REQUEST_COMPLETED = 'SIGN_REQUEST.COMPLETED',
	SIGN_REQUEST_DECLINED = 'SIGN_REQUEST.DECLINED',
	SIGN_REQUEST_EXPIRED = 'SIGN_REQUEST.EXPIRED',	
}

// -----------------------------------------------------------------------------
// Private
// -----------------------------------------------------------------------------

// Base path for all webhooks endpoints
const BASE_PATH = '/webhooks';

// This prevents replay attacks
const MAX_MESSAGE_AGE = 10 * 60; // 10 minutes

/**
 * Compute the message signature
 * @see {@Link https://developer.box.com/en/guides/webhooks/handle/setup-signatures/}
 *
 * @param {string} body - The request body of the webhook message
 * @param {Object} headers - The request headers of the webhook message
 * @param {?string} signatureKey - The signature to verify the message with
 * @returns {?string} - The message signature (or null, if it can't be computed)
 * @private
 */
function computeSignature(
	body: string,
	headers: Record<string, any>,
	signatureKey?: string
) {
	if (!signatureKey) {
		return null;
	}

	if (headers['box-signature-version'] !== '1') {
		return null;
	}

	if (headers['box-signature-algorithm'] !== 'HmacSHA256') {
		return null;
	}

	let hmac = crypto.createHmac('sha256', signatureKey);
	hmac.update(body);
	hmac.update(headers['box-delivery-timestamp']);

	const signature = hmac.digest('base64');

	return signature;
}

/**
 * Validate the message signature
 * @see {@Link https://developer.box.com/en/guides/webhooks/handle/verify-signatures/}
 *
 * @param {string} body - The request body of the webhook message
 * @param {Object} headers - The request headers of the webhook message
 * @param {string} [primarySignatureKey] - The primary signature to verify the message with
 * @param {string} [secondarySignatureKey] - The secondary signature to verify the message with
 * @returns {boolean} - True or false
 * @private
 */
function validateSignature(
	body: string,
	headers: Record<string, any>,
	primarySignatureKey?: string,
	secondarySignatureKey?: string
) {
	// Either the primary or secondary signature must match the corresponding signature from Box
	// (The use of two signatures allows the signing keys to be rotated one at a time)
	const primarySignature = computeSignature(body, headers, primarySignatureKey);

	if (
		primarySignature &&
		primarySignature === headers['box-signature-primary']
	) {
		return true;
	}

	const secondarySignature = computeSignature(
		body,
		headers,
		secondarySignatureKey
	);

	if (
		secondarySignature &&
		secondarySignature === headers['box-signature-secondary']
	) {
		return true;
	}

	return false;
}

/**
 * Validate that the delivery timestamp is not too far in the past (to prevent replay attacks)
 *
 * @param {Object} headers - The request headers of the webhook message
 * @param {int} maxMessageAge - The maximum message age (in seconds)
 * @returns {boolean} - True or false
 * @private
 */
function validateDeliveryTimestamp(
	headers: Record<string, any>,
	maxMessageAge: number
) {
	const deliveryTime = Date.parse(headers['box-delivery-timestamp']);
	const currentTime = Date.now();
	const messageAge = (currentTime - deliveryTime) / 1000;

	if (messageAge > maxMessageAge) {
		return false;
	}

	return true;
}

/**
 * Stringify JSON with escaped multibyte Unicode characters to ensure computed signatures match PHP's default behavior
 *
 * @param {Object} body - The parsed JSON object
 * @returns {string} - Stringified JSON with escaped multibyte Unicode characters
 * @private
 */
function jsonStringifyWithEscapedUnicode(body: object) {
	return JSON.stringify(body).replace(
		/[\u007f-\uffff]/g,
		(char) => `\\u${`0000${char.charCodeAt(0).toString(16)}`.slice(-4)}`
	);
}

// -----------------------------------------------------------------------------
// Public
// -----------------------------------------------------------------------------

/**
 * Simple manager for interacting with all 'Webhooks' endpoints and actions.
 *
 * @param {BoxClient} client The Box API Client that is responsible for making calls to the API
 * @constructor
 */
class Webhooks {
	/**
	 * Primary signature key to protect webhooks against attacks.
	 * @static
	 * @type {?string}
	 */
	static primarySignatureKey: string | null = null;

	/**
	 * Secondary signature key to protect webhooks against attacks.
	 * @static
	 * @type {?string}
	 */
	static secondarySignatureKey: string | null = null;

	/**
	 * Sets primary and secondary signatures that are used to verify the Webhooks messages
	 *
	 * @param {string} primaryKey - The primary signature to verify the message with
	 * @param {string} [secondaryKey] - The secondary signature to verify the message with
	 * @returns {void}
	 */
	static setSignatureKeys(primaryKey: string, secondaryKey?: string) {
		Webhooks.primarySignatureKey = primaryKey;

		if (typeof secondaryKey === 'string') {
			Webhooks.secondarySignatureKey = secondaryKey;
		}
	}

	/**
	 * Validate a webhook message by verifying the signature and the delivery timestamp
	 *
	 * @param {string|Object} body - The request body of the webhook message
	 * @param {Object} headers - The request headers of the webhook message
	 * @param {string} [primaryKey] - The primary signature to verify the message with. If it is sent as a parameter,
		 it overrides the static variable primarySignatureKey
	* @param {string} [secondaryKey] - The secondary signature to verify the message with. If it is sent as a parameter,
		it overrides the static variable primarySignatureKey
	* @param {int} [maxMessageAge] - The maximum message age (in seconds).  Defaults to 10 minutes
	* @returns {boolean} - True or false
	*/
	static validateMessage(
		body: string | object,
		headers: Record<string, string>,
		primaryKey?: string,
		secondaryKey?: string,
		maxMessageAge?: number
	) {
		if (!primaryKey && Webhooks.primarySignatureKey) {
			primaryKey = Webhooks.primarySignatureKey;
		}

		if (!secondaryKey && Webhooks.secondarySignatureKey) {
			secondaryKey = Webhooks.secondarySignatureKey;
		}

		if (typeof maxMessageAge !== 'number') {
			maxMessageAge = MAX_MESSAGE_AGE;
		}

		// For frameworks like Express that automatically parse JSON
		// bodies into Objects, re-stringify for signature testing
		if (typeof body === 'object') {
			// Escape forward slashes to ensure a matching signature
			body = jsonStringifyWithEscapedUnicode(body).replace(/\//g, '\\/');
		}

		if (!validateSignature(body, headers, primaryKey, secondaryKey)) {
			return false;
		}

		if (!validateDeliveryTimestamp(headers, maxMessageAge)) {
			return false;
		}

		return true;
	}

	client: BoxClient;

	triggerTypes!: Record<
		| 'FILE'
		| 'COMMENT'
		| 'TASK_ASSIGNMENT'
		| 'METADATA_INSTANCE'
		| 'FOLDER'
		| 'WEBHOOK'
		| 'COLLABORATION'
		| 'SHARED_LINK'
		| 'SIGN_REQUEST',
		Record<string, WebhookTriggerType>
	>;
	validateMessage!: typeof Webhooks.validateMessage;

	constructor(client: BoxClient) {
		// Attach the client, for making API calls
		this.client = client;
	}

	/**
	 * Create a new webhook on a given Box object, specified by type and ID.
	 *
	 * API Endpoint: '/webhooks'
	 * Method: POST
	 *
	 * @param {string} targetID - Box ID  of the item to create webhook on
	 * @param {ItemType} targetType - Type of item the webhook will be created on
	 * @param {string} notificationURL - The URL of your application where Box will notify you of events triggers
	 * @param {WebhookTriggerType[]} triggerTypes - Array of event types that trigger notification for the target
	 * @param {Function} [callback] - Passed the new webhook information if it was acquired successfully
	 * @returns {Promise<Object>} A promise resolving to the new webhook object
	 */
	create(
		targetID: string,
		targetType: string,
		notificationURL: string,
		triggerTypes: WebhookTriggerType[],
		callback?: Function
	) {
		var params = {
			body: {
				target: {
					id: targetID,
					type: targetType,
				},
				address: notificationURL,
				triggers: triggerTypes,
			},
		};

		var apiPath = urlPath(BASE_PATH);
		return this.client.wrapWithDefaultHandler(this.client.post)(
			apiPath,
			params,
			callback
		);
	}

	/**
	 * Returns a webhook object with the specified Webhook ID
	 *
	 * API Endpoint: '/webhooks/:webhookID'
	 * Method: GET
	 *
	 * @param {string} webhookID - ID of the webhook to retrieve
	 * @param {Object} [options] - Additional options for the request. Can be left null in most cases.
	 * @param {Function} [callback] - Passed the webhook information if it was acquired successfully
	 * @returns {Promise<Object>} A promise resolving to the webhook object
	 */
	get(webhookID: string, options?: Record<string, any>, callback?: Function) {
		var params = {
			qs: options,
		};

		var apiPath = urlPath(BASE_PATH, webhookID);
		return this.client.wrapWithDefaultHandler(this.client.get)(
			apiPath,
			params,
			callback
		);
	}

	/**
	 * Get a list of webhooks that are active for the current application and user.
	 *
	 * API Endpoint: '/webhooks'
	 * Method: GET
	 *
	 * @param {Object} [options] - Additional options for the request. Can be left null in most cases.
	 * @param {int} [options.limit=100] - The number of webhooks to return
	 * @param {string} [options.marker] - Pagination marker
	 * @param {Function} [callback] - Passed the list of webhooks if successful, error otherwise
	 * @returns {Promise<Object>} A promise resolving to the collection of webhooks
	 */
	getAll(
		options?: {
			limit?: number;
			marker?: string;
		},
		callback?: Function
	) {
		var params = {
			qs: options,
		};

		var apiPath = urlPath(BASE_PATH);
		return this.client.wrapWithDefaultHandler(this.client.get)(
			apiPath,
			params,
			callback
		);
	}

	/**
	 * Update a webhook
	 *
	 * API Endpoint: '/webhooks/:webhookID'
	 * Method: PUT
	 *
	 * @param {string} webhookID - The ID of the webhook to be updated
	 * @param {Object} updates - Webhook fields to update
	 * @param {string} [updates.address] - The new URL used by Box to send a notification when webhook is triggered
	 * @param {WebhookTriggerType[]} [updates.triggers] - The new events that triggers a notification
	 * @param {Function} [callback] - Passed the updated webhook information if successful, error otherwise
	 * @returns {Promise<Object>} A promise resolving to the updated webhook object
	 */
	update(
		webhookID: string,
		updates?: {
			address?: string;
			triggers?: WebhookTriggerType[];
		},
		callback?: Function
	) {
		var apiPath = urlPath(BASE_PATH, webhookID),
			params = {
				body: updates,
			};

		return this.client.wrapWithDefaultHandler(this.client.put)(
			apiPath,
			params,
			callback
		);
	}

	/**
	 * Delete a specified webhook by ID
	 *
	 * API Endpoint: '/webhooks/:webhookID'
	 * Method: DELETE
	 *
	 * @param {string} webhookID - ID of webhook to be deleted
	 * @param {Function} [callback] - Empty response body passed if successful.
	 * @returns {Promise<void>} A promise resolving to nothing
	 */
	delete(webhookID: string, callback?: Function) {
		var apiPath = urlPath(BASE_PATH, webhookID);
		return this.client.wrapWithDefaultHandler(this.client.del)(
			apiPath,
			null,
			callback
		);
	}
}

/**
 * Enum of valid webhooks event triggers
 *
 * @readonly
 * @enum {WebhookTriggerType}
 */
Webhooks.prototype.triggerTypes = {
	FILE: {
		UPLOADED: WebhookTriggerType.FILE_UPLOADED,
		PREVIEWED: WebhookTriggerType.FILE_PREVIEWED,
		DOWNLOADED: WebhookTriggerType.FILE_DOWNLOADED,
		TRASHED: WebhookTriggerType.FILE_TRASHED,
		DELETED: WebhookTriggerType.FILE_DELETED,
		RESTORED: WebhookTriggerType.FILE_RESTORED,
		COPIED: WebhookTriggerType.FILE_COPIED,
		MOVED: WebhookTriggerType.FILE_MOVED,
		LOCKED: WebhookTriggerType.FILE_LOCKED,
		UNLOCKED: WebhookTriggerType.FILE_UNLOCKED,
		RENAMED: WebhookTriggerType.FILE_RENAMED,
	},
	COMMENT: {
		CREATED: WebhookTriggerType.COMMENT_CREATED,
		UPDATED: WebhookTriggerType.COMMENT_UPDATED,
		DELETED: WebhookTriggerType.COMMENT_DELETED,
	},
	TASK_ASSIGNMENT: {
		CREATED: WebhookTriggerType.TASK_ASSIGNMENT_CREATED,
		UPDATED: WebhookTriggerType.TASK_ASSIGNMENT_UPDATED,
	},
	METADATA_INSTANCE: {
		CREATED: WebhookTriggerType.METADATA_INSTANCE_CREATED,
		UPDATED: WebhookTriggerType.METADATA_INSTANCE_UPDATED,
		DELETED: WebhookTriggerType.METADATA_INSTANCE_DELETED,
	},
	FOLDER: {
		CREATED: WebhookTriggerType.FOLDER_CREATED,
		DOWNLOADED: WebhookTriggerType.FOLDER_DOWNLOADED,
		RESTORED: WebhookTriggerType.FOLDER_RESTORED,
		DELETED: WebhookTriggerType.FOLDER_DELETED,
		COPIED: WebhookTriggerType.FOLDER_COPIED,
		MOVED: WebhookTriggerType.FOLDER_MOVED,
		TRASHED: WebhookTriggerType.FOLDER_TRASHED,
		RENAMED: WebhookTriggerType.FOLDER_RENAMED,
	},
	WEBHOOK: {
		DELETED: WebhookTriggerType.WEBHOOK_DELETED,
	},
	COLLABORATION: {
		CREATED: WebhookTriggerType.COLLABORATION_CREATED,
		ACCEPTED: WebhookTriggerType.COLLABORATION_ACCEPTED,
		REJECTED: WebhookTriggerType.COLLABORATION_REJECTED,
		REMOVED: WebhookTriggerType.COLLABORATION_REMOVED,
		UPDATED: WebhookTriggerType.COLLABORATION_UPDATED,
	},
	SHARED_LINK: {
		DELETED: WebhookTriggerType.SHARED_LINK_DELETED,
		CREATED: WebhookTriggerType.SHARED_LINK_CREATED,
		UPDATED: WebhookTriggerType.SHARED_LINK_UPDATED,
	},
	SIGN_REQUEST: {
		COMPLETED: WebhookTriggerType.SIGN_REQUEST_COMPLETED,
		DECLINED: WebhookTriggerType.SIGN_REQUEST_DECLINED,
		EXPIRED: WebhookTriggerType.SIGN_REQUEST_EXPIRED,
	}
};

Webhooks.prototype.validateMessage = Webhooks.validateMessage;

/**
 * @module box-node-sdk/lib/managers/webhooks
 * @see {@Link Webhooks}
 */
export = Webhooks;