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 &&
    compareSignatures(primarySignature, headers['box-signature-primary'])
  ) {
    return true;
  }

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

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

  return false;
}

/**
 * Compare two signatures using a timing-safe comparison
 * @param expectedSignature Expected signature in base64 format
 * @param receivedSignature Received signature in base64 format
 */
function compareSignatures(
  expectedSignature: string,
  receivedSignature: string
): boolean {
  const expectedBuffer = Buffer.from(expectedSignature, 'base64');
  const receivedBuffer = Buffer.from(receivedSignature, 'base64');
  return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}

/**
 * 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;