@sanity/webhook
Version:
Toolkit for dealing with GROQ-powered webhooks delivered by Sanity.io
1 lines • 14.3 kB
Source Map (JSON)
{"version":3,"file":"index.mjs","sources":["../src/errors.ts","../src/signature.ts","../src/middleware.ts"],"sourcesContent":["/**\n * Error types used on signature errors.\n * Includes `type` and `statusCode` properties.\n *\n * @public\n */\nexport type WebhookSignatureError = WebhookSignatureValueError | WebhookSignatureFormatError\n\n/**\n * Error thrown when the signature value does not match the expected value.\n *\n * @public\n */\nexport class WebhookSignatureValueError extends Error {\n public type = 'WebhookSignatureValueError'\n public statusCode = 401\n}\n\n/**\n * Error thrown when the signature format is invalid.\n * This can happen when the signature is not a string or is not in the format of `t=<timestamp>,v=<signature>`.\n * This error is also thrown when the timestamp is not a number or is not within the tolerance time.\n *\n * @public\n */\nexport class WebhookSignatureFormatError extends Error {\n public type = 'WebhookSignatureFormatError'\n public statusCode = 400\n}\n\n/**\n * Checks whether or not the given error is a signature error.\n *\n * @param error - The error to check.\n * @returns `true` if the error is a signature error, otherwise `false`.\n * @public\n */\nexport function isSignatureError(error: unknown): error is WebhookSignatureError {\n return (\n typeof error === 'object' &&\n error !== null &&\n 'type' in error &&\n ['WebhookSignatureValueError', 'WebhookSignatureFormatError'].includes(\n (error as WebhookSignatureError).type,\n )\n )\n}\n","import {isSignatureError, WebhookSignatureFormatError, WebhookSignatureValueError} from './errors'\nimport type {ConnectLikeRequest, DecodedSignature} from './types'\n\n/**\n * We didn't send signed payloads prior to 2021 (2021-01-01T00:00:00.000Z)\n */\nconst MINIMUM_TIMESTAMP = 1609459200000\n\nconst SIGNATURE_HEADER_REGEX = /^t=(\\d+)[, ]+v1=([^, ]+)$/\n\n/**\n * The name of the header that contains the signature.\n *\n * @public\n */\nexport const SIGNATURE_HEADER_NAME = 'sanity-webhook-signature'\n\n/**\n * Asserts that the given signature is valid.\n * Throws an error if the signature is invalid.\n *\n * @param stringifiedPayload - The stringified payload to verify - should be straight from the request, not a re-encoded JSON string, as this in certain cases will yield mismatches due to inconsistent encoding.\n * @param signature - The signature to verify against\n * @param secret - The secret to use for verifying the signature\n * @public\n */\nexport async function assertValidSignature(\n stringifiedPayload: string,\n signature: string,\n secret: string,\n): Promise<void> {\n const {timestamp} = decodeSignatureHeader(signature)\n const encoded = await encodeSignatureHeader(stringifiedPayload, timestamp, secret)\n if (signature !== encoded) {\n throw new WebhookSignatureValueError('Signature is invalid')\n }\n}\n\n/**\n * Checks if the given signature is valid.\n *\n * @param stringifiedPayload - The stringified payload to verify - should be straight from the request, not a re-encoded JSON string, as this in certain cases will yield mismatches due to inconsistent encoding.\n * @param signature - The signature to verify against\n * @param secret - The secret to use for verifying the signature\n * @returns A promise that resolves to `true` if the signature is valid, `false` otherwise.\n * @public\n */\nexport async function isValidSignature(\n stringifiedPayload: string,\n signature: string,\n secret: string,\n): Promise<boolean> {\n try {\n await assertValidSignature(stringifiedPayload, signature, secret)\n return true\n } catch (err) {\n if (isSignatureError(err)) {\n return false\n }\n throw err\n }\n}\n\n/**\n * Asserts that the given request is valid.\n * Throws an error if the request is invalid.\n *\n * @param request - The Connect/Express-like request to verify\n * @param secret - The secret to use for verifying the signature\n * @public\n */\nexport async function assertValidRequest(\n request: ConnectLikeRequest,\n secret: string,\n): Promise<void> {\n const signature = request.headers[SIGNATURE_HEADER_NAME]\n if (Array.isArray(signature)) {\n throw new WebhookSignatureFormatError('Multiple signature headers received')\n }\n\n if (typeof signature !== 'string') {\n throw new WebhookSignatureValueError('Request contained no signature header')\n }\n\n if (typeof request.body === 'undefined') {\n throw new WebhookSignatureFormatError('Request contained no parsed request body')\n }\n\n if (typeof request.body === 'string' || Buffer.isBuffer(request.body)) {\n await assertValidSignature(request.body.toString('utf8'), signature, secret)\n } else {\n throw new Error(\n '[@sanity/webhook] `request.body` was not a string/buffer - this can lead to invalid signatures. See the [migration docs](https://github.com/sanity-io/webhook-toolkit#from-parsed-to-unparsed-body) for details on how to fix this.',\n )\n }\n}\n\n/**\n * Checks if the given request is valid.\n *\n * @param request - The Connect/Express-like request to verify\n * @param secret - The secret to use for verifying the signature\n * @returns Promise that resolves to `true` if the request is valid, `false` otherwise.\n * @public\n */\nexport async function isValidRequest(\n request: ConnectLikeRequest,\n secret: string,\n): Promise<boolean> {\n try {\n await assertValidRequest(request, secret)\n return true\n } catch (err) {\n if (isSignatureError(err)) {\n return false\n }\n throw err\n }\n}\n\n/**\n * Encodes a signature header for the given payload and timestamp.\n *\n * @param stringifiedPayload - The stringified payload to verify - should be straight from the request, not a re-encoded JSON string, as this in certain cases will yield mismatches due to inconsistent encoding.\n * @param timestamp - The timestamp to use for the signature\n * @param secret - The secret to use for verifying the signature\n * @returns A promise that resolves to the encoded signature header\n * @public\n */\nexport async function encodeSignatureHeader(\n stringifiedPayload: string,\n timestamp: number,\n secret: string,\n): Promise<string> {\n const signature = await createHS256Signature(stringifiedPayload, timestamp, secret)\n return `t=${timestamp},v1=${signature}`\n}\n\n/**\n * Decode a signature header into a timestamp and hashed payload.\n *\n * @param signaturePayload - The signature header to decode\n * @returns An object with the decoded timestamp and hashed payload\n * @public\n */\nexport function decodeSignatureHeader(signaturePayload: string): DecodedSignature {\n if (!signaturePayload) {\n throw new WebhookSignatureFormatError('Missing or empty signature header')\n }\n\n const [, timestamp, hashedPayload] = signaturePayload.trim().match(SIGNATURE_HEADER_REGEX) || []\n if (!timestamp || !hashedPayload) {\n throw new WebhookSignatureFormatError('Invalid signature payload format')\n }\n\n return {\n timestamp: parseInt(timestamp, 10),\n hashedPayload,\n }\n}\n\n/**\n * Creates a HS256 signature for the given payload and timestamp.\n *\n * @param stringifiedPayload - The stringified payload to verify - should be straight from the request, not a re-encoded JSON string, as this in certain cases will yield mismatches due to inconsistent encoding.\n * @param timestamp - The timestamp to use for the signature\n * @param secret - The secret to use for verifying the signature\n * @returns A promise that resolves to the encoded signature\n * @internal\n */\nasync function createHS256Signature(\n stringifiedPayload: string,\n timestamp: number,\n secret: string,\n): Promise<string> {\n if (typeof crypto === 'undefined') {\n throw new TypeError(\n 'The Web Crypto API is not available in this environment, either polyfill `globalThis.crypto` or downgrade to `@sanity/webhook@3` which uses the Node.js `crypto` module.',\n )\n }\n if (!secret || typeof secret !== 'string') {\n throw new WebhookSignatureFormatError('Invalid secret provided')\n }\n\n if (!stringifiedPayload) {\n throw new WebhookSignatureFormatError('Can not create signature for empty payload')\n }\n\n if (typeof stringifiedPayload !== 'string') {\n throw new WebhookSignatureFormatError('Payload must be a JSON-encoded string')\n }\n\n if (typeof timestamp !== 'number' || isNaN(timestamp) || timestamp < MINIMUM_TIMESTAMP) {\n throw new WebhookSignatureFormatError(\n 'Invalid signature timestamp, must be a unix timestamp with millisecond precision',\n )\n }\n\n const enc = new TextEncoder()\n const key = await crypto.subtle.importKey(\n 'raw',\n enc.encode(secret),\n {name: 'HMAC', hash: 'SHA-256'},\n false,\n ['sign'],\n )\n const signaturePayload = `${timestamp}.${stringifiedPayload}`\n const signature = await crypto.subtle.sign('HMAC', key, enc.encode(signaturePayload))\n\n // Encode as base64url\n const signatureArray = Array.from(new Uint8Array(signature))\n return btoa(String.fromCharCode.apply(null, signatureArray))\n .replace(/\\+/g, '-') // Replace '+' with '-'\n .replace(/\\//g, '_') // Replace '/' with '_'\n .replace(/=+$/, '') // Remove padding\n}\n","import type {RequestHandler} from 'express'\n\nimport {isSignatureError} from './errors'\nimport {assertValidRequest} from './signature'\n\n/**\n * Options for the `requireSignedRequest` middleware\n *\n * @public\n */\nexport interface SignatureMiddlewareOptions {\n /**\n * The secret to use for verifying the signature\n */\n secret: string\n\n /**\n * Whether or not to parse the request body as JSON on success (assigns it to `request.body`).\n * Default: `true`\n */\n parseBody?: boolean\n\n /**\n * Whether or not to respond with an error when the signature is invalid.\n * If `false`, it will call the `next` function with the error instead.\n * Default: `true`\n */\n respondOnError?: boolean\n}\n\n/**\n * Express/Connect style middleware that verifies the signature of a request.\n * Should be added _after_ a body parser that parses the request body to _text_, not parsed JSON.\n *\n * @example\n * ```ts\n * import express from 'express'\n * import bodyParser from 'body-parser'\n * import {requireSignedRequest} from '@sanity/webhook'\n *\n * express()\n * .use(bodyParser.text({type: 'application/json'}))\n * .post(\n * '/hook',\n * requireSignedRequest({secret: process.env.MY_WEBHOOK_SECRET, parseBody: true}),\n * function myRequestHandler(req, res) {\n * // Note that `req.body` is now a parsed version, set `parseBody` to `false`\n * // if you want the raw text version of the request body\n * },\n * )\n * .listen(1337)\n * ```\n *\n * @param options - Options for the middleware\n * @returns A middleware function\n * @public\n */\nexport function requireSignedRequest(options: SignatureMiddlewareOptions): RequestHandler {\n const parseBody = typeof options.parseBody === 'undefined' ? true : options.parseBody\n const respondOnError =\n typeof options.respondOnError === 'undefined' ? true : options.respondOnError\n\n return async function ensureSignedRequest(request, response, next) {\n try {\n await assertValidRequest(request, options.secret)\n if (parseBody && typeof request.body === 'string') {\n request.body = JSON.parse(request.body)\n }\n next()\n } catch (err) {\n if (!respondOnError || !isSignatureError(err)) {\n next(err)\n return\n }\n\n response.status(err.statusCode).json({message: err.message})\n }\n }\n}\n"],"names":[],"mappings":"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"}