UNPKG

@notionhq/client

Version:

A simple and easy to use client for the Notion API

124 lines 5.43 kB
"use strict"; /** * Helpers for receiving and verifying Notion webhook deliveries. * * Notion signs each webhook request with HMAC-SHA256 over the raw HTTP * body using the subscription's verification token as the key, and sends * the resulting hex digest in the `X-Notion-Signature` header as * `sha256=<hex>`. To verify a delivery, callers must pass the body * **exactly as it arrived over the wire** — any JSON re-serialization * will change the bytes and invalidate the signature. * * The helpers prefer the Web Crypto API (`globalThis.crypto.subtle`) * when present (browsers, edge runtimes, Node.js >= 18.19) and * transparently fall back to `node:crypto`'s `webcrypto.subtle` on older * Node.js 18 builds where `globalThis.crypto` is not yet enabled by * default. They work without configuration in Node.js, Bun, Deno, * Vercel Edge Functions, Cloudflare Workers, and modern browsers. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.verifyWebhookSignature = verifyWebhookSignature; exports.signWebhookPayload = signWebhookPayload; const SIGNATURE_PREFIX = "sha256="; const SHA256_HEX_LENGTH = 64; /** * Verify that a webhook delivery came from Notion and has not been * tampered with. * * Performs a constant-time comparison; returns `true` only if the * supplied `signature` matches HMAC-SHA256 of `body` keyed by * `verificationToken`. Returns `false` (rather than throwing) for any * malformed input so the caller can respond with a single 401/403 path. */ async function verifyWebhookSignature(args) { const { body, signature, verificationToken } = args; if (typeof signature !== "string") return false; if (!signature.startsWith(SIGNATURE_PREFIX)) return false; const providedHex = signature.slice(SIGNATURE_PREFIX.length).toLowerCase(); if (providedHex.length !== SHA256_HEX_LENGTH) return false; if (!/^[0-9a-f]+$/.test(providedHex)) return false; const computedHex = await computeHmacSha256Hex(verificationToken, body); return timingSafeEqualHex(providedHex, computedHex); } /** * Compute the value Notion would send in `X-Notion-Signature` for a * given body and verification token. Useful for unit-testing webhook * handlers without standing up a real subscription. */ async function signWebhookPayload(args) { const hex = await computeHmacSha256Hex(args.verificationToken, args.body); return `${SIGNATURE_PREFIX}${hex}`; } async function computeHmacSha256Hex(key, body) { const subtle = await getSubtle(); const encoder = new TextEncoder(); const keyBytes = encoder.encode(key); // Copy any Uint8Array input into a fresh ArrayBuffer-backed view so // subtle.sign (which forbids SharedArrayBuffer-backed views) is // satisfied regardless of where the caller's buffer came from. const bodyBytes = typeof body === "string" ? encoder.encode(body) : Uint8Array.from(body); const cryptoKey = await subtle.importKey("raw", keyBytes, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const signatureBuffer = await subtle.sign("HMAC", cryptoKey, bodyBytes); return bytesToHex(new Uint8Array(signatureBuffer)); } let cachedSubtle; async function getSubtle() { var _a, _b; if (cachedSubtle) return cachedSubtle; // Preferred path: every modern runtime exposes Web Crypto on the // global object. This includes browsers, Vercel Edge, Cloudflare // Workers, Deno, Bun, and Node.js >= 18.19 (which backported the // change from Node.js 19). const fromGlobal = (_a = globalThis .crypto) === null || _a === void 0 ? void 0 : _a.subtle; if (fromGlobal) { cachedSubtle = fromGlobal; return cachedSubtle; } // Fallback for Node.js 18.0–18.18, which ships Web Crypto via // `node:crypto` but does not expose it on `globalThis` by default. // Using `require` (rather than a static `import`) keeps the // node:crypto reference off the dependency graph of browser bundlers // that would otherwise fail to resolve it. try { /* eslint-disable @typescript-eslint/no-var-requires */ const nodeCrypto = require("crypto"); /* eslint-enable @typescript-eslint/no-var-requires */ if ((_b = nodeCrypto.webcrypto) === null || _b === void 0 ? void 0 : _b.subtle) { cachedSubtle = nodeCrypto.webcrypto.subtle; return cachedSubtle; } } catch { // node:crypto unavailable (e.g. a browser bundle that stubbed it). // Fall through to the error below. } throw new Error("verifyWebhookSignature requires Web Crypto support " + "(globalThis.crypto.subtle or node:crypto.webcrypto). Upgrade to " + "a runtime that provides one of them."); } function bytesToHex(bytes) { let hex = ""; for (let i = 0; i < bytes.length; i++) { hex += bytes[i].toString(16).padStart(2, "0"); } return hex; } // Constant-time string comparison. The inputs are already known to be // the same length (callers enforce SHA256_HEX_LENGTH), so this purely // avoids early-exit on the first differing character. function timingSafeEqualHex(a, b) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } //# sourceMappingURL=webhooks.js.map