@hookflo/tern 2.2.5 → 2.2.9

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.
package/README.md CHANGED
@@ -193,7 +193,7 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
193
193
 
194
194
  ## Supported Platforms
195
195
 
196
- ### Stripe
196
+ ### Stripe OK Tested
197
197
  - **Signature Format**: `t={timestamp},v1={signature}`
198
198
  - **Algorithm**: HMAC-SHA256
199
199
  - **Payload Format**: `{timestamp}.{body}`
@@ -209,20 +209,20 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
209
209
  - **Payload Format**: `{id}.{timestamp}.{body}`
210
210
 
211
211
  ### Other Platforms
212
- - **Dodo Payments**: HMAC-SHA256
213
- - **Paddle**: HMAC-SHA256
214
- - **Razorpay**: HMAC-SHA256
215
- - **Lemon Squeezy**: HMAC-SHA256
216
- - **Auth0**: HMAC-SHA256
217
- - **WorkOS**: HMAC-SHA256 (`workos-signature`, `t/v1`)
218
- - **WooCommerce**: HMAC-SHA256 (base64 signature)
219
- - **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style)
212
+ - **Dodo Payments**: HMAC-SHA256 OK Tested
213
+ - **Paddle**: HMAC-SHA256 OK Tested
214
+ - **Razorpay**: HMAC-SHA256 Pending
215
+ - **Lemon Squeezy**: HMAC-SHA256 OK Tested
216
+ - **Auth0**: HMAC-SHA256 Pending
217
+ - **WorkOS**: HMAC-SHA256 (`workos-signature`, `t/v1`) OK Tested
218
+ - **WooCommerce**: HMAC-SHA256 (base64 signature) Pending
219
+ - **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style) OK Tested
220
220
  - **fal.ai**: ED25519 (`x-fal-webhook-signature`)
221
- - **Shopify**: HMAC-SHA256 (base64 signature)
222
- - **Vercel**: HMAC-SHA256
223
- - **Polar**: HMAC-SHA256
224
- - **Supabase**: Token-based authentication
225
- - **GitLab**: Token-based authentication
221
+ - **Shopify**: HMAC-SHA256 (base64 signature) OK Tested
222
+ - **Vercel**: HMAC-SHA256 Pending
223
+ - **Polar**: HMAC-SHA256 OK Tested
224
+ - **Supabase**: Token-based authentication Pending
225
+ - **GitLab**: Token-based authentication OK Tested
226
226
 
227
227
  ## Custom Platform Configuration
228
228
 
@@ -119,12 +119,12 @@ exports.platformAlgorithmConfigs = {
119
119
  platform: "supabase",
120
120
  signatureConfig: {
121
121
  algorithm: "custom",
122
- headerName: "x-webhook-token",
122
+ headerName: "x-supabase-token",
123
123
  headerFormat: "raw",
124
124
  payloadFormat: "raw",
125
125
  customConfig: {
126
126
  type: "token-based",
127
- idHeader: "x-webhook-id",
127
+ idHeader: "x-supabase-token",
128
128
  },
129
129
  },
130
130
  description: "Supabase webhooks use token-based authentication",
@@ -244,14 +244,13 @@ exports.platformAlgorithmConfigs = {
244
244
  headerFormat: "raw",
245
245
  payloadFormat: "custom",
246
246
  customConfig: {
247
- requestIdHeader: "x-fal-request-id",
248
- userIdHeader: "x-fal-user-id",
247
+ requestIdHeader: "x-fal-webhook-request-id",
248
+ userIdHeader: "x-fal-webhook-user-id",
249
249
  timestampHeader: "x-fal-webhook-timestamp",
250
- kidHeader: "x-fal-webhook-key-id",
251
250
  jwksUrl: "https://rest.alpha.fal.ai/.well-known/jwks.json",
252
251
  },
253
252
  },
254
- description: "fal.ai webhooks use ED25519 with a signed request/user/timestamp/body-hash payload",
253
+ description: "fal.ai webhooks use ED25519 with JWKS key verification. No secret required — pass empty string.",
255
254
  },
