@hookflo/tern
Version:
A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms
296 lines (295 loc) • 12.4 kB
JavaScript
"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}`);
}
}