My team chose to situate the decryption logic in our NodeJS API rather than our NextJS middleware, to circumvent this issue. Here is the original middleware I tried to use, in full:
import { NextRequest, NextResponse } from "next/server";
export type ZoomAppContext = {
uid: string;
aud: string;
iss: "marketplace.zoom.us" | string;
ts: number;
exp: number;
} & (
| { typ: "panel" }
| { typ: "meeting"; mid: string; attendrole: "host" | string }
);
export function ZoomMiddleware(request: NextRequest) {
//Detect and decrypt Zoom app context
const zoomAppCtx = request.headers.get("x-zoom-app-context");
if (zoomAppCtx) {
const decrypted = decryptAppContext(
zoomAppCtx,
process.env.ZOOM_CLIENT_SECRET
);
console.log("decrypted", decrypted);
}
return NextResponse.next();
}
/**
* Decode and parse a base64 encoded Zoom App Context
* @param ctx - Encoded Zoom App Context
* @return Decoded Zoom App Context object
*/
function unpack(ctx: string) {
// Decode base64
let buf = Buffer.from(ctx, "base64");
// Get iv length (1 byte)
const ivLength = buf.readUInt8();
buf = buf.subarray(1);
// Get iv
const iv = buf.subarray(0, ivLength);
buf = buf.subarray(ivLength);
// Get aad length (2 bytes)
const aadLength = buf.readUInt16LE();
buf = buf.subarray(2);
// Get aad
const aad = buf.subarray(0, aadLength);
buf = buf.subarray(aadLength);
// Get cipher length (4 bytes)
const cipherLength = buf.readInt32LE();
buf = buf.subarray(4);
// Get cipherText
const cipherText = buf.subarray(0, cipherLength);
// Get tag
const tag = buf.subarray(cipherLength);
return { iv, aad, cipherText, tag };
}
/**
* Decrypts cipherText from a decoded Zoom App Context object
* @param cipherText - Data to decrypt
* @param hash - sha256 hash of the Client Secret
* @param iv - Initialization Vector for cipherText
* @param aad - Additional Auth Data for cipher
* @param tag - cipherText auth tag
*/
async function decryptCtxWithHash(
cipherText: Buffer,
hash: ArrayBuffer,
iv: Buffer,
aad: Buffer,
tag: Buffer
) {
const key = await crypto.subtle.importKey("raw", hash, "aes-gcm", false, [
"decrypt",
]);
// AES/GCM decryption
const deciphered = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv, additionalData: aad, tagLength: tag.byteLength * 8, },
key,
cipherText
);
const value = deciphered.slice(0, deciphered.byteLength - tag.byteLength);
console.log("value", value);
return JSON.parse(new TextDecoder().decode(value)) as ZoomAppContext;
}
/**
* Decodes, parses and decrypts the x-zoom-app-context header
* @see https://marketplace.zoom.us/docs/beta-docs/zoom-apps/zoomappcontext#decrypting-the-header-value
* @param header - Encoded Zoom App Context header
* @param key - Client Secret for the Zoom App
* @return Decrypted Zoom App Context or Error
*/
async function decryptAppContext(header: string, key = "") {
// Decode and parse context
const { iv, aad, cipherText, tag } = unpack(header);
// Create sha256 hash from Client Secret (key)
const hash = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(key)
);
// return decrypted context
return await decryptCtxWithHash(cipherText, hash, iv, aad, tag);
}
I can’t guarantee this is the closest working version I got, but it should be shoe-hornable into a client-side JavaScript environment for testing.