256
255
  custom: {
257
256
  platform: "custom",
@@ -1,5 +1,5 @@
1
- import { WebhookVerifier } from './base';
2
- import { WebhookVerificationResult, SignatureConfig, WebhookPlatform } from '../types';
1
+ import { WebhookVerifier } from "./base";
2
+ import { WebhookVerificationResult, SignatureConfig, WebhookPlatform } from "../types";
3
3
  export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
4
4
  protected config: SignatureConfig;
5
5
  protected platform: WebhookPlatform;
@@ -20,7 +20,27 @@ export declare class GenericHMACVerifier extends AlgorithmBasedVerifier {
20
20
  verify(request: Request): Promise<WebhookVerificationResult>;
21
21
  }
22
22
  export declare class Ed25519Verifier extends AlgorithmBasedVerifier {
23
- private resolvePublicKey;
23
+ /**
24
+ * Fetch all public keys from JWKS endpoint.
25
+ * Returns array of PEM strings — tries all keys during verification
26
+ * so key rotation works transparently.
27
+ * Caches for 24 hours per fal.ai docs recommendation.
28
+ */
29
+ private fetchJWKSKeys;
30
+ /**
31
+ * Resolve public keys to use for verification.
32
+ * Priority:
33
+ * 1. Explicit publicKey in config
34
+ * 2. Non-empty secret passed directly (user-provided PEM)
35
+ * 3. JWKS URL in config (fal.ai — fetches all keys, tries each)
36
+ */
37
+ private resolvePublicKeys;
38
+ /**
39
+ * Build fal.ai specific payload string.
40
+ *
41
+ * Per fal.ai docs, message is newline-separated (NOT dot-separated):
42
+ * {request-id}\n{user-id}\n{timestamp}\n{sha256hex(body)}
43
+ */
24
44
  private buildFalPayload;
25
45
  verify(request: Request): Promise<WebhookVerificationResult>;
26
46
  }
@@ -4,7 +4,9 @@ exports.HMACSHA512Verifier = exports.HMACSHA1Verifier = exports.HMACSHA256Verifi
4
4
  exports.createAlgorithmVerifier = createAlgorithmVerifier;
5
5
  const crypto_1 = require("crypto");
6
6
  const base_1 = require("./base");
7
+ // Cache stores array of PEM keys per JWKS URL
7
8
  const ed25519KeyCache = new Map();
9
+ const JWKS_CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours per fal.ai docs
8
10
  class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
9
11
  constructor(secret, config, platform, toleranceInSeconds = 300) {
10
12
  super(secret, toleranceInSeconds);
@@ -18,7 +20,7 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
18
20
  const trimmed = part.trim();
19
21
  if (!trimmed)
20
22
  continue;
21
- const equalIndex = trimmed.indexOf('=');
23
+ const equalIndex = trimmed.indexOf("=");
22
24
  if (equalIndex === -1)
23
25
  continue;
24
26
  const key = trimmed.slice(0, equalIndex).trim();
@@ -34,20 +36,20 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
34
36
  if (!headerValue)
35
37
  return null;
36
38
  switch (this.config.headerFormat) {
37
- case 'prefixed':
38
- return headerValue;
39
- case 'comma-separated': {
39
+ case "prefixed":
40
+ return headerValue.trim();
41
+ case "comma-separated": {
40
42
  const sigMap = this.parseDelimitedHeader(headerValue);
41
- const signatureKey = this.config.customConfig?.signatureKey || 'v1';
43
+ const signatureKey = this.config.customConfig?.signatureKey || "v1";
42
44
  return sigMap[signatureKey] || sigMap.signature || sigMap.v1 || null;
43
45
  }
44
- case 'raw':
46
+ case "raw":
45
47
  default:
46
- if (this.config.customConfig?.signatureFormat?.includes('v1=')) {
47
- const signatures = headerValue.split(' ');
48
+ if (this.config.customConfig?.signatureFormat?.includes("v1=")) {
49
+ const signatures = headerValue.split(" ");
48
50
  for (const sig of signatures) {
49
- const [version, signature] = sig.split(',');
50
- if (version === 'v1') {
51
+ const [version, signature] = sig.split(",");
52
+ if (version === "v1") {
51
53
  return signature;
52
54
  }
53
55
  }
@@ -63,37 +65,37 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
63
65
  if (!timestampHeader)
64
66
  return null;
65
67
  switch (this.config.timestampFormat) {
66
- case 'unix':
68
+ case "unix":
67
69
  return parseInt(timestampHeader, 10);
68
- case 'iso':
70
+ case "iso":
69
71
  return Math.floor(new Date(timestampHeader).getTime() / 1000);
70
- case 'custom':
72
+ case "custom":
71
73
  return parseInt(timestampHeader, 10);
72
74
  default:
73
75
  return parseInt(timestampHeader, 10);
74
76
  }
75
77
  }
76
78
  extractTimestampFromSignature(request) {
77
- if (this.config.headerFormat !== 'comma-separated') {
79
+ if (this.config.headerFormat !== "comma-separated") {
78
80
  return null;
79
81
  }
80
82
  const headerValue = request.headers.get(this.config.headerName);
81
83
  if (!headerValue)
82
84
  return null;
83
85
  const sigMap = this.parseDelimitedHeader(headerValue);
84
- const timestampKey = this.config.customConfig?.timestampKey || 't';
86
+ const timestampKey = this.config.customConfig?.timestampKey || "t";
85
87
  return sigMap[timestampKey] ? parseInt(sigMap[timestampKey], 10) : null;
86
88
  }
87
89
  formatPayload(rawBody, request) {
88
90
  switch (this.config.payloadFormat) {
89
- case 'timestamped': {
90
- const timestamp = this.extractTimestampFromSignature(request)
91
- || this.extractTimestamp(request);
91
+ case "timestamped": {
92
+ const timestamp = this.extractTimestampFromSignature(request) ||
93
+ this.extractTimestamp(request);
92
94
  return timestamp ? `${timestamp}.${rawBody}` : rawBody;
93
95
  }
94
- case 'custom':
96
+ case "custom":
95
97
  return this.formatCustomPayload(rawBody, request);
96
- case 'raw':
98
+ case "raw":
97
99
  default:
98
100
  return rawBody;
99
101
  }
@@ -103,78 +105,80 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
103
105
  return rawBody;
104
106
  }
105
107
  const customFormat = this.config.customConfig.payloadFormat;
106
- if (customFormat.includes('{id}') && customFormat.includes('{timestamp}')) {
107
- const id = request.headers.get(this.config.customConfig.idHeader || 'x-webhook-id');
108
- const timestamp = request.headers.get(this.config.timestampHeader || 'x-webhook-timestamp');
108
+ if (customFormat.includes("{id}") && customFormat.includes("{timestamp}")) {
109
+ const id = request.headers.get(this.config.customConfig.idHeader || "x-webhook-id");
110
+ const timestamp = request.headers.get(this.config.timestampHeader || "x-webhook-timestamp");
109
111
  return customFormat
110
- .replace('{id}', id || '')
111
- .replace('{timestamp}', timestamp || '')
112
- .replace('{body}', rawBody);
112
+ .replace("{id}", id || "")
113
+ .replace("{timestamp}", timestamp || "")
114
+ .replace("{body}", rawBody);
113
115
  }
114
- if (customFormat.includes('{timestamp}')
115
- && customFormat.includes('{body}')) {
116
- const timestamp = this.extractTimestampFromSignature(request)
117
- || this.extractTimestamp(request);
116
+ if (customFormat.includes("{timestamp}") &&
117
+ customFormat.includes("{body}")) {
118
+ const timestamp = this.extractTimestampFromSignature(request) ||
119
+ this.extractTimestamp(request);
118
120
  return customFormat
119
- .replace('{timestamp}', timestamp?.toString() || '')
120
- .replace('{body}', rawBody);
121
+ .replace("{timestamp}", timestamp?.toString() || "")
122
+ .replace("{body}", rawBody);
121
123
  }
122
124
  return rawBody;
123
125
  }
124
- verifyHMAC(payload, signature, algorithm = 'sha256') {
126
+ verifyHMAC(payload, signature, algorithm = "sha256") {
125
127
  const hmac = (0, crypto_1.createHmac)(algorithm, this.secret);
126
128
  hmac.update(payload);
127
- const expectedSignature = hmac.digest('hex');
129
+ const expectedSignature = hmac.digest("hex");
128
130
  return this.safeCompare(signature, expectedSignature);
129
131
  }
130
- verifyHMACWithPrefix(payload, signature, algorithm = 'sha256') {
132
+ verifyHMACWithPrefix(payload, signature, algorithm = "sha256") {
131
133
  const hmac = (0, crypto_1.createHmac)(algorithm, this.secret);
132
134
  hmac.update(payload);
133
- const expectedSignature = `${this.config.prefix || ''}${hmac.digest('hex')}`;
135
+ const expectedSignature = `${this.config.prefix || ""}${hmac.digest("hex")}`;
134
136
  return this.safeCompare(signature, expectedSignature);
135
137
  }
136
- verifyHMACWithBase64(payload, signature, algorithm = 'sha256') {
137
- const secretEncoding = this.config.customConfig?.secretEncoding || 'base64';
138
+ verifyHMACWithBase64(payload, signature, algorithm = "sha256") {
139
+ const secretEncoding = this.config.customConfig?.secretEncoding || "base64";
138
140
  let secretMaterial = this.secret;
139
- if (secretEncoding === 'base64') {
140
- const base64Secret = this.secret.includes('_')
141
- ? this.secret.split('_').slice(1).join('_')
141
+ if (secretEncoding === "base64") {
142
+ const base64Secret = this.secret.includes("_")
143
+ ? this.secret.split("_").slice(1).join("_")
142
144
  : this.secret;
143
- secretMaterial = new Uint8Array(Buffer.from(base64Secret, 'base64'));
145
+ secretMaterial = new Uint8Array(Buffer.from(base64Secret, "base64"));
144
146
  }
147
+ // 'utf8', 'raw', or anything else → use secret as-is
145
148
  const hmac = (0, crypto_1.createHmac)(algorithm, secretMaterial);
146
149
  hmac.update(payload);
147
- const expectedSignature = hmac.digest('base64');
150
+ const expectedSignature = hmac.digest("base64");
148
151
  return this.safeCompare(signature, expectedSignature);
149
152
  }
150
153
  extractMetadata(request) {
151
154
  const metadata = {
152
155
  algorithm: this.config.algorithm,
153
156
  };
154
- const timestamp = this.extractTimestamp(request) || this.extractTimestampFromSignature(request);
157
+ const timestamp = this.extractTimestamp(request) ||
158
+ this.extractTimestampFromSignature(request);
155
159
  if (timestamp) {
156
160
  metadata.timestamp = timestamp.toString();
157
161
  }
158
162
  switch (this.platform) {
159
- case 'github':
160
- metadata.event = request.headers.get('x-github-event');
161
- metadata.delivery = request.headers.get('x-github-delivery');
163
+ case "github":
164
+ metadata.event = request.headers.get("x-github-event");
165
+ metadata.delivery = request.headers.get("x-github-delivery");
162
166
  break;
163
- case 'stripe': {
167
+ case "stripe": {
164
168
  const headerValue = request.headers.get(this.config.headerName);
165
- if (headerValue && this.config.headerFormat === 'comma-separated') {
169
+ if (headerValue && this.config.headerFormat === "comma-separated") {
166
170
  const sigMap = this.parseDelimitedHeader(headerValue);
167
171
  metadata.id = sigMap.id;
168
172
  }
169
173
  break;
170
174
  }
171
- case 'clerk':
172
- case 'dodopayments':
173
- case 'replicateai':
174
- metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
175
+ case "clerk":
176
+ case "dodopayments":
177
+ case "replicateai":
178
+ metadata.id = request.headers.get(this.config.customConfig?.idHeader || "webhook-id");
175
179
  break;
176
- case 'workos':
177
- metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
180
+ case "workos":
181
+ metadata.id = request.headers.get(this.config.customConfig?.idHeader || "webhook-id");
178
182
  break;
179
183
  default:
180
184
  break;
@@ -191,13 +195,13 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
191
195
  return {
192
196
  isValid: false,
193
197
  error: `Missing signature header: ${this.config.headerName}`,
194
- errorCode: 'MISSING_SIGNATURE',
198
+ errorCode: "MISSING_SIGNATURE",
195
199
  platform: this.platform,
196
200
  };
197
201
  }
198
202
  const rawBody = await request.text();
199
203
  let timestamp = null;
200
- if (this.config.headerFormat === 'comma-separated') {
204
+ if (this.config.headerFormat === "comma-separated") {
201
205
  timestamp = this.extractTimestampFromSignature(request);
202
206
  }
203
207
  else {
@@ -206,18 +210,18 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
206
210
  if (timestamp && !this.isTimestampValid(timestamp)) {
207
211
  return {
208
212
  isValid: false,
209
- error: 'Webhook timestamp expired',
210
- errorCode: 'TIMESTAMP_EXPIRED',
213
+ error: "Webhook timestamp expired",
214
+ errorCode: "TIMESTAMP_EXPIRED",
211
215
  platform: this.platform,
212
216
  };
213
217
  }
214
218
  const payload = this.formatPayload(rawBody, request);
215
219
  let isValid = false;
216
- const algorithm = this.config.algorithm.replace('hmac-', '');
217
- if (this.config.customConfig?.encoding === 'base64') {
220
+ const algorithm = this.config.algorithm.replace("hmac-", "");
221
+ if (this.config.customConfig?.encoding === "base64") {
218
222
  isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
219
223
  }
220
- else if (this.config.headerFormat === 'prefixed') {
224
+ else if (this.config.headerFormat === "prefixed") {
221
225
  isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
222
226
  }
223
227
  else {
@@ -226,8 +230,8 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
226
230
  if (!isValid) {
227
231
  return {
228
232
  isValid: false,
229
- error: 'Invalid signature',
230
- errorCode: 'INVALID_SIGNATURE',
233
+ error: "Invalid signature",
234
+ errorCode: "INVALID_SIGNATURE",
231
235
  platform: this.platform,
232
236
  };
233
237
  }
@@ -238,19 +242,18 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
238
242
  catch (e) {
239
243
  parsedPayload = rawBody;
240
244
  }
241
- const metadata = this.extractMetadata(request);
242
245
  return {
243
246
  isValid: true,
244
247
  platform: this.platform,
245
248
  payload: parsedPayload,
246
- metadata,
249
+ metadata: this.extractMetadata(request),
247
250
  };
248
251
  }
249
252
  catch (error) {
250
253
  return {
251
254
  isValid: false,
252
255
  error: `${this.platform} verification error: ${error.message}`,
253
- errorCode: 'VERIFICATION_ERROR',
256
+ errorCode: "VERIFICATION_ERROR",
254
257
  platform: this.platform,
255
258
  };
256
259
  }
@@ -258,47 +261,91 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
258
261
  }
259
262
  exports.GenericHMACVerifier = GenericHMACVerifier;
260
263
  class Ed25519Verifier extends AlgorithmBasedVerifier {
261
- async resolvePublicKey(request) {
262
- const configPublicKey = this.config.customConfig?.publicKey;
263
- if (configPublicKey) {
264
- return configPublicKey;
264
+ /**
265
+ * Fetch all public keys from JWKS endpoint.
266
+ * Returns array of PEM strings — tries all keys during verification
267
+ * so key rotation works transparently.
268
+ * Caches for 24 hours per fal.ai docs recommendation.
269
+ */
270
+ async fetchJWKSKeys(jwksUrl) {
271
+ const cached = ed25519KeyCache.get(jwksUrl);
272
+ if (cached && Date.now() < cached.expiresAt) {
273
+ return cached.pems;
265
274
  }
266
- if (this.secret && this.secret.trim().length > 0) {
267
- return this.secret;
275
+ const response = await fetch(jwksUrl);
276
+ if (!response.ok) {
277
+ throw new Error(`Failed to fetch JWKS from ${jwksUrl}: ${response.status}`);
268
278
  }
269
- const jwksUrl = this.config.customConfig?.jwksUrl;
270
- const kidHeader = this.config.customConfig?.kidHeader;
271
- const kid = kidHeader ? request.headers.get(kidHeader) : null;
272
- if (!jwksUrl || !kid) {
273
- return null;
279
+ const body = (await response.json());
280
+ const keys = body.keys || [];
281
+ if (keys.length === 0) {
282
+ throw new Error("No keys found in JWKS response");
274
283
  }
275
- const cacheKey = `${jwksUrl}:${kid}`;
276
- if (ed25519KeyCache.has(cacheKey)) {
277
- return ed25519KeyCache.get(cacheKey);
284
+ const pems = [];
285
+ for (const key of keys) {
286
+ try {
287
+ const keyObject = (0, crypto_1.createPublicKey)({ key, format: "jwk" });
288
+ const pem = keyObject
289
+ .export({ type: "spki", format: "pem" })
290
+ .toString();
291
+ pems.push(pem);
292
+ }
293
+ catch {
294
+ // skip malformed keys, continue with rest
295
+ continue;
296
+ }
278
297
  }
279
- const response = await fetch(jwksUrl);
280
- if (!response.ok) {
281
- return null;
298
+ if (pems.length === 0) {
299
+ throw new Error("No valid ED25519 keys found in JWKS");
282
300
  }
283
- const body = await response.json();
284
- const key = body.keys?.find((entry) => entry.kid === kid);
285
- if (!key) {
286
- return null;
301
+ ed25519KeyCache.set(jwksUrl, {
302
+ pems,
303
+ expiresAt: Date.now() + JWKS_CACHE_TTL,
304
+ });
305
+ return pems;
306
+ }
307
+ /**
308
+ * Resolve public keys to use for verification.
309
+ * Priority:
310
+ * 1. Explicit publicKey in config
311
+ * 2. Non-empty secret passed directly (user-provided PEM)
312
+ * 3. JWKS URL in config (fal.ai — fetches all keys, tries each)
313
+ */
314
+ async resolvePublicKeys(request) {
315
+ // 1. explicit public key in config
316
+ const configPublicKey = this.config.customConfig?.publicKey;
317
+ if (configPublicKey) {
318
+ return [configPublicKey];
287
319
  }
288
- const keyObject = (0, crypto_1.createPublicKey)({ key, format: 'jwk' });
289
- const pem = keyObject.export({ type: 'spki', format: 'pem' }).toString();
290
- ed25519KeyCache.set(cacheKey, pem);
291
- return pem;
320
+ // 2. non-empty secret = user passed PEM directly
321
+ // empty string = fal.ai pattern (no shared secret, use JWKS)
322
+ if (this.secret &&
323
+ this.secret.trim().length > 0 &&
324
+ this.platform !== "falai") {
325
+ return [this.secret];
326
+ }
327
+ // 3. fetch from JWKS — handles fal.ai and any other JWKS-based platform
328
+ const jwksUrl = this.config.customConfig?.jwksUrl;
329
+ if (!jwksUrl) {
330
+ return [];
331
+ }
332
+ return this.fetchJWKSKeys(jwksUrl);
292
333
  }
334
+ /**
335
+ * Build fal.ai specific payload string.
336
+ *
337
+ * Per fal.ai docs, message is newline-separated (NOT dot-separated):
338
+ * {request-id}\n{user-id}\n{timestamp}\n{sha256hex(body)}
339
+ */
293
340
  buildFalPayload(rawBody, request) {
294
- const requestIdHeader = this.config.customConfig?.requestIdHeader || 'x-fal-request-id';
295
- const userIdHeader = this.config.customConfig?.userIdHeader || 'x-fal-user-id';
296
- const timestampHeader = this.config.customConfig?.timestampHeader || 'x-fal-webhook-timestamp';
297
- const requestId = request.headers.get(requestIdHeader) || '';
298
- const userId = request.headers.get(userIdHeader) || '';
299
- const timestamp = request.headers.get(timestampHeader) || '';
300
- const bodyHash = (0, crypto_1.createHash)('sha256').update(rawBody).digest('hex');
301
- return `${requestId}.${userId}.${timestamp}.${bodyHash}`;
341
+ const requestIdHeader = this.config.customConfig?.requestIdHeader || "x-fal-webhook-request-id";
342
+ const userIdHeader = this.config.customConfig?.userIdHeader || "x-fal-webhook-user-id";
343
+ const timestampHeader = this.config.customConfig?.timestampHeader || "x-fal-webhook-timestamp";
344
+ const requestId = request.headers.get(requestIdHeader) || "";
345
+ const userId = request.headers.get(userIdHeader) || "";
346
+ const timestamp = request.headers.get(timestampHeader) || "";
347
+ const bodyHash = (0, crypto_1.createHash)("sha256").update(rawBody).digest("hex");
348
+ return `${requestId}\n${userId}\n${timestamp}\n${bodyHash}`;
302
349
  }
303
350
  async verify(request) {
304
351
  try {
@@ -307,32 +354,79 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
307
354
  return {
308
355
  isValid: false,
309
356
  error: `Missing signature header: ${this.config.headerName}`,
310
- errorCode: 'MISSING_SIGNATURE',
357
+ errorCode: "MISSING_SIGNATURE",
311
358
  platform: this.platform,
312
359
  };
313
360
  }
314
361
  const rawBody = await request.text();
315
- const payload = this.platform === 'falai'
362
+ // fal.ai timestamp validation before signature check
363
+ if (this.platform === "falai") {
364
+ const timestampHeader = this.config.customConfig?.timestampHeader ||
365
+ "x-fal-webhook-timestamp";
366
+ const timestampStr = request.headers.get(timestampHeader);
367
+ if (timestampStr) {
368
+ const timestamp = parseInt(timestampStr, 10);
369
+ if (!this.isTimestampValid(timestamp)) {
370
+ return {
371
+ isValid: false,
372
+ error: "Webhook timestamp expired",
373
+ errorCode: "TIMESTAMP_EXPIRED",
374
+ platform: this.platform,
375
+ };
376
+ }
377
+ }
378
+ }
379
+ const payload = this.platform === "falai"
316
380
  ? this.buildFalPayload(rawBody, request)
317
381
  : this.formatPayload(rawBody, request);
318
- const publicKey = await this.resolvePublicKey(request);
319
- if (!publicKey) {
382
+ // resolve all public keys
383
+ let publicKeys;
384
+ try {
385
+ publicKeys = await this.resolvePublicKeys(request);
386
+ }
387
+ catch (error) {
388
+ return {
389
+ isValid: false,
390
+ error: `Failed to resolve public keys: ${error.message}`,
391
+ errorCode: "VERIFICATION_ERROR",
392
+ platform: this.platform,
393
+ };
394
+ }
395
+ if (publicKeys.length === 0) {
320
396
  return {
321
397
  isValid: false,
322
- error: 'Missing public key for ED25519 verification',
323
- errorCode: 'VERIFICATION_ERROR',
398
+ error: "No public keys available for ED25519 verification",
399
+ errorCode: "VERIFICATION_ERROR",
324
400
  platform: this.platform,
325
401
  };
326
402
  }
327
- const signatureBytes = new Uint8Array(Buffer.from(signature, 'base64'));
328
- const keyObject = (0, crypto_1.createPublicKey)(publicKey);
329
- const payloadBytes = new Uint8Array(Buffer.from(payload));
330
- const isValid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
403
+ // fal.ai signature is hex encoded (not base64)
404
+ // other ED25519 platforms use base64 by default
405
+ const signatureEncoding = this.platform === "falai" ? "hex" : "base64";
406
+ const signatureBytes = new Uint8Array(Buffer.from(signature, signatureEncoding));
407
+ const payloadBytes = new Uint8Array(Buffer.from(payload, "utf-8"));
408
+ // try all keys — succeeds if any key validates
409
+ // this handles key rotation gracefully
410
+ let isValid = false;
411
+ for (const pem of publicKeys) {
412
+ try {
413
+ const keyObject = (0, crypto_1.createPublicKey)(pem);
414
+ const valid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
415
+ if (valid) {
416
+ isValid = true;
417
+ break;
418
+ }
419
+ }
420
+ catch {
421
+ // this key failed — try next
422
+ continue;
423
+ }
424
+ }
331
425
  if (!isValid) {
332
426
  return {
333
427
  isValid: false,
334
- error: 'Invalid signature',
335
- errorCode: 'INVALID_SIGNATURE',
428
+ error: "Invalid signature",
429
+ errorCode: "INVALID_SIGNATURE",
336
430
  platform: this.platform,
337
431
  };
338
432
  }
@@ -349,9 +443,11 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
349
443
  payload: parsedPayload,
350
444
  metadata: {
351
445
  algorithm: this.config.algorithm,
352
- requestId: request.headers.get(this.config.customConfig?.requestIdHeader || 'x-fal-request-id'),
353
- userId: request.headers.get(this.config.customConfig?.userIdHeader || 'x-fal-user-id'),
354
- timestamp: request.headers.get(this.config.customConfig?.timestampHeader || 'x-fal-webhook-timestamp') || undefined,
446
+ requestId: request.headers.get(this.config.customConfig?.requestIdHeader ||
447
+ "x-fal-webhook-request-id"),
448
+ userId: request.headers.get(this.config.customConfig?.userIdHeader || "x-fal-webhook-user-id"),
449
+ timestamp: request.headers.get(this.config.customConfig?.timestampHeader ||
450
+ "x-fal-webhook-timestamp") || undefined,
355
451
  },
356
452
  };
357
453
  }
@@ -359,7 +455,7 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
359
455
  return {
360
456
  isValid: false,
361
457
  error: `${this.platform} verification error: ${error.message}`,
362
- errorCode: 'VERIFICATION_ERROR',
458
+ errorCode: "VERIFICATION_ERROR",
363
459
  platform: this.platform,
364
460
  };
365
461
  }
@@ -367,33 +463,33 @@ class Ed25519Verifier extends AlgorithmBasedVerifier {
367
463
  }
368
464
  exports.Ed25519Verifier = Ed25519Verifier;
369
465
  class HMACSHA256Verifier extends GenericHMACVerifier {
370
- constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
466
+ constructor(secret, config, platform = "unknown", toleranceInSeconds = 300) {
371
467
  super(secret, config, platform, toleranceInSeconds);
372
468
  }
373
469
  }
374
470
  exports.HMACSHA256Verifier = HMACSHA256Verifier;
375
471
  class HMACSHA1Verifier extends GenericHMACVerifier {
376
- constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
472
+ constructor(secret, config, platform = "unknown", toleranceInSeconds = 300) {
377
473
  super(secret, config, platform, toleranceInSeconds);
378
474
  }
379
475
  }
380
476
  exports.HMACSHA1Verifier = HMACSHA1Verifier;
381
477
  class HMACSHA512Verifier extends GenericHMACVerifier {
382
- constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
478
+ constructor(secret, config, platform = "unknown", toleranceInSeconds = 300) {
383
479
  super(secret, config, platform, toleranceInSeconds);
384
480
  }
385
481
  }
386
482
  exports.HMACSHA512Verifier = HMACSHA512Verifier;
387
- function createAlgorithmVerifier(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
483
+ function createAlgorithmVerifier(secret, config, platform = "unknown", toleranceInSeconds = 300) {
388
484
  switch (config.algorithm) {
389
- case 'hmac-sha256':
390
- case 'hmac-sha1':
391
- case 'hmac-sha512':
485
+ case "hmac-sha256":
486
+ case "hmac-sha1":
487
+ case "hmac-sha512":
392
488
  return new GenericHMACVerifier(secret, config, platform, toleranceInSeconds);
393
- case 'ed25519':
489
+ case "ed25519":
394
490
  return new Ed25519Verifier(secret, config, platform, toleranceInSeconds);
395
- case 'rsa-sha256':
396
- case 'custom':
491
+ case "rsa-sha256":
492
+ case "custom":
397
493
  throw new Error(`Algorithm ${config.algorithm} not yet implemented`);
398
494
  default:
399
495
  throw new Error(`Unknown algorithm: ${config.algorithm}`);
@@ -1,4 +1,4 @@
1
- import { WebhookVerificationResult } from '../types';
1
+ import { WebhookVerificationResult } from "../types";
2
2
  export declare abstract class WebhookVerifier {
3
3
  protected secret: string;
4
4
  protected toleranceInSeconds: number;
@@ -9,7 +9,9 @@ class WebhookVerifier {
9
9
  }
10
10
  isTimestampValid(timestamp) {
11
11
  const now = Math.floor(Date.now() / 1000);
12
- return Math.abs(now - timestamp) <= this.toleranceInSeconds;
12
+ // WorkOS sends milliseconds — auto-detect by magnitude
13
+ const timestampInSeconds = timestamp > 1e12 ? Math.floor(timestamp / 1000) : timestamp;
14
+ return Math.abs(now - timestampInSeconds) <= this.toleranceInSeconds;
13
15
  }
14
16
  safeCompare(a, b) {
15
17
  if (a.length !== b.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hookflo/tern",
3
- "version": "2.2.5",
3
+ "version": "2.2.9",
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",