@hookflo/tern 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
6
6
  constructor(secret: string, config: SignatureConfig, platform: WebhookPlatform, toleranceInSeconds?: number);
7
7
  abstract verify(request: Request): Promise<WebhookVerificationResult>;
8
8
  protected parseDelimitedHeader(headerValue: string): Record<string, string>;
9
- protected extractSignature(request: Request): string | null;
9
+ protected extractSignatures(request: Request): string[];
10
10
  protected extractTimestamp(request: Request): number | null;
11
11
  protected extractTimestampFromSignature(request: Request): number | null;
12
12
  protected formatPayload(rawBody: string, request: Request): string;
@@ -31,31 +31,51 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
31
31
  }
32
32
  return values;
33
33
  }
34
- extractSignature(request) {
34
+ extractSignatures(request) {
35
35
  const headerValue = request.headers.get(this.config.headerName);
36
36
  if (!headerValue)
37
- return null;
37
+ return [];
38
38
  switch (this.config.headerFormat) {
39
39
  case "prefixed":
40
- return headerValue.trim();
40
+ return [headerValue.trim()].filter(Boolean);
41
41
  case "comma-separated": {
42
42
  const sigMap = this.parseDelimitedHeader(headerValue);
43
43
  const signatureKey = this.config.customConfig?.signatureKey || "v1";
44
- return sigMap[signatureKey] || sigMap.signature || sigMap.v1 || null;
44
+ return [
45
+ sigMap[signatureKey],
46
+ sigMap.signature,
47
+ sigMap.v1,
48
+ ].filter((value) => Boolean(value));
45
49
  }
46
50
  case "raw":
47
51
  default:
48
52
  if (this.config.customConfig?.signatureFormat?.includes("v1=")) {
49
- const signatures = headerValue.split(" ");
53
+ const signatures = headerValue
54
+ .trim()
55
+ .split(/\s+/)
56
+ .map((entry) => entry.trim())
57
+ .filter(Boolean);
58
+ const normalized = [];
50
59
  for (const sig of signatures) {
51
- const [version, signature] = sig.split(",");
52
- if (version === "v1") {
53
- return signature;
60
+ // Standard Webhooks format is usually "v1,<signature>"
61
+ if (sig.startsWith("v1,")) {
62
+ const [, value] = sig.split(",", 2);
63
+ if (value) {
64
+ normalized.push(value.trim());
65
+ }
66
+ continue;
67
+ }
68
+ // Accept "v1=<signature>" variants used by some providers/docs.
69
+ if (sig.startsWith("v1=")) {
70
+ const [, value] = sig.split("=", 2);
71
+ if (value) {
72
+ normalized.push(value.trim());
73
+ }
54
74
  }
55
75
  }
56
- return null;
76
+ return normalized;
57
77
  }
58
- return headerValue;
78
+ return [headerValue.trim()].filter(Boolean);
59
79
  }
60
80
  }
61
81
  extractTimestamp(request) {
@@ -114,10 +134,18 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
114
134
  const customFormat = this.config.customConfig.payloadFormat;
115
135
  if (customFormat.includes("{id}") && customFormat.includes("{timestamp}")) {
116
136
  const id = request.headers.get(this.config.customConfig.idHeader || "x-webhook-id");
117
- const timestamp = request.headers.get(this.config.timestampHeader || "x-webhook-timestamp");
137
+ const timestamp = request.headers.get(this.config.timestampHeader ||
138
+ this.config.customConfig?.timestampHeader ||
139
+ "x-webhook-timestamp");
140
+ // if either is missing payload will be malformed — fail explicitly
141
+ if (!id || !timestamp) {
142
+ throw new Error(`Missing required headers for payload construction: ${!id ? this.config.customConfig.idHeader || "x-webhook-id" : ""} ${!timestamp
143
+ ? this.config.timestampHeader || "x-webhook-timestamp"
144
+ : ""}`.trim());
145
+ }
118
146
  return customFormat
119
- .replace("{id}", id || "")
120
- .replace("{timestamp}", timestamp || "")
147
+ .replace("{id}", id.trim() || "")
148
+ .replace("{timestamp}", timestamp.trim() || "")
121
149
  .replace("{body}", rawBody);
122
150
  }
123
151
  if (customFormat.includes("{timestamp}") &&
@@ -187,7 +215,9 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
187
215
  case "workos":
188
216
  case "sentry":
189
217
  case "sanity":
190
- metadata.id = request.headers.get(this.config.idHeader || this.config.customConfig?.idHeader || "webhook-id");
218
+ metadata.id = request.headers.get(this.config.idHeader ||
219
+ this.config.customConfig?.idHeader ||
220
+ "webhook-id");
191
221
  break;
192
222
  default:
193
223
  if (this.config.idHeader) {
@@ -201,7 +231,10 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
201
231
  exports.AlgorithmBasedVerifier = AlgorithmBasedVerifier;
202
232
  class GenericHMACVerifier extends AlgorithmBasedVerifier {
203
233
  resolveSentryPayloadCandidates(rawBody, request) {
204
- const candidates = [this.formatPayload(rawBody, request), rawBody];
234
+ const candidates = [
235
+ this.formatPayload(rawBody, request),
236
+ rawBody,
237
+ ];
205
238
  if (this.config.payloadFormat === "json-stringified") {
206
239
  try {
207
240
  const parsed = JSON.parse(rawBody);
@@ -226,8 +259,8 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
226
259
  }
227
260
  async verify(request) {
228
261
  try {
229
- const signature = this.extractSignature(request);
230
- if (!signature) {
262
+ const signatures = this.extractSignatures(request);
263
+ if (signatures.length === 0) {
231
264
  return {
232
265
  isValid: false,
233
266
  error: `Missing signature header: ${this.config.headerName}`,
@@ -257,14 +290,19 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
257
290
  let isValid = false;
258
291
  const algorithm = this.config.algorithm.replace("hmac-", "");
259
292
  for (const payload of payloadCandidates) {
260
- if (this.config.customConfig?.encoding === "base64") {
261
- isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
262
- }
263
- else if (this.config.headerFormat === "prefixed") {
264
- isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
265
- }
266
- else {
267
- isValid = this.verifyHMAC(payload, signature, algorithm);
293
+ for (const signature of signatures) {
294
+ if (this.config.customConfig?.encoding === "base64") {
295
+ isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
296
+ }
297
+ else if (this.config.headerFormat === "prefixed") {
298
+ isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
299
+ }
300
+ else {
301
+ isValid = this.verifyHMAC(payload, signature, algorithm);
302
+ }
303
+ if (isValid) {
304
+ break;
305
+ }
268
306
  }
269
307
  if (isValid) {
270
308
  break;
@@ -399,8 +437,8 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
399
437
  }
400
438
  async verify(request) {
401
439
  try {
402
- const signature = this.extractSignature(request);
403
- if (!signature) {
440
+ const signatures = this.extractSignatures(request);
441
+ if (signatures.length === 0) {
404
442
  return {
405
443
  isValid: false,
406
444
  error: `Missing signature header: ${this.config.headerName}`,
@@ -453,23 +491,28 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
453
491
  // fal.ai signature is hex encoded (not base64)
454
492
  // other ED25519 platforms use base64 by default
455
493
  const signatureEncoding = this.platform === "falai" ? "hex" : "base64";
456
- const signatureBytes = new Uint8Array(Buffer.from(signature, signatureEncoding));
457
494
  const payloadBytes = new Uint8Array(Buffer.from(payload, "utf-8"));
458
495
  // try all keys — succeeds if any key validates
459
496
  // this handles key rotation gracefully
460
497
  let isValid = false;
461
- for (const pem of publicKeys) {
462
- try {
463
- const keyObject = (0, crypto_1.createPublicKey)(pem);
464
- const valid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
465
- if (valid) {
466
- isValid = true;
467
- break;
498
+ for (const signature of signatures) {
499
+ const signatureBytes = new Uint8Array(Buffer.from(signature, signatureEncoding));
500
+ for (const pem of publicKeys) {
501
+ try {
502
+ const keyObject = (0, crypto_1.createPublicKey)(pem);
503
+ const valid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
504
+ if (valid) {
505
+ isValid = true;
506
+ break;
507
+ }
508
+ }
509
+ catch {
510
+ // this key failed — try next
511
+ continue;
468
512
  }
469
513
  }
470
- catch {
471
- // this key failed — try next
472
- continue;
514
+ if (isValid) {
515
+ break;
473
516
  }
474
517
  }
475
518
  if (!isValid) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "A robust, scalable webhook verification framework supporting multiple platforms and signature algorithms",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",