Verify Zoom Webhook in Worker

I want to process Zoom Meeting Webhooks in a Cloudflare worker. It makes sense to verify the post data is from Zoom following the instructions here: https://marketplace.zoom.us/docs/api-reference/webhook-reference/#verify-webhook-events

However, the example code given uses Node.JS using a different crypto library to Cloudflare workers. I’ve tried to convert this to use subtle crypto, but whilst I’m using some examples to help, I’m struggling to get my head around it all (as always with crypto stuff).

Has anyone already done this (not found anything from searching)?

Here’s the code I have got to so far:

export default {
    async fetch(request, env) {
        return await handleRequest(request).catch(
            (err) => new Response(err.stack, {
                status: 500
            })
        )
    }
}

async function handleRequest(request) {
    const req = await request.json()
    const ZOOM_WEBHOOK_SECRET_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXX'

	// https://marketplace.zoom.us/docs/api-reference/webhook-reference/#verify-webhook-events

	let requestHeaders = Object.fromEntries(request.headers)
	let response
	const verifyMessage = `v0:${requestHeaders['x-zm-request-timestamp']}:${JSON.stringify(req)}`

	const algorithm = {
		name: "HMAC",
		hash: "SHA-256"
	};
	const encoder = new TextEncoder();

	const key = await crypto.subtle.importKey(
		"raw",
		byteStringToUint8Array(ZOOM_WEBHOOK_SECRET_TOKEN),
		algorithm,
		false,
		["verify"]
	)

	const verified = await crypto.subtle.verify(
		algorithm.name,
		key,
		byteStringToUint8Array(requestHeaders['x-zm-signature']),
		byteStringToUint8Array(verifyMessage)
	)

	if (verified) {
		// Webhook request came from Zoom

		// business logic here, example make API request to Zoom or 3rd party

		response = {
			message: 'Authorized request',
			status: 200
		}
	} else {
		// Webhook request did not come from Zoom
		response = {
			message: 'Request not authorized',
			status: 401
		}
	}

	// return the reponse (authorized or not)
	console.log(response)
	return new Response(JSON.stringify(response), {
		headers: {
			'content-type': 'application/json;charset=UTF-8',
		},
	})    
}


// Convert a ByteString (a string whose code units are all in the range
// [0, 255]), to a Uint8Array. If you pass in a string with code units larger
// than 255, their values will overflow.
function byteStringToUint8Array(byteString) {
    const ui = new Uint8Array(byteString.length)
    for (let i = 0; i < byteString.length; ++i) {
        ui[i] = byteString.charCodeAt(i)
    }
    return ui
}

Hey @user93310

If you are needing to create a hex hash, have a look at the Converting a digest to a hex string example on MDN

Thankyou @the ! I think that’s pointed me in the right direction. I’ve now got the following code - although it’s still not verifying, I think this now mimics the zoom example.

export default {
    async fetch(request, env) {
        return await handleRequest(request).catch(
            (err) => new Response(err.stack, {
                status: 500
            })
        )
    }
}

async function handleRequest(request) {
    const req = await request.json()
    const ZOOM_WEBHOOK_SECRET_TOKEN = 'XXXXXXXXXXXXXXXXXXXXXX'

	// https://marketplace.zoom.us/docs/api-reference/webhook-reference/#verify-webhook-events
	let requestHeaders = Object.fromEntries(request.headers)
	let response

	const message = `v0:${requestHeaders['x-zm-request-timestamp']}:${JSON.stringify(req)}`

	const hashForVerify = await digestMessage(message)

	const signature = `v0=${hashForVerify}`

	console.log(req)
	console.log(requestHeaders['x-zm-signature'])
	console.log(signature)

	if (requestHeaders['x-zm-signature'] === signature) {

		// Webhook request came from Zoom

		// business logic here, example make API request to Zoom or 3rd party

		response = {
			message: 'Authorized request',
			status: 200
		}
	} else {
		// Webhook request did not come from Zoom
		response = {
			message: 'Request not authorized',
			status: 401
		}
	}

	// return the reponse (authorized or not)
	console.log(response)
	return new Response(response.message, {
		status: response.status
	})
}

async function digestMessage(message) {
  const msgUint8 = new TextEncoder().encode(message);                           // encode as (utf-8) Uint8Array
  const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);           // hash the message
  const hashArray = Array.from(new Uint8Array(hashBuffer));                     // convert buffer to byte array
  const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
  return hashHex;
}

As I don’t real data to test this on, it’s a little hard to offer pointers.

I must note too that the digestMessage function works very different to the importKey()/verify() functions of SubtleCrypto.

Thanks @the - it looks like the sample code from Zoom uses digest in a similar way. I’d love to provide real data but that’s not going to be safe to do so.

I have noted that the Zoom sample app seems to have commented out all this verfication, so maybe its something broken at Zoom’s end.

I’ve got a Zoom dev forum post running here in parallel: Verify Zoom Webhooks using a CloudFlare Worker - API and Webhooks - Zoom Developer Forum