UNPKG

@hookflo/tern

Version:

A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms

296 lines (295 loc) 12.4 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.HMACSHA512Verifier = exports.HMACSHA1Verifier = exports.HMACSHA256Verifier = exports.GenericHMACVerifier = exports.AlgorithmBasedVerifier = void 0; exports.createAlgorithmVerifier = createAlgorithmVerifier; const crypto_1 = require("crypto"); const base_1 = require("./base"); class AlgorithmBasedVerifier extends base_1.WebhookVerifier { constructor(secret, config, platform, toleranceInSeconds = 300) { super(secret, toleranceInSeconds); this.config = config; this.platform = platform; } extractSignature(request) { const headerValue = request.headers.get(this.config.headerName); if (!headerValue) return null; switch (this.config.headerFormat) { case 'prefixed': // For GitHub, return the full signature including prefix for comparison return headerValue; case 'comma-separated': // Handle comma-separated format like Stripe: "t=1234567890,v1=abc123" const parts = headerValue.split(','); const sigMap = {}; for (const part of parts) { const [key, value] = part.split('='); if (key && value) { sigMap[key] = value; } } return sigMap.v1 || sigMap.signature || null; case 'raw': default: if (this.platform === 'clerk' || this.platform === 'dodopayments') { const signatures = headerValue.split(' '); for (const sig of signatures) { const [version, signature] = sig.split(','); if (version === 'v1') { return signature; } } return null; } return headerValue; } } extractTimestamp(request) { if (!this.config.timestampHeader) return null; const timestampHeader = request.headers.get(this.config.timestampHeader); if (!timestampHeader) return null; switch (this.config.timestampFormat) { case 'unix': return parseInt(timestampHeader, 10); case 'iso': return Math.floor(new Date(timestampHeader).getTime() / 1000); case 'custom': // Custom timestamp parsing logic can be added here return parseInt(timestampHeader, 10); default: return parseInt(timestampHeader, 10); } } extractTimestampFromSignature(request) { // For platforms like Stripe where timestamp is embedded in signature if (this.config.headerFormat === 'comma-separated') { const headerValue = request.headers.get(this.config.headerName); if (!headerValue) return null; const parts = headerValue.split(','); const sigMap = {}; for (const part of parts) { const [key, value] = part.split('='); if (key && value) { sigMap[key] = value; } } return sigMap.t ? parseInt(sigMap.t, 10) : null; } return null; } formatPayload(rawBody, request) { switch (this.config.payloadFormat) { case 'timestamped': // For Stripe, timestamp is embedded in signature const timestamp = this.extractTimestampFromSignature(request) || this.extractTimestamp(request); return timestamp ? `${timestamp}.${rawBody}` : rawBody; case 'custom': return this.formatCustomPayload(rawBody, request); case 'raw': default: return rawBody; } } formatCustomPayload(rawBody, request) { if (!this.config.customConfig?.payloadFormat) { return rawBody; } const customFormat = this.config.customConfig.payloadFormat; // Handle Clerk-style format: {id}.{timestamp}.{body} if (customFormat.includes('{id}') && customFormat.includes('{timestamp}')) { const id = request.headers.get(this.config.customConfig.idHeader || 'x-webhook-id'); const timestamp = request.headers.get(this.config.timestampHeader || 'x-webhook-timestamp'); return customFormat .replace('{id}', id || '') .replace('{timestamp}', timestamp || '') .replace('{body}', rawBody); } // Handle Stripe-style format: {timestamp}.{body} if (customFormat.includes('{timestamp}') && customFormat.includes('{body}')) { const timestamp = this.extractTimestamp(request); return customFormat .replace('{timestamp}', timestamp?.toString() || '') .replace('{body}', rawBody); } return rawBody; } verifyHMAC(payload, signature, algorithm = 'sha256') { const hmac = (0, crypto_1.createHmac)(algorithm, this.secret); hmac.update(payload); const expectedSignature = hmac.digest('hex'); return this.safeCompare(signature, expectedSignature); } verifyHMACWithPrefix(payload, signature, algorithm = 'sha256') { const hmac = (0, crypto_1.createHmac)(algorithm, this.secret); hmac.update(payload); const expectedSignature = `${this.config.prefix || ''}${hmac.digest('hex')}`; return this.safeCompare(signature, expectedSignature); } verifyHMACWithBase64(payload, signature, algorithm = 'sha256') { // For platforms like Clerk that use base64 encoding const secretBytes = new Uint8Array(Buffer.from(this.secret.split('_')[1], 'base64')); const hmac = (0, crypto_1.createHmac)(algorithm, secretBytes); hmac.update(payload); const expectedSignature = hmac.digest('base64'); return this.safeCompare(signature, expectedSignature); } extractMetadata(request) { const metadata = { algorithm: this.config.algorithm, }; // Add timestamp if available const timestamp = this.extractTimestamp(request); if (timestamp) { metadata.timestamp = timestamp.toString(); } // Add platform-specific metadata switch (this.platform) { case 'github': metadata.event = request.headers.get('x-github-event'); metadata.delivery = request.headers.get('x-github-delivery'); break; case 'stripe': // Extract Stripe-specific metadata from signature const headerValue = request.headers.get(this.config.headerName); if (headerValue && this.config.headerFormat === 'comma-separated') { const parts = headerValue.split(','); const sigMap = {}; for (const part of parts) { const [key, value] = part.split('='); if (key && value) { sigMap[key] = value; } } metadata.id = sigMap.id; } break; case 'clerk': metadata.id = request.headers.get('svix-id'); break; } return metadata; } } exports.AlgorithmBasedVerifier = AlgorithmBasedVerifier; // Generic HMAC Verifier that handles all HMAC-based algorithms class GenericHMACVerifier extends AlgorithmBasedVerifier { async verify(request) { try { const signature = this.extractSignature(request); if (!signature) { return { isValid: false, error: `Missing signature header: ${this.config.headerName}`, platform: this.platform, }; } const rawBody = await request.text(); // Extract timestamp based on platform configuration let timestamp = null; if (this.config.headerFormat === 'comma-separated') { // For platforms like Stripe where timestamp is embedded in signature timestamp = this.extractTimestampFromSignature(request); } else { // For platforms with separate timestamp header timestamp = this.extractTimestamp(request); } // Validate timestamp if required if (timestamp && !this.isTimestampValid(timestamp)) { return { isValid: false, error: 'Webhook timestamp expired', platform: this.platform, }; } // Format payload according to platform requirements const payload = this.formatPayload(rawBody, request); // Verify signature based on platform configuration let isValid = false; const algorithm = this.config.algorithm.replace('hmac-', ''); if (this.config.customConfig?.encoding === 'base64') { // For platforms like Clerk that use base64 encoding isValid = this.verifyHMACWithBase64(payload, signature, algorithm); } else if (this.config.headerFormat === 'prefixed') { // For platforms like GitHub that use prefixed signatures isValid = this.verifyHMACWithPrefix(payload, signature, algorithm); } else { // Standard HMAC verification isValid = this.verifyHMAC(payload, signature, algorithm); } if (!isValid) { return { isValid: false, error: 'Invalid signature', platform: this.platform, }; } // Parse payload let parsedPayload; try { parsedPayload = JSON.parse(rawBody); } catch (e) { parsedPayload = rawBody; } // Extract platform-specific metadata const metadata = this.extractMetadata(request); return { isValid: true, platform: this.platform, payload: parsedPayload, metadata, }; } catch (error) { return { isValid: false, error: `${this.platform} verification error: ${error.message}`, platform: this.platform, }; } } } exports.GenericHMACVerifier = GenericHMACVerifier; // Legacy verifiers for backward compatibility class HMACSHA256Verifier extends GenericHMACVerifier { constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) { super(secret, config, platform, toleranceInSeconds); } } exports.HMACSHA256Verifier = HMACSHA256Verifier; class HMACSHA1Verifier extends GenericHMACVerifier { constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) { super(secret, config, platform, toleranceInSeconds); } } exports.HMACSHA1Verifier = HMACSHA1Verifier; class HMACSHA512Verifier extends GenericHMACVerifier { constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) { super(secret, config, platform, toleranceInSeconds); } } exports.HMACSHA512Verifier = HMACSHA512Verifier; // Factory function to create verifiers based on algorithm function createAlgorithmVerifier(secret, config, platform = 'unknown', toleranceInSeconds = 300) { switch (config.algorithm) { case 'hmac-sha256': case 'hmac-sha1': case 'hmac-sha512': return new GenericHMACVerifier(secret, config, platform, toleranceInSeconds); case 'rsa-sha256': case 'ed25519': case 'custom': // These can be implemented as needed throw new Error(`Algorithm ${config.algorithm} not yet implemented`); default: throw new Error(`Unknown algorithm: ${config.algorithm}`); } }