Source: managers/webhooks.ts

  1. /**
  2. * @fileoverview Manager for the Box Webhooks resource
  3. */
  4. // -----------------------------------------------------------------------------
  5. // Requirements
  6. // -----------------------------------------------------------------------------
  7. import urlPath from '../util/url-path';
  8. import crypto from 'crypto';
  9. import BoxClient from '../box-client';
  10. // -----------------------------------------------------------------------------
  11. // Typedefs
  12. // -----------------------------------------------------------------------------
  13. /**
  14. * A webhook trigger type constant
  15. * @typedef {string} WebhookTriggerType
  16. */
  17. enum WebhookTriggerType {
  18. FILE_UPLOADED = 'FILE.UPLOADED',
  19. FILE_PREVIEWED = 'FILE.PREVIEWED',
  20. FILE_DOWNLOADED = 'FILE.DOWNLOADED',
  21. FILE_TRASHED = 'FILE.TRASHED',
  22. FILE_DELETED = 'FILE.DELETED',
  23. FILE_RESTORED = 'FILE.RESTORED',
  24. FILE_COPIED = 'FILE.COPIED',
  25. FILE_MOVED = 'FILE.MOVED',
  26. FILE_LOCKED = 'FILE.LOCKED',
  27. FILE_UNLOCKED = 'FILE.UNLOCKED',
  28. FILE_RENAMED = 'FILE.RENAMED',
  29. COMMENT_CREATED = 'COMMENT.CREATED',
  30. COMMENT_UPDATED = 'COMMENT.UPDATED',
  31. COMMENT_DELETED = 'COMMENT.DELETED',
  32. TASK_ASSIGNMENT_CREATED = 'TASK_ASSIGNMENT.CREATED',
  33. TASK_ASSIGNMENT_UPDATED = 'TASK_ASSIGNMENT.UPDATED',
  34. METADATA_INSTANCE_CREATED = 'METADATA_INSTANCE.CREATED',
  35. METADATA_INSTANCE_UPDATED = 'METADATA_INSTANCE.UPDATED',
  36. METADATA_INSTANCE_DELETED = 'METADATA_INSTANCE.DELETED',
  37. FOLDER_CREATED = 'FOLDER.CREATED',
  38. FOLDER_DOWNLOADED = 'FOLDER.DOWNLOADED',
  39. FOLDER_RESTORED = 'FOLDER.RESTORED',
  40. FOLDER_DELETED = 'FOLDER.DELETED',
  41. FOLDER_COPIED = 'FOLDER.COPIED',
  42. FOLDER_MOVED = 'FOLDER.MOVED',
  43. FOLDER_TRASHED = 'FOLDER.TRASHED',
  44. FOLDER_RENAMED = 'FOLDER.RENAMED',
  45. WEBHOOK_DELETED = 'WEBHOOK.DELETED',
  46. COLLABORATION_CREATED = 'COLLABORATION.CREATED',
  47. COLLABORATION_ACCEPTED = 'COLLABORATION.ACCEPTED',
  48. COLLABORATION_REJECTED = 'COLLABORATION.REJECTED',
  49. COLLABORATION_REMOVED = 'COLLABORATION.REMOVED',
  50. COLLABORATION_UPDATED = 'COLLABORATION.UPDATED',
  51. SHARED_LINK_DELETED = 'SHARED_LINK.DELETED',
  52. SHARED_LINK_CREATED = 'SHARED_LINK.CREATED',
  53. SHARED_LINK_UPDATED = 'SHARED_LINK.UPDATED',
  54. SIGN_REQUEST_COMPLETED = 'SIGN_REQUEST.COMPLETED',
  55. SIGN_REQUEST_DECLINED = 'SIGN_REQUEST.DECLINED',
  56. SIGN_REQUEST_EXPIRED = 'SIGN_REQUEST.EXPIRED',
  57. }
  58. // -----------------------------------------------------------------------------
  59. // Private
  60. // -----------------------------------------------------------------------------
  61. // Base path for all webhooks endpoints
  62. const BASE_PATH = '/webhooks';
  63. // This prevents replay attacks
  64. const MAX_MESSAGE_AGE = 10 * 60; // 10 minutes
  65. /**
  66. * Compute the message signature
  67. * @see {@Link https://developer.box.com/en/guides/webhooks/handle/setup-signatures/}
  68. *
  69. * @param {string} body - The request body of the webhook message
  70. * @param {Object} headers - The request headers of the webhook message
  71. * @param {?string} signatureKey - The signature to verify the message with
  72. * @returns {?string} - The message signature (or null, if it can't be computed)
  73. * @private
  74. */
  75. function computeSignature(
  76. body: string,
  77. headers: Record<string, any>,
  78. signatureKey?: string
  79. ) {
  80. if (!signatureKey) {
  81. return null;
  82. }
  83. if (headers['box-signature-version'] !== '1') {
  84. return null;
  85. }
  86. if (headers['box-signature-algorithm'] !== 'HmacSHA256') {
  87. return null;
  88. }
  89. let hmac = crypto.createHmac('sha256', signatureKey);
  90. hmac.update(body);
  91. hmac.update(headers['box-delivery-timestamp']);
  92. const signature = hmac.digest('base64');
  93. return signature;
  94. }
  95. /**
  96. * Validate the message signature
  97. * @see {@Link https://developer.box.com/en/guides/webhooks/handle/verify-signatures/}
  98. *
  99. * @param {string} body - The request body of the webhook message
  100. * @param {Object} headers - The request headers of the webhook message
  101. * @param {string} [primarySignatureKey] - The primary signature to verify the message with
  102. * @param {string} [secondarySignatureKey] - The secondary signature to verify the message with
  103. * @returns {boolean} - True or false
  104. * @private
  105. */
  106. function validateSignature(
  107. body: string,
  108. headers: Record<string, any>,
  109. primarySignatureKey?: string,
  110. secondarySignatureKey?: string
  111. ) {
  112. // Either the primary or secondary signature must match the corresponding signature from Box
  113. // (The use of two signatures allows the signing keys to be rotated one at a time)
  114. const primarySignature = computeSignature(body, headers, primarySignatureKey);
  115. if (
  116. primarySignature &&
  117. primarySignature === headers['box-signature-primary']
  118. ) {
  119. return true;
  120. }
  121. const secondarySignature = computeSignature(
  122. body,
  123. headers,
  124. secondarySignatureKey
  125. );
  126. if (
  127. secondarySignature &&
  128. secondarySignature === headers['box-signature-secondary']
  129. ) {
  130. return true;
  131. }
  132. return false;
  133. }
  134. /**
  135. * Validate that the delivery timestamp is not too far in the past (to prevent replay attacks)
  136. *
  137. * @param {Object} headers - The request headers of the webhook message
  138. * @param {int} maxMessageAge - The maximum message age (in seconds)
  139. * @returns {boolean} - True or false
  140. * @private
  141. */
  142. function validateDeliveryTimestamp(
  143. headers: Record<string, any>,
  144. maxMessageAge: number
  145. ) {
  146. const deliveryTime = Date.parse(headers['box-delivery-timestamp']);
  147. const currentTime = Date.now();
  148. const messageAge = (currentTime - deliveryTime) / 1000;
  149. if (messageAge > maxMessageAge) {
  150. return false;
  151. }
  152. return true;
  153. }
  154. /**
  155. * Stringify JSON with escaped multibyte Unicode characters to ensure computed signatures match PHP's default behavior
  156. *
  157. * @param {Object} body - The parsed JSON object
  158. * @returns {string} - Stringified JSON with escaped multibyte Unicode characters
  159. * @private
  160. */
  161. function jsonStringifyWithEscapedUnicode(body: object) {
  162. return JSON.stringify(body).replace(
  163. /[\u007f-\uffff]/g,
  164. (char) => `\\u${`0000${char.charCodeAt(0).toString(16)}`.slice(-4)}`
  165. );
  166. }
  167. // -----------------------------------------------------------------------------
  168. // Public
  169. // -----------------------------------------------------------------------------
  170. /**
  171. * Simple manager for interacting with all 'Webhooks' endpoints and actions.
  172. *
  173. * @param {BoxClient} client The Box API Client that is responsible for making calls to the API
  174. * @constructor
  175. */
  176. class Webhooks {
  177. /**
  178. * Primary signature key to protect webhooks against attacks.
  179. * @static
  180. * @type {?string}
  181. */
  182. static primarySignatureKey: string | null = null;
  183. /**
  184. * Secondary signature key to protect webhooks against attacks.
  185. * @static
  186. * @type {?string}
  187. */
  188. static secondarySignatureKey: string | null = null;
  189. /**
  190. * Sets primary and secondary signatures that are used to verify the Webhooks messages
  191. *
  192. * @param {string} primaryKey - The primary signature to verify the message with
  193. * @param {string} [secondaryKey] - The secondary signature to verify the message with
  194. * @returns {void}
  195. */
  196. static setSignatureKeys(primaryKey: string, secondaryKey?: string) {
  197. Webhooks.primarySignatureKey = primaryKey;
  198. if (typeof secondaryKey === 'string') {
  199. Webhooks.secondarySignatureKey = secondaryKey;
  200. }
  201. }
  202. /**
  203. * Validate a webhook message by verifying the signature and the delivery timestamp
  204. *
  205. * @param {string|Object} body - The request body of the webhook message
  206. * @param {Object} headers - The request headers of the webhook message
  207. * @param {string} [primaryKey] - The primary signature to verify the message with. If it is sent as a parameter,
  208. it overrides the static variable primarySignatureKey
  209. * @param {string} [secondaryKey] - The secondary signature to verify the message with. If it is sent as a parameter,
  210. it overrides the static variable primarySignatureKey
  211. * @param {int} [maxMessageAge] - The maximum message age (in seconds). Defaults to 10 minutes
  212. * @returns {boolean} - True or false
  213. */
  214. static validateMessage(
  215. body: string | object,
  216. headers: Record<string, string>,
  217. primaryKey?: string,
  218. secondaryKey?: string,
  219. maxMessageAge?: number
  220. ) {
  221. if (!primaryKey && Webhooks.primarySignatureKey) {
  222. primaryKey = Webhooks.primarySignatureKey;
  223. }
  224. if (!secondaryKey && Webhooks.secondarySignatureKey) {
  225. secondaryKey = Webhooks.secondarySignatureKey;
  226. }
  227. if (typeof maxMessageAge !== 'number') {
  228. maxMessageAge = MAX_MESSAGE_AGE;
  229. }
  230. // For frameworks like Express that automatically parse JSON
  231. // bodies into Objects, re-stringify for signature testing
  232. if (typeof body === 'object') {
  233. // Escape forward slashes to ensure a matching signature
  234. body = jsonStringifyWithEscapedUnicode(body).replace(/\//g, '\\/');
  235. }
  236. if (!validateSignature(body, headers, primaryKey, secondaryKey)) {
  237. return false;
  238. }
  239. if (!validateDeliveryTimestamp(headers, maxMessageAge)) {
  240. return false;
  241. }
  242. return true;
  243. }
  244. client: BoxClient;
  245. triggerTypes!: Record<
  246. | 'FILE'
  247. | 'COMMENT'
  248. | 'TASK_ASSIGNMENT'
  249. | 'METADATA_INSTANCE'
  250. | 'FOLDER'
  251. | 'WEBHOOK'
  252. | 'COLLABORATION'
  253. | 'SHARED_LINK'
  254. | 'SIGN_REQUEST',
  255. Record<string, WebhookTriggerType>
  256. >;
  257. validateMessage!: typeof Webhooks.validateMessage;
  258. constructor(client: BoxClient) {
  259. // Attach the client, for making API calls
  260. this.client = client;
  261. }
  262. /**
  263. * Create a new webhook on a given Box object, specified by type and ID.
  264. *
  265. * API Endpoint: '/webhooks'
  266. * Method: POST
  267. *
  268. * @param {string} targetID - Box ID of the item to create webhook on
  269. * @param {ItemType} targetType - Type of item the webhook will be created on
  270. * @param {string} notificationURL - The URL of your application where Box will notify you of events triggers
  271. * @param {WebhookTriggerType[]} triggerTypes - Array of event types that trigger notification for the target
  272. * @param {Function} [callback] - Passed the new webhook information if it was acquired successfully
  273. * @returns {Promise<Object>} A promise resolving to the new webhook object
  274. */
  275. create(
  276. targetID: string,
  277. targetType: string,
  278. notificationURL: string,
  279. triggerTypes: WebhookTriggerType[],
  280. callback?: Function
  281. ) {
  282. var params = {
  283. body: {
  284. target: {
  285. id: targetID,
  286. type: targetType,
  287. },
  288. address: notificationURL,
  289. triggers: triggerTypes,
  290. },
  291. };
  292. var apiPath = urlPath(BASE_PATH);
  293. return this.client.wrapWithDefaultHandler(this.client.post)(
  294. apiPath,
  295. params,
  296. callback
  297. );
  298. }
  299. /**
  300. * Returns a webhook object with the specified Webhook ID
  301. *
  302. * API Endpoint: '/webhooks/:webhookID'
  303. * Method: GET
  304. *
  305. * @param {string} webhookID - ID of the webhook to retrieve
  306. * @param {Object} [options] - Additional options for the request. Can be left null in most cases.
  307. * @param {Function} [callback] - Passed the webhook information if it was acquired successfully
  308. * @returns {Promise<Object>} A promise resolving to the webhook object
  309. */
  310. get(webhookID: string, options?: Record<string, any>, callback?: Function) {
  311. var params = {
  312. qs: options,
  313. };
  314. var apiPath = urlPath(BASE_PATH, webhookID);
  315. return this.client.wrapWithDefaultHandler(this.client.get)(
  316. apiPath,
  317. params,
  318. callback
  319. );
  320. }
  321. /**
  322. * Get a list of webhooks that are active for the current application and user.
  323. *
  324. * API Endpoint: '/webhooks'
  325. * Method: GET
  326. *
  327. * @param {Object} [options] - Additional options for the request. Can be left null in most cases.
  328. * @param {int} [options.limit=100] - The number of webhooks to return
  329. * @param {string} [options.marker] - Pagination marker
  330. * @param {Function} [callback] - Passed the list of webhooks if successful, error otherwise
  331. * @returns {Promise<Object>} A promise resolving to the collection of webhooks
  332. */
  333. getAll(
  334. options?: {
  335. limit?: number;
  336. marker?: string;
  337. },
  338. callback?: Function
  339. ) {
  340. var params = {
  341. qs: options,
  342. };
  343. var apiPath = urlPath(BASE_PATH);
  344. return this.client.wrapWithDefaultHandler(this.client.get)(
  345. apiPath,
  346. params,
  347. callback
  348. );
  349. }
  350. /**
  351. * Update a webhook
  352. *
  353. * API Endpoint: '/webhooks/:webhookID'
  354. * Method: PUT
  355. *
  356. * @param {string} webhookID - The ID of the webhook to be updated
  357. * @param {Object} updates - Webhook fields to update
  358. * @param {string} [updates.address] - The new URL used by Box to send a notification when webhook is triggered
  359. * @param {WebhookTriggerType[]} [updates.triggers] - The new events that triggers a notification
  360. * @param {Function} [callback] - Passed the updated webhook information if successful, error otherwise
  361. * @returns {Promise<Object>} A promise resolving to the updated webhook object
  362. */
  363. update(
  364. webhookID: string,
  365. updates?: {
  366. address?: string;
  367. triggers?: WebhookTriggerType[];
  368. },
  369. callback?: Function
  370. ) {
  371. var apiPath = urlPath(BASE_PATH, webhookID),
  372. params = {
  373. body: updates,
  374. };
  375. return this.client.wrapWithDefaultHandler(this.client.put)(
  376. apiPath,
  377. params,
  378. callback
  379. );
  380. }
  381. /**
  382. * Delete a specified webhook by ID
  383. *
  384. * API Endpoint: '/webhooks/:webhookID'
  385. * Method: DELETE
  386. *
  387. * @param {string} webhookID - ID of webhook to be deleted
  388. * @param {Function} [callback] - Empty response body passed if successful.
  389. * @returns {Promise<void>} A promise resolving to nothing
  390. */
  391. delete(webhookID: string, callback?: Function) {
  392. var apiPath = urlPath(BASE_PATH, webhookID);
  393. return this.client.wrapWithDefaultHandler(this.client.del)(
  394. apiPath,
  395. null,
  396. callback
  397. );
  398. }
  399. }
  400. /**
  401. * Enum of valid webhooks event triggers
  402. *
  403. * @readonly
  404. * @enum {WebhookTriggerType}
  405. */
  406. Webhooks.prototype.triggerTypes = {
  407. FILE: {
  408. UPLOADED: WebhookTriggerType.FILE_UPLOADED,
  409. PREVIEWED: WebhookTriggerType.FILE_PREVIEWED,
  410. DOWNLOADED: WebhookTriggerType.FILE_DOWNLOADED,
  411. TRASHED: WebhookTriggerType.FILE_TRASHED,
  412. DELETED: WebhookTriggerType.FILE_DELETED,
  413. RESTORED: WebhookTriggerType.FILE_RESTORED,
  414. COPIED: WebhookTriggerType.FILE_COPIED,
  415. MOVED: WebhookTriggerType.FILE_MOVED,
  416. LOCKED: WebhookTriggerType.FILE_LOCKED,
  417. UNLOCKED: WebhookTriggerType.FILE_UNLOCKED,
  418. RENAMED: WebhookTriggerType.FILE_RENAMED,
  419. },
  420. COMMENT: {
  421. CREATED: WebhookTriggerType.COMMENT_CREATED,
  422. UPDATED: WebhookTriggerType.COMMENT_UPDATED,
  423. DELETED: WebhookTriggerType.COMMENT_DELETED,
  424. },
  425. TASK_ASSIGNMENT: {
  426. CREATED: WebhookTriggerType.TASK_ASSIGNMENT_CREATED,
  427. UPDATED: WebhookTriggerType.TASK_ASSIGNMENT_UPDATED,
  428. },
  429. METADATA_INSTANCE: {
  430. CREATED: WebhookTriggerType.METADATA_INSTANCE_CREATED,
  431. UPDATED: WebhookTriggerType.METADATA_INSTANCE_UPDATED,
  432. DELETED: WebhookTriggerType.METADATA_INSTANCE_DELETED,
  433. },
  434. FOLDER: {
  435. CREATED: WebhookTriggerType.FOLDER_CREATED,
  436. DOWNLOADED: WebhookTriggerType.FOLDER_DOWNLOADED,
  437. RESTORED: WebhookTriggerType.FOLDER_RESTORED,
  438. DELETED: WebhookTriggerType.FOLDER_DELETED,
  439. COPIED: WebhookTriggerType.FOLDER_COPIED,
  440. MOVED: WebhookTriggerType.FOLDER_MOVED,
  441. TRASHED: WebhookTriggerType.FOLDER_TRASHED,
  442. RENAMED: WebhookTriggerType.FOLDER_RENAMED,
  443. },
  444. WEBHOOK: {
  445. DELETED: WebhookTriggerType.WEBHOOK_DELETED,
  446. },
  447. COLLABORATION: {
  448. CREATED: WebhookTriggerType.COLLABORATION_CREATED,
  449. ACCEPTED: WebhookTriggerType.COLLABORATION_ACCEPTED,
  450. REJECTED: WebhookTriggerType.COLLABORATION_REJECTED,
  451. REMOVED: WebhookTriggerType.COLLABORATION_REMOVED,
  452. UPDATED: WebhookTriggerType.COLLABORATION_UPDATED,
  453. },
  454. SHARED_LINK: {
  455. DELETED: WebhookTriggerType.SHARED_LINK_DELETED,
  456. CREATED: WebhookTriggerType.SHARED_LINK_CREATED,
  457. UPDATED: WebhookTriggerType.SHARED_LINK_UPDATED,
  458. },
  459. SIGN_REQUEST: {
  460. COMPLETED: WebhookTriggerType.SIGN_REQUEST_COMPLETED,
  461. DECLINED: WebhookTriggerType.SIGN_REQUEST_DECLINED,
  462. EXPIRED: WebhookTriggerType.SIGN_REQUEST_EXPIRED,
  463. }
  464. };
  465. Webhooks.prototype.validateMessage = Webhooks.validateMessage;
  466. /**
  467. * @module box-node-sdk/lib/managers/webhooks
  468. * @see {@Link Webhooks}
  469. */
  470. export = Webhooks;