Search code examples
node.jsexpresswebhookshmactodoist

Validating request payload in Express.js for Todoist Webhook API


I am trying to integrate the Webhooks API from Todoist. I get the correct header and body information, but fail to validate the X-Todoist-Hmac-SHA256 header. From the Todoist documentation:

To verify each webhook request was indeed sent by Todoist, an X-Todoist-Hmac-SHA256 header is included; it is a SHA256 Hmac generated using your client_secret as the encryption key and the whole request payload as the message to be encrypted. The resulting Hmac would be encoded in a base64 string.

Now this is my code for the webhook route using express.js and the Node.js Crypto library for decrypting:

app.post("/webhooks/todoist", async (req, res) => {

    // this is my stored client secret from Todoist
    const secret = keys.todoistClientSecret 
    
    // Using the Node.js Crypto library
    const hash = crypto.createHmac('sha256', secret)
      .update(req.toString()) // <-- is this the "whole request payload"?
      .digest("base64");


    // These 2 are not equal
    console.log("X-Todoist-Hmac-SHA256:", req.header("X-Todoist-Hmac-SHA256"))
    console.log("Hash:", hash)

    res.status(200).send()
  })

I already found out that req is of type IncomingMessage. The Crypto library accepts only certain types, if I pass the req object itself, I get the following error:

The "data" argument must be of type string or an instance of Buffer, TypedArray, or DataView.

What is the correct way to pass the "whole request payload" to the crypto library?


Solution

  • Ok, so after try-and-error-ing some other variants, I found the solution. You have to pass JSON.stringify(req.body) as the message to encrypt. Maybe this helps others.

        const hash = crypto.createHmac('sha256', secret)
          .update(JSON.stringify(req.body)) // <-- this is the needed message to encrypt
          .digest("base64");

    Import update for special character encoding

    I used app.use(bodyParser.json()) in my express setup. This will not work for request bodies that have special characters (like German Umlauts ä, ö, ü). Instead, I will have to set the body parser up like this:

    app.use(bodyParser.json({
      verify: (req, res, buf) => {
        req.rawBody = buf
      }
    }))
    

    Then, in the encryption instead of passing JSON.stringify(req.body) it has to be req.rawBody. So the final code for the crypto library looks like this:

        const secret = keys.todoistClientSecret
        const hash = crypto.createHmac('sha256', secret)
          .update(req.rawBody)
          .digest("base64");