webhook.js

/**
 * @fileoverview This file contains a wrapper for a TidyHQ Webhook.
 * @author Sean McGinty <newfolderlocation@gmail.com>
 * @version 1.0.0
 * @license GPL-3.0
 */

const axios = require("axios");
const crypto = require("crypto");

/**
 * @description This class is used to listen and act on Webhooks.
 * @class
 */
class TidyHQWebhook {

    /**
     * @type {Record<string, Function>}
     */
    callbacks = {};

    /**
     * @description Create a new instance of the TidyHQWebhook class.
     * @param {string} webhookId - The ID of the webhook.
     * @param {string} signingKey - The signing key for the webhook.
     * @constructor
     */
    constructor(webhookId, signingKey) {
        this.signingKey = signingKey;
        this.webhookId = webhookId;
    }

    /**
     * @param {string} event 
     * @param {Function} callback 
     */
    registerCallback(event, callback) {
        this.callbacks[event] = callback;
    }

    /**
     * @param {string} event 
     * @param {object} data 
     */
    handleEvent(event, data) {
        if (this.callbacks[event]) {
            this.callbacks[event](data);
        } else {
            console.log("No callback registered for event: " + event);
            console.log(data);
        }
    }

    /**
     * @description Verify a message from TidyHQ.
     * @param {string} tidySignatureHeader - The signature header from TidyHQ.
     * @param {string} body - The raw body of the message.
     * @param {string} httpMethod - The HTTP method of the webhook.
     * @returns {void | string} - If an error occurs, the error message is returned.
     */
    verifyAndHandle(tidySignatureHeader, body, httpMethod = 'POST') {
        this.verify(tidySignatureHeader, body, httpMethod).then((data) => {
            this.handleEvent(data.kind, data.data);
        }).catch((error) => {
            return error;
        });
    }

    /**
     * @description Verify a message from TidyHQ.
     * @param {string} tidySignatureHeader - The signature header from TidyHQ.
     * @param {string} body - The raw body of the message.
     * @param {string} httpMethod - The HTTP method of the webhook.
     * @returns {Promise<Tidy_V2_WebhookMessage>} - The message from the webhook.
     */
    async verify(tidySignatureHeader, body, httpMethod = 'POST') {
        const signingKey = Buffer.from(this.signingKey, 'base64')
        const details = this.parseHeader(tidySignatureHeader, 'v1')
        const tolerance = 300

        if (!details || details.timestamp === -1) {
            throw new Error('Unable to extract timestamp and signatures from header')
        }

        if (!details.signatures.length) {
            throw new Error('No signatures found with expected scheme')
        }

        const timestamp = details.timestamp
        const signature = details.signatures[0]

        const timestampedPayload = `${timestamp}.${body}`

        const expectedSignature = crypto.createHmac('sha256', signingKey)
            .update(timestampedPayload, 'utf8')
            .digest('hex')

        if (signature !== expectedSignature) {
            throw new Error('Signature mismatch')
        }

        const timestampAge = Math.floor(Date.now() / 1000) - timestamp

        if (tolerance > 0 && timestampAge > tolerance) {
            throw new Error('Timestamp outside the tolerance zone')
        }

        const data = JSON.parse(body);

        if (data.webhook_id !== this.webhookId) {
            throw new Error(`There has been a webhook ID mismatch, expected ${this.webhookId} got ${data.webhook_id}`)
        }

        if (data.http_method !== httpMethod) {
            throw new Error(`There has been a HTTP method mismatch, expected ${httpMethod} got ${data.http_method}`)
        }

        return data
    }

    /**
     * 
     * @param {string | null | undefined} header 
     * @param {string} scheme 
     * @returns {{ timestamp: number, signatures: string[] } | null}
     */
    parseHeader(header, scheme) {
        if (typeof header !== 'string') {
            return null
        }

        return header.split(',').reduce(
            /**
             * @param {{ timestamp: number, signatures: string[] }} accum
             * @param {string} item
             * @returns {{ timestamp: number, signatures: string[] }}
             */
            (accum, item) => {
                const kv = item.split('=')

                if (kv[0] === 't') {
                    accum.timestamp = parseInt(kv[1], 10)
                }

                if (kv[0] === scheme) {
                    accum.signatures.push(kv[1])
                }

                return accum
            },
            {
                timestamp: -1,
                signatures: [],
            }
        )
    }

}

module.exports = TidyHQWebhook;