@hookflo/tern 2.0.2 → 2.2.0
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 +60 -1
- package/dist/index.js +19 -1
- package/dist/platforms/algorithms.js +114 -1
- package/dist/test.js +179 -0
- package/dist/types.d.ts +9 -1
- package/dist/types.js +8 -0
- package/dist/utils.js +25 -0
- package/dist/verifiers/algorithms.d.ts +6 -0
- package/dist/verifiers/algorithms.js +166 -68
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -210,6 +210,14 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
|
|
|
210
210
|
|
|
211
211
|
### Other Platforms
|
|
212
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)
|
|
220
|
+
- **fal.ai**: ED25519 (`x-fal-webhook-signature`)
|
|
213
221
|
- **Shopify**: HMAC-SHA256
|
|
214
222
|
- **Vercel**: HMAC-SHA256
|
|
215
223
|
- **Polar**: HMAC-SHA256
|
|
@@ -369,6 +377,58 @@ export default {
|
|
|
369
377
|
};
|
|
370
378
|
```
|
|
371
379
|
|
|
380
|
+
|
|
381
|
+
### Are new platforms available in framework middlewares automatically?
|
|
382
|
+
|
|
383
|
+
Yes. All built-in platforms are available in:
|
|
384
|
+
- `createWebhookMiddleware` (`@hookflo/tern/express`)
|
|
385
|
+
- `createWebhookHandler` (`@hookflo/tern/nextjs`)
|
|
386
|
+
- `createWebhookHandler` (`@hookflo/tern/cloudflare`)
|
|
387
|
+
|
|
388
|
+
You only change `platform` and `secret` per route.
|
|
389
|
+
|
|
390
|
+
### Platform route examples (Express / Next.js / Cloudflare)
|
|
391
|
+
|
|
392
|
+
```typescript
|
|
393
|
+
// Express (Razorpay)
|
|
394
|
+
app.post('/webhooks/razorpay', createWebhookMiddleware({
|
|
395
|
+
platform: 'razorpay',
|
|
396
|
+
secret: process.env.RAZORPAY_WEBHOOK_SECRET!,
|
|
397
|
+
}), (req, res) => res.json({ ok: true }));
|
|
398
|
+
|
|
399
|
+
// Next.js (WorkOS)
|
|
400
|
+
export const POST = createWebhookHandler({
|
|
401
|
+
platform: 'workos',
|
|
402
|
+
secret: process.env.WORKOS_WEBHOOK_SECRET!,
|
|
403
|
+
handler: async (payload) => ({ received: true, type: payload.type }),
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Cloudflare (Lemon Squeezy)
|
|
407
|
+
const handleLemonSqueezy = createWebhookHandler({
|
|
408
|
+
platform: 'lemonsqueezy',
|
|
409
|
+
secretEnv: 'LEMON_SQUEEZY_WEBHOOK_SECRET',
|
|
410
|
+
handler: async () => ({ received: true }),
|
|
411
|
+
});
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### fal.ai production usage
|
|
415
|
+
|
|
416
|
+
fal.ai uses **ED25519** (`x-fal-webhook-signature`) and signs:
|
|
417
|
+
`{request-id}.{user-id}.{timestamp}.{sha256(body)}`.
|
|
418
|
+
|
|
419
|
+
Use one of these strategies:
|
|
420
|
+
1. **Public key as `secret`** (recommended for framework adapters).
|
|
421
|
+
2. **JWKS auto-resolution** via the built-in fal.ai config (`x-fal-webhook-key-id` + fal JWKS URL).
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
// Next.js with explicit public key PEM as secret
|
|
425
|
+
export const POST = createWebhookHandler({
|
|
426
|
+
platform: 'falai',
|
|
427
|
+
secret: process.env.FAL_PUBLIC_KEY_PEM!,
|
|
428
|
+
handler: async (payload, metadata) => ({ received: true, requestId: metadata.requestId }),
|
|
429
|
+
});
|
|
430
|
+
```
|
|
431
|
+
|
|
372
432
|
## API Reference
|
|
373
433
|
|
|
374
434
|
### WebhookVerificationService
|
|
@@ -545,4 +605,3 @@ export const POST = createWebhookHandler({
|
|
|
545
605
|
handler: async (payload) => ({ received: true, event: payload.event ?? payload.type }),
|
|
546
606
|
});
|
|
547
607
|
```
|
|
548
|
-
|
package/dist/index.js
CHANGED
|
@@ -103,12 +103,30 @@ class WebhookVerificationService {
|
|
|
103
103
|
return 'github';
|
|
104
104
|
if (headers.has('svix-signature'))
|
|
105
105
|
return 'clerk';
|
|
106
|
-
if (headers.has('
|
|
106
|
+
if (headers.has('workos-signature'))
|
|
107
|
+
return 'workos';
|
|
108
|
+
if (headers.has('webhook-signature')) {
|
|
109
|
+
const userAgent = headers.get('user-agent')?.toLowerCase() || '';
|
|
110
|
+
if (userAgent.includes('replicate'))
|
|
111
|
+
return 'replicateai';
|
|
107
112
|
return 'dodopayments';
|
|
113
|
+
}
|
|
108
114
|
if (headers.has('x-gitlab-token'))
|
|
109
115
|
return 'gitlab';
|
|
110
116
|
if (headers.has('x-polar-signature'))
|
|
111
117
|
return 'polar';
|
|
118
|
+
if (headers.has('paddle-signature'))
|
|
119
|
+
return 'paddle';
|
|
120
|
+
if (headers.has('x-razorpay-signature'))
|
|
121
|
+
return 'razorpay';
|
|
122
|
+
if (headers.has('x-signature'))
|
|
123
|
+
return 'lemonsqueezy';
|
|
124
|
+
if (headers.has('x-auth0-signature'))
|
|
125
|
+
return 'auth0';
|
|
126
|
+
if (headers.has('x-wc-webhook-signature'))
|
|
127
|
+
return 'woocommerce';
|
|
128
|
+
if (headers.has('x-fal-signature') || headers.has('x-fal-webhook-signature'))
|
|
129
|
+
return 'falai';
|
|
112
130
|
if (headers.has('x-shopify-hmac-sha256'))
|
|
113
131
|
return 'shopify';
|
|
114
132
|
if (headers.has('x-vercel-signature'))
|
|
@@ -45,6 +45,7 @@ exports.platformAlgorithmConfigs = {
|
|
|
45
45
|
signatureFormat: 'v1={signature}',
|
|
46
46
|
payloadFormat: '{id}.{timestamp}.{body}',
|
|
47
47
|
encoding: 'base64',
|
|
48
|
+
secretEncoding: 'base64',
|
|
48
49
|
idHeader: 'svix-id',
|
|
49
50
|
},
|
|
50
51
|
},
|
|
@@ -63,6 +64,7 @@ exports.platformAlgorithmConfigs = {
|
|
|
63
64
|
signatureFormat: 'v1={signature}',
|
|
64
65
|
payloadFormat: '{id}.{timestamp}.{body}',
|
|
65
66
|
encoding: 'base64',
|
|
67
|
+
secretEncoding: 'base64',
|
|
66
68
|
idHeader: 'webhook-id',
|
|
67
69
|
},
|
|
68
70
|
},
|
|
@@ -131,6 +133,116 @@ exports.platformAlgorithmConfigs = {
|
|
|
131
133
|
},
|
|
132
134
|
description: 'GitLab webhooks use HMAC-SHA256 with X-Gitlab-Token header',
|
|
133
135
|
},
|
|
136
|
+
paddle: {
|
|
137
|
+
platform: 'paddle',
|
|
138
|
+
signatureConfig: {
|
|
139
|
+
algorithm: 'hmac-sha256',
|
|
140
|
+
headerName: 'paddle-signature',
|
|
141
|
+
headerFormat: 'comma-separated',
|
|
142
|
+
payloadFormat: 'custom',
|
|
143
|
+
customConfig: {
|
|
144
|
+
timestampKey: 'ts',
|
|
145
|
+
signatureKey: 'h1',
|
|
146
|
+
payloadFormat: '{timestamp}:{body}',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
description: 'Paddle webhooks use HMAC-SHA256 with Paddle-Signature (ts/h1) header format',
|
|
150
|
+
},
|
|
151
|
+
razorpay: {
|
|
152
|
+
platform: 'razorpay',
|
|
153
|
+
signatureConfig: {
|
|
154
|
+
algorithm: 'hmac-sha256',
|
|
155
|
+
headerName: 'x-razorpay-signature',
|
|
156
|
+
headerFormat: 'raw',
|
|
157
|
+
payloadFormat: 'raw',
|
|
158
|
+
},
|
|
159
|
+
description: 'Razorpay webhooks use HMAC-SHA256 with X-Razorpay-Signature header',
|
|
160
|
+
},
|
|
161
|
+
lemonsqueezy: {
|
|
162
|
+
platform: 'lemonsqueezy',
|
|
163
|
+
signatureConfig: {
|
|
164
|
+
algorithm: 'hmac-sha256',
|
|
165
|
+
headerName: 'x-signature',
|
|
166
|
+
headerFormat: 'raw',
|
|
167
|
+
payloadFormat: 'raw',
|
|
168
|
+
},
|
|
169
|
+
description: 'Lemon Squeezy webhooks use HMAC-SHA256 with X-Signature header',
|
|
170
|
+
},
|
|
171
|
+
auth0: {
|
|
172
|
+
platform: 'auth0',
|
|
173
|
+
signatureConfig: {
|
|
174
|
+
algorithm: 'hmac-sha256',
|
|
175
|
+
headerName: 'x-auth0-signature',
|
|
176
|
+
headerFormat: 'raw',
|
|
177
|
+
payloadFormat: 'raw',
|
|
178
|
+
},
|
|
179
|
+
description: 'Auth0 webhooks use HMAC-SHA256 with X-Auth0-Signature header',
|
|
180
|
+
},
|
|
181
|
+
workos: {
|
|
182
|
+
platform: 'workos',
|
|
183
|
+
signatureConfig: {
|
|
184
|
+
algorithm: 'hmac-sha256',
|
|
185
|
+
headerName: 'workos-signature',
|
|
186
|
+
headerFormat: 'comma-separated',
|
|
187
|
+
payloadFormat: 'custom',
|
|
188
|
+
customConfig: {
|
|
189
|
+
timestampKey: 't',
|
|
190
|
+
signatureKey: 'v1',
|
|
191
|
+
payloadFormat: '{timestamp}.{body}',
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
description: 'WorkOS webhooks use HMAC-SHA256 with WorkOS-Signature (t/v1) format',
|
|
195
|
+
},
|
|
196
|
+
woocommerce: {
|
|
197
|
+
platform: 'woocommerce',
|
|
198
|
+
signatureConfig: {
|
|
199
|
+
algorithm: 'hmac-sha256',
|
|
200
|
+
headerName: 'x-wc-webhook-signature',
|
|
201
|
+
headerFormat: 'raw',
|
|
202
|
+
payloadFormat: 'raw',
|
|
203
|
+
customConfig: {
|
|
204
|
+
encoding: 'base64',
|
|
205
|
+
secretEncoding: 'utf8',
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
description: 'WooCommerce webhooks use HMAC-SHA256 with base64 encoded signature',
|
|
209
|
+
},
|
|
210
|
+
replicateai: {
|
|
211
|
+
platform: 'replicateai',
|
|
212
|
+
signatureConfig: {
|
|
213
|
+
algorithm: 'hmac-sha256',
|
|
214
|
+
headerName: 'webhook-signature',
|
|
215
|
+
headerFormat: 'raw',
|
|
216
|
+
timestampHeader: 'webhook-timestamp',
|
|
217
|
+
timestampFormat: 'unix',
|
|
218
|
+
payloadFormat: 'custom',
|
|
219
|
+
customConfig: {
|
|
220
|
+
signatureFormat: 'v1={signature}',
|
|
221
|
+
payloadFormat: '{id}.{timestamp}.{body}',
|
|
222
|
+
encoding: 'base64',
|
|
223
|
+
secretEncoding: 'base64',
|
|
224
|
+
idHeader: 'webhook-id',
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
description: 'Replicate webhooks use HMAC-SHA256 with Standard Webhooks (svix-style) format',
|
|
228
|
+
},
|
|
229
|
+
falai: {
|
|
230
|
+
platform: 'falai',
|
|
231
|
+
signatureConfig: {
|
|
232
|
+
algorithm: 'ed25519',
|
|
233
|
+
headerName: 'x-fal-webhook-signature',
|
|
234
|
+
headerFormat: 'raw',
|
|
235
|
+
payloadFormat: 'custom',
|
|
236
|
+
customConfig: {
|
|
237
|
+
requestIdHeader: 'x-fal-request-id',
|
|
238
|
+
userIdHeader: 'x-fal-user-id',
|
|
239
|
+
timestampHeader: 'x-fal-webhook-timestamp',
|
|
240
|
+
kidHeader: 'x-fal-webhook-key-id',
|
|
241
|
+
jwksUrl: 'https://rest.alpha.fal.ai/.well-known/jwks.json',
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
description: 'fal.ai webhooks use ED25519 with a signed request/user/timestamp/body-hash payload',
|
|
245
|
+
},
|
|
134
246
|
custom: {
|
|
135
247
|
platform: 'custom',
|
|
136
248
|
signatureConfig: {
|
|
@@ -178,8 +290,9 @@ function validateSignatureConfig(config) {
|
|
|
178
290
|
case 'hmac-sha512':
|
|
179
291
|
return true;
|
|
180
292
|
case 'rsa-sha256':
|
|
181
|
-
case 'ed25519':
|
|
182
293
|
return !!config.customConfig?.publicKey;
|
|
294
|
+
case 'ed25519':
|
|
295
|
+
return !!config.customConfig?.publicKey || !!config.customConfig?.jwksUrl;
|
|
183
296
|
case 'custom':
|
|
184
297
|
return !!config.customConfig;
|
|
185
298
|
default:
|
package/dist/test.js
CHANGED
|
@@ -35,6 +35,35 @@ function createClerkSignature(body, secret, id, timestamp) {
|
|
|
35
35
|
hmac.update(signedContent);
|
|
36
36
|
return `v1,${hmac.digest('base64')}`;
|
|
37
37
|
}
|
|
38
|
+
function createStandardWebhooksSignature(body, secret, id, timestamp) {
|
|
39
|
+
const signedContent = `${id}.${timestamp}.${body}`;
|
|
40
|
+
const base64Secret = secret.includes('_') ? secret.split('_')[1] : secret;
|
|
41
|
+
const secretBytes = new Uint8Array(Buffer.from(base64Secret, 'base64'));
|
|
42
|
+
const hmac = (0, crypto_1.createHmac)('sha256', secretBytes);
|
|
43
|
+
hmac.update(signedContent);
|
|
44
|
+
return `v1,${hmac.digest('base64')}`;
|
|
45
|
+
}
|
|
46
|
+
function createPaddleSignature(body, secret, timestamp) {
|
|
47
|
+
const signedPayload = `${timestamp}:${body}`;
|
|
48
|
+
const hmac = (0, crypto_1.createHmac)('sha256', secret);
|
|
49
|
+
hmac.update(signedPayload);
|
|
50
|
+
return `ts=${timestamp};h1=${hmac.digest('hex')}`;
|
|
51
|
+
}
|
|
52
|
+
function createWooCommerceSignature(body, secret) {
|
|
53
|
+
const hmac = (0, crypto_1.createHmac)('sha256', secret);
|
|
54
|
+
hmac.update(body);
|
|
55
|
+
return hmac.digest('base64');
|
|
56
|
+
}
|
|
57
|
+
function createWorkOSSignature(body, secret, timestamp) {
|
|
58
|
+
const signedPayload = `${timestamp}.${body}`;
|
|
59
|
+
const hmac = (0, crypto_1.createHmac)('sha256', secret);
|
|
60
|
+
hmac.update(signedPayload);
|
|
61
|
+
return `t=${timestamp},v1=${hmac.digest('hex')}`;
|
|
62
|
+
}
|
|
63
|
+
function createFalPayloadToSign(body, requestId, userId, timestamp) {
|
|
64
|
+
const bodyHash = (0, crypto_1.createHash)('sha256').update(body).digest('hex');
|
|
65
|
+
return `${requestId}.${userId}.${timestamp}.${bodyHash}`;
|
|
66
|
+
}
|
|
38
67
|
async function runTests() {
|
|
39
68
|
console.log('🧪 Running Webhook Verification Tests...\n');
|
|
40
69
|
// Test 1: Stripe Webhook
|
|
@@ -289,6 +318,156 @@ async function runTests() {
|
|
|
289
318
|
catch (error) {
|
|
290
319
|
console.log(' ❌ Category registry test failed:', error);
|
|
291
320
|
}
|
|
321
|
+
// Test 13: Razorpay
|
|
322
|
+
console.log('\n13. Testing Razorpay webhook...');
|
|
323
|
+
try {
|
|
324
|
+
const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
|
|
325
|
+
hmac.update(testBody);
|
|
326
|
+
const signature = hmac.digest('hex');
|
|
327
|
+
const request = createMockRequest({
|
|
328
|
+
'x-razorpay-signature': signature,
|
|
329
|
+
'content-type': 'application/json',
|
|
330
|
+
});
|
|
331
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'razorpay', testSecret);
|
|
332
|
+
console.log(' ✅ Razorpay:', result.isValid ? 'PASSED' : 'FAILED');
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
console.log(' ❌ Razorpay test failed:', error);
|
|
336
|
+
}
|
|
337
|
+
// Test 14: Lemon Squeezy
|
|
338
|
+
console.log('\n14. Testing Lemon Squeezy webhook...');
|
|
339
|
+
try {
|
|
340
|
+
const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
|
|
341
|
+
hmac.update(testBody);
|
|
342
|
+
const signature = hmac.digest('hex');
|
|
343
|
+
const request = createMockRequest({
|
|
344
|
+
'x-signature': signature,
|
|
345
|
+
'content-type': 'application/json',
|
|
346
|
+
});
|
|
347
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'lemonsqueezy', testSecret);
|
|
348
|
+
console.log(' ✅ Lemon Squeezy:', result.isValid ? 'PASSED' : 'FAILED');
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
console.log(' ❌ Lemon Squeezy test failed:', error);
|
|
352
|
+
}
|
|
353
|
+
// Test 15: Paddle
|
|
354
|
+
console.log('\n15. Testing Paddle webhook...');
|
|
355
|
+
try {
|
|
356
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
357
|
+
const signature = createPaddleSignature(testBody, testSecret, timestamp);
|
|
358
|
+
const request = createMockRequest({
|
|
359
|
+
'paddle-signature': signature,
|
|
360
|
+
'content-type': 'application/json',
|
|
361
|
+
});
|
|
362
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'paddle', testSecret);
|
|
363
|
+
console.log(' ✅ Paddle:', result.isValid ? 'PASSED' : 'FAILED');
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
console.log(' ❌ Paddle test failed:', error);
|
|
367
|
+
}
|
|
368
|
+
// Test 16: Auth0
|
|
369
|
+
console.log('\n16. Testing Auth0 webhook...');
|
|
370
|
+
try {
|
|
371
|
+
const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
|
|
372
|
+
hmac.update(testBody);
|
|
373
|
+
const signature = hmac.digest('hex');
|
|
374
|
+
const request = createMockRequest({
|
|
375
|
+
'x-auth0-signature': signature,
|
|
376
|
+
'content-type': 'application/json',
|
|
377
|
+
});
|
|
378
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'auth0', testSecret);
|
|
379
|
+
console.log(' ✅ Auth0:', result.isValid ? 'PASSED' : 'FAILED');
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
console.log(' ❌ Auth0 test failed:', error);
|
|
383
|
+
}
|
|
384
|
+
// Test 17: WorkOS
|
|
385
|
+
console.log('\n17. Testing WorkOS webhook...');
|
|
386
|
+
try {
|
|
387
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
388
|
+
const signature = createWorkOSSignature(testBody, testSecret, timestamp);
|
|
389
|
+
const request = createMockRequest({
|
|
390
|
+
'workos-signature': signature,
|
|
391
|
+
'content-type': 'application/json',
|
|
392
|
+
});
|
|
393
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'workos', testSecret);
|
|
394
|
+
console.log(' ✅ WorkOS:', result.isValid ? 'PASSED' : 'FAILED');
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
console.log(' ❌ WorkOS test failed:', error);
|
|
398
|
+
}
|
|
399
|
+
// Test 18: WooCommerce
|
|
400
|
+
console.log('\n18. Testing WooCommerce webhook...');
|
|
401
|
+
try {
|
|
402
|
+
const signature = createWooCommerceSignature(testBody, testSecret);
|
|
403
|
+
const request = createMockRequest({
|
|
404
|
+
'x-wc-webhook-signature': signature,
|
|
405
|
+
'content-type': 'application/json',
|
|
406
|
+
});
|
|
407
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'woocommerce', testSecret);
|
|
408
|
+
console.log(' ✅ WooCommerce:', result.isValid ? 'PASSED' : 'FAILED');
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
console.log(' ❌ WooCommerce test failed:', error);
|
|
412
|
+
}
|
|
413
|
+
// Test 19: Replicate
|
|
414
|
+
console.log('\n19. Testing Replicate webhook...');
|
|
415
|
+
try {
|
|
416
|
+
const secret = `whsec_${Buffer.from(testSecret).toString('base64')}`;
|
|
417
|
+
const webhookId = 'replicate-webhook-id-1';
|
|
418
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
419
|
+
const signature = createStandardWebhooksSignature(testBody, secret, webhookId, timestamp);
|
|
420
|
+
const request = createMockRequest({
|
|
421
|
+
'webhook-signature': signature,
|
|
422
|
+
'webhook-id': webhookId,
|
|
423
|
+
'webhook-timestamp': timestamp.toString(),
|
|
424
|
+
'user-agent': 'Replicate-Webhooks/1.0',
|
|
425
|
+
'content-type': 'application/json',
|
|
426
|
+
});
|
|
427
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'replicateai', secret);
|
|
428
|
+
console.log(' ✅ Replicate:', result.isValid ? 'PASSED' : 'FAILED');
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
console.log(' ❌ Replicate test failed:', error);
|
|
432
|
+
}
|
|
433
|
+
// Test 20: fal.ai
|
|
434
|
+
console.log('\n20. Testing fal.ai webhook...');
|
|
435
|
+
try {
|
|
436
|
+
const { privateKey, publicKey } = (0, crypto_1.generateKeyPairSync)('ed25519');
|
|
437
|
+
const requestId = 'fal-request-id';
|
|
438
|
+
const userId = 'fal-user-id';
|
|
439
|
+
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
440
|
+
const payloadToSign = createFalPayloadToSign(testBody, requestId, userId, timestamp);
|
|
441
|
+
const payloadBytes = new Uint8Array(Buffer.from(payloadToSign));
|
|
442
|
+
const signature = (0, crypto_1.sign)(null, payloadBytes, privateKey).toString('base64');
|
|
443
|
+
const request = createMockRequest({
|
|
444
|
+
'x-fal-webhook-signature': signature,
|
|
445
|
+
'x-fal-request-id': requestId,
|
|
446
|
+
'x-fal-user-id': userId,
|
|
447
|
+
'x-fal-webhook-timestamp': timestamp,
|
|
448
|
+
'content-type': 'application/json',
|
|
449
|
+
});
|
|
450
|
+
const result = await index_1.WebhookVerificationService.verify(request, {
|
|
451
|
+
platform: 'falai',
|
|
452
|
+
secret: '',
|
|
453
|
+
signatureConfig: {
|
|
454
|
+
algorithm: 'ed25519',
|
|
455
|
+
headerName: 'x-fal-webhook-signature',
|
|
456
|
+
headerFormat: 'raw',
|
|
457
|
+
payloadFormat: 'custom',
|
|
458
|
+
customConfig: {
|
|
459
|
+
publicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
460
|
+
requestIdHeader: 'x-fal-request-id',
|
|
461
|
+
userIdHeader: 'x-fal-user-id',
|
|
462
|
+
timestampHeader: 'x-fal-webhook-timestamp',
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
console.log(' ✅ fal.ai:', result.isValid ? 'PASSED' : 'FAILED');
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
console.log(' ❌ fal.ai test failed:', error);
|
|
470
|
+
}
|
|
292
471
|
console.log('\n🎉 All tests completed!');
|
|
293
472
|
}
|
|
294
473
|
// Run tests if this file is executed directly
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type WebhookPlatform = 'custom' | 'clerk' | 'supabase' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'gitlab' | 'unknown';
|
|
1
|
+
export type WebhookPlatform = 'custom' | 'clerk' | 'supabase' | 'github' | 'stripe' | 'shopify' | 'vercel' | 'polar' | 'dodopayments' | 'gitlab' | 'paddle' | 'razorpay' | 'lemonsqueezy' | 'auth0' | 'workos' | 'woocommerce' | 'replicateai' | 'falai' | 'unknown';
|
|
2
2
|
export declare enum WebhookPlatformKeys {
|
|
3
3
|
GitHub = "github",
|
|
4
4
|
Stripe = "stripe",
|
|
@@ -9,6 +9,14 @@ export declare enum WebhookPlatformKeys {
|
|
|
9
9
|
Polar = "polar",
|
|
10
10
|
Supabase = "supabase",
|
|
11
11
|
GitLab = "gitlab",
|
|
12
|
+
Paddle = "paddle",
|
|
13
|
+
Razorpay = "razorpay",
|
|
14
|
+
LemonSqueezy = "lemonsqueezy",
|
|
15
|
+
Auth0 = "auth0",
|
|
16
|
+
WorkOS = "workos",
|
|
17
|
+
WooCommerce = "woocommerce",
|
|
18
|
+
ReplicateAI = "replicateai",
|
|
19
|
+
FalAI = "falai",
|
|
12
20
|
Custom = "custom",
|
|
13
21
|
Unknown = "unknown"
|
|
14
22
|
}
|
package/dist/types.js
CHANGED
|
@@ -12,6 +12,14 @@ var WebhookPlatformKeys;
|
|
|
12
12
|
WebhookPlatformKeys["Polar"] = "polar";
|
|
13
13
|
WebhookPlatformKeys["Supabase"] = "supabase";
|
|
14
14
|
WebhookPlatformKeys["GitLab"] = "gitlab";
|
|
15
|
+
WebhookPlatformKeys["Paddle"] = "paddle";
|
|
16
|
+
WebhookPlatformKeys["Razorpay"] = "razorpay";
|
|
17
|
+
WebhookPlatformKeys["LemonSqueezy"] = "lemonsqueezy";
|
|
18
|
+
WebhookPlatformKeys["Auth0"] = "auth0";
|
|
19
|
+
WebhookPlatformKeys["WorkOS"] = "workos";
|
|
20
|
+
WebhookPlatformKeys["WooCommerce"] = "woocommerce";
|
|
21
|
+
WebhookPlatformKeys["ReplicateAI"] = "replicateai";
|
|
22
|
+
WebhookPlatformKeys["FalAI"] = "falai";
|
|
15
23
|
WebhookPlatformKeys["Custom"] = "custom";
|
|
16
24
|
WebhookPlatformKeys["Unknown"] = "unknown";
|
|
17
25
|
})(WebhookPlatformKeys || (exports.WebhookPlatformKeys = WebhookPlatformKeys = {}));
|
package/dist/utils.js
CHANGED
|
@@ -112,9 +112,34 @@ function detectPlatformFromHeaders(headers) {
|
|
|
112
112
|
return 'clerk';
|
|
113
113
|
}
|
|
114
114
|
// Dodo Payments
|
|
115
|
+
if (headerMap.has('workos-signature')) {
|
|
116
|
+
return 'workos';
|
|
117
|
+
}
|
|
115
118
|
if (headerMap.has('webhook-signature')) {
|
|
119
|
+
const userAgent = headerMap.get('user-agent') || '';
|
|
120
|
+
if (userAgent.includes('replicate')) {
|
|
121
|
+
return 'replicateai';
|
|
122
|
+
}
|
|
116
123
|
return 'dodopayments';
|
|
117
124
|
}
|
|
125
|
+
if (headerMap.has('paddle-signature')) {
|
|
126
|
+
return 'paddle';
|
|
127
|
+
}
|
|
128
|
+
if (headerMap.has('x-razorpay-signature')) {
|
|
129
|
+
return 'razorpay';
|
|
130
|
+
}
|
|
131
|
+
if (headerMap.has('x-signature')) {
|
|
132
|
+
return 'lemonsqueezy';
|
|
133
|
+
}
|
|
134
|
+
if (headerMap.has('x-auth0-signature')) {
|
|
135
|
+
return 'auth0';
|
|
136
|
+
}
|
|
137
|
+
if (headerMap.has('x-wc-webhook-signature')) {
|
|
138
|
+
return 'woocommerce';
|
|
139
|
+
}
|
|
140
|
+
if (headerMap.has('x-fal-signature') || headerMap.has('x-fal-webhook-signature')) {
|
|
141
|
+
return 'falai';
|
|
142
|
+
}
|
|
118
143
|
// Shopify
|
|
119
144
|
if (headerMap.has('x-shopify-hmac-sha256')) {
|
|
120
145
|
return 'shopify';
|
|
@@ -5,6 +5,7 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
|
|
|
5
5
|
protected platform: WebhookPlatform;
|
|
6
6
|
constructor(secret: string, config: SignatureConfig, platform: WebhookPlatform, toleranceInSeconds?: number);
|
|
7
7
|
abstract verify(request: Request): Promise<WebhookVerificationResult>;
|
|
8
|
+
protected parseDelimitedHeader(headerValue: string): Record<string, string>;
|
|
8
9
|
protected extractSignature(request: Request): string | null;
|
|
9
10
|
protected extractTimestamp(request: Request): number | null;
|
|
10
11
|
protected extractTimestampFromSignature(request: Request): number | null;
|
|
@@ -18,6 +19,11 @@ export declare abstract class AlgorithmBasedVerifier extends WebhookVerifier {
|
|
|
18
19
|
export declare class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
19
20
|
verify(request: Request): Promise<WebhookVerificationResult>;
|
|
20
21
|
}
|
|
22
|
+
export declare class Ed25519Verifier extends AlgorithmBasedVerifier {
|
|
23
|
+
private resolvePublicKey;
|
|
24
|
+
private buildFalPayload;
|
|
25
|
+
verify(request: Request): Promise<WebhookVerificationResult>;
|
|
26
|
+
}
|
|
21
27
|
export declare class HMACSHA256Verifier extends GenericHMACVerifier {
|
|
22
28
|
constructor(secret: string, config: SignatureConfig, platform?: WebhookPlatform, toleranceInSeconds?: number);
|
|
23
29
|
}
|
|
@@ -1,37 +1,49 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.HMACSHA512Verifier = exports.HMACSHA1Verifier = exports.HMACSHA256Verifier = exports.GenericHMACVerifier = exports.AlgorithmBasedVerifier = void 0;
|
|
3
|
+
exports.HMACSHA512Verifier = exports.HMACSHA1Verifier = exports.HMACSHA256Verifier = exports.Ed25519Verifier = exports.GenericHMACVerifier = exports.AlgorithmBasedVerifier = void 0;
|
|
4
4
|
exports.createAlgorithmVerifier = createAlgorithmVerifier;
|
|
5
5
|
const crypto_1 = require("crypto");
|
|
6
6
|
const base_1 = require("./base");
|
|
7
|
+
const ed25519KeyCache = new Map();
|
|
7
8
|
class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
8
9
|
constructor(secret, config, platform, toleranceInSeconds = 300) {
|
|
9
10
|
super(secret, toleranceInSeconds);
|
|
10
11
|
this.config = config;
|
|
11
12
|
this.platform = platform;
|
|
12
13
|
}
|
|
14
|
+
parseDelimitedHeader(headerValue) {
|
|
15
|
+
const parts = headerValue.split(/[;,]/);
|
|
16
|
+
const values = {};
|
|
17
|
+
for (const part of parts) {
|
|
18
|
+
const trimmed = part.trim();
|
|
19
|
+
if (!trimmed)
|
|
20
|
+
continue;
|
|
21
|
+
const equalIndex = trimmed.indexOf('=');
|
|
22
|
+
if (equalIndex === -1)
|
|
23
|
+
continue;
|
|
24
|
+
const key = trimmed.slice(0, equalIndex).trim();
|
|
25
|
+
const value = trimmed.slice(equalIndex + 1).trim();
|
|
26
|
+
if (key && value) {
|
|
27
|
+
values[key] = value;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return values;
|
|
31
|
+
}
|
|
13
32
|
extractSignature(request) {
|
|
14
33
|
const headerValue = request.headers.get(this.config.headerName);
|
|
15
34
|
if (!headerValue)
|
|
16
35
|
return null;
|
|
17
36
|
switch (this.config.headerFormat) {
|
|
18
37
|
case 'prefixed':
|
|
19
|
-
// For GitHub, return the full signature including prefix for comparison
|
|
20
38
|
return headerValue;
|
|
21
|
-
case 'comma-separated':
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const [key, value] = part.split('=');
|
|
27
|
-
if (key && value) {
|
|
28
|
-
sigMap[key] = value;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return sigMap.v1 || sigMap.signature || null;
|
|
39
|
+
case 'comma-separated': {
|
|
40
|
+
const sigMap = this.parseDelimitedHeader(headerValue);
|
|
41
|
+
const signatureKey = this.config.customConfig?.signatureKey || 'v1';
|
|
42
|
+
return sigMap[signatureKey] || sigMap.signature || sigMap.v1 || null;
|
|
43
|
+
}
|
|
32
44
|
case 'raw':
|
|
33
45
|
default:
|
|
34
|
-
if (this.
|
|
46
|
+
if (this.config.customConfig?.signatureFormat?.includes('v1=')) {
|
|
35
47
|
const signatures = headerValue.split(' ');
|
|
36
48
|
for (const sig of signatures) {
|
|
37
49
|
const [version, signature] = sig.split(',');
|
|
@@ -56,37 +68,29 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
56
68
|
case 'iso':
|
|
57
69
|
return Math.floor(new Date(timestampHeader).getTime() / 1000);
|
|
58
70
|
case 'custom':
|
|
59
|
-
// Custom timestamp parsing logic can be added here
|
|
60
71
|
return parseInt(timestampHeader, 10);
|
|
61
72
|
default:
|
|
62
73
|
return parseInt(timestampHeader, 10);
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
extractTimestampFromSignature(request) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const headerValue = request.headers.get(this.config.headerName);
|
|
69
|
-
if (!headerValue)
|
|
70
|
-
return null;
|
|
71
|
-
const parts = headerValue.split(',');
|
|
72
|
-
const sigMap = {};
|
|
73
|
-
for (const part of parts) {
|
|
74
|
-
const [key, value] = part.split('=');
|
|
75
|
-
if (key && value) {
|
|
76
|
-
sigMap[key] = value;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return sigMap.t ? parseInt(sigMap.t, 10) : null;
|
|
77
|
+
if (this.config.headerFormat !== 'comma-separated') {
|
|
78
|
+
return null;
|
|
80
79
|
}
|
|
81
|
-
|
|
80
|
+
const headerValue = request.headers.get(this.config.headerName);
|
|
81
|
+
if (!headerValue)
|
|
82
|
+
return null;
|
|
83
|
+
const sigMap = this.parseDelimitedHeader(headerValue);
|
|
84
|
+
const timestampKey = this.config.customConfig?.timestampKey || 't';
|
|
85
|
+
return sigMap[timestampKey] ? parseInt(sigMap[timestampKey], 10) : null;
|
|
82
86
|
}
|
|
83
87
|
formatPayload(rawBody, request) {
|
|
84
88
|
switch (this.config.payloadFormat) {
|
|
85
|
-
case 'timestamped':
|
|
86
|
-
// For Stripe, timestamp is embedded in signature
|
|
89
|
+
case 'timestamped': {
|
|
87
90
|
const timestamp = this.extractTimestampFromSignature(request)
|
|
88
91
|
|| this.extractTimestamp(request);
|
|
89
92
|
return timestamp ? `${timestamp}.${rawBody}` : rawBody;
|
|
93
|
+
}
|
|
90
94
|
case 'custom':
|
|
91
95
|
return this.formatCustomPayload(rawBody, request);
|
|
92
96
|
case 'raw':
|
|
@@ -99,7 +103,6 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
99
103
|
return rawBody;
|
|
100
104
|
}
|
|
101
105
|
const customFormat = this.config.customConfig.payloadFormat;
|
|
102
|
-
// Handle Clerk-style format: {id}.{timestamp}.{body}
|
|
103
106
|
if (customFormat.includes('{id}') && customFormat.includes('{timestamp}')) {
|
|
104
107
|
const id = request.headers.get(this.config.customConfig.idHeader || 'x-webhook-id');
|
|
105
108
|
const timestamp = request.headers.get(this.config.timestampHeader || 'x-webhook-timestamp');
|
|
@@ -108,10 +111,10 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
108
111
|
.replace('{timestamp}', timestamp || '')
|
|
109
112
|
.replace('{body}', rawBody);
|
|
110
113
|
}
|
|
111
|
-
// Handle Stripe-style format: {timestamp}.{body}
|
|
112
114
|
if (customFormat.includes('{timestamp}')
|
|
113
115
|
&& customFormat.includes('{body}')) {
|
|
114
|
-
const timestamp = this.
|
|
116
|
+
const timestamp = this.extractTimestampFromSignature(request)
|
|
117
|
+
|| this.extractTimestamp(request);
|
|
115
118
|
return customFormat
|
|
116
119
|
.replace('{timestamp}', timestamp?.toString() || '')
|
|
117
120
|
.replace('{body}', rawBody);
|
|
@@ -131,9 +134,11 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
131
134
|
return this.safeCompare(signature, expectedSignature);
|
|
132
135
|
}
|
|
133
136
|
verifyHMACWithBase64(payload, signature, algorithm = 'sha256') {
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
+
const secretEncoding = this.config.customConfig?.secretEncoding || 'base64';
|
|
138
|
+
const secretMaterial = secretEncoding === 'base64'
|
|
139
|
+
? new Uint8Array(Buffer.from(this.secret.includes('_') ? this.secret.split('_')[1] : this.secret, 'base64'))
|
|
140
|
+
: this.secret;
|
|
141
|
+
const hmac = (0, crypto_1.createHmac)(algorithm, secretMaterial);
|
|
137
142
|
hmac.update(payload);
|
|
138
143
|
const expectedSignature = hmac.digest('base64');
|
|
139
144
|
return this.safeCompare(signature, expectedSignature);
|
|
@@ -142,41 +147,38 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
142
147
|
const metadata = {
|
|
143
148
|
algorithm: this.config.algorithm,
|
|
144
149
|
};
|
|
145
|
-
|
|
146
|
-
const timestamp = this.extractTimestamp(request);
|
|
150
|
+
const timestamp = this.extractTimestamp(request) || this.extractTimestampFromSignature(request);
|
|
147
151
|
if (timestamp) {
|
|
148
152
|
metadata.timestamp = timestamp.toString();
|
|
149
153
|
}
|
|
150
|
-
// Add platform-specific metadata
|
|
151
154
|
switch (this.platform) {
|
|
152
155
|
case 'github':
|
|
153
156
|
metadata.event = request.headers.get('x-github-event');
|
|
154
157
|
metadata.delivery = request.headers.get('x-github-delivery');
|
|
155
158
|
break;
|
|
156
|
-
case 'stripe':
|
|
157
|
-
// Extract Stripe-specific metadata from signature
|
|
159
|
+
case 'stripe': {
|
|
158
160
|
const headerValue = request.headers.get(this.config.headerName);
|
|
159
161
|
if (headerValue && this.config.headerFormat === 'comma-separated') {
|
|
160
|
-
const
|
|
161
|
-
const sigMap = {};
|
|
162
|
-
for (const part of parts) {
|
|
163
|
-
const [key, value] = part.split('=');
|
|
164
|
-
if (key && value) {
|
|
165
|
-
sigMap[key] = value;
|
|
166
|
-
}
|
|
167
|
-
}
|
|
162
|
+
const sigMap = this.parseDelimitedHeader(headerValue);
|
|
168
163
|
metadata.id = sigMap.id;
|
|
169
164
|
}
|
|
170
165
|
break;
|
|
166
|
+
}
|
|
171
167
|
case 'clerk':
|
|
172
|
-
|
|
168
|
+
case 'dodopayments':
|
|
169
|
+
case 'replicateai':
|
|
170
|
+
metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
|
|
171
|
+
break;
|
|
172
|
+
case 'workos':
|
|
173
|
+
metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
|
|
174
|
+
break;
|
|
175
|
+
default:
|
|
173
176
|
break;
|
|
174
177
|
}
|
|
175
178
|
return metadata;
|
|
176
179
|
}
|
|
177
180
|
}
|
|
178
181
|
exports.AlgorithmBasedVerifier = AlgorithmBasedVerifier;
|
|
179
|
-
// Generic HMAC Verifier that handles all HMAC-based algorithms
|
|
180
182
|
class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
181
183
|
async verify(request) {
|
|
182
184
|
try {
|
|
@@ -190,17 +192,13 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
190
192
|
};
|
|
191
193
|
}
|
|
192
194
|
const rawBody = await request.text();
|
|
193
|
-
// Extract timestamp based on platform configuration
|
|
194
195
|
let timestamp = null;
|
|
195
196
|
if (this.config.headerFormat === 'comma-separated') {
|
|
196
|
-
// For platforms like Stripe where timestamp is embedded in signature
|
|
197
197
|
timestamp = this.extractTimestampFromSignature(request);
|
|
198
198
|
}
|
|
199
199
|
else {
|
|
200
|
-
// For platforms with separate timestamp header
|
|
201
200
|
timestamp = this.extractTimestamp(request);
|
|
202
201
|
}
|
|
203
|
-
// Validate timestamp if required
|
|
204
202
|
if (timestamp && !this.isTimestampValid(timestamp)) {
|
|
205
203
|
return {
|
|
206
204
|
isValid: false,
|
|
@@ -209,21 +207,16 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
209
207
|
platform: this.platform,
|
|
210
208
|
};
|
|
211
209
|
}
|
|
212
|
-
// Format payload according to platform requirements
|
|
213
210
|
const payload = this.formatPayload(rawBody, request);
|
|
214
|
-
// Verify signature based on platform configuration
|
|
215
211
|
let isValid = false;
|
|
216
212
|
const algorithm = this.config.algorithm.replace('hmac-', '');
|
|
217
213
|
if (this.config.customConfig?.encoding === 'base64') {
|
|
218
|
-
// For platforms like Clerk that use base64 encoding
|
|
219
214
|
isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
|
|
220
215
|
}
|
|
221
216
|
else if (this.config.headerFormat === 'prefixed') {
|
|
222
|
-
// For platforms like GitHub that use prefixed signatures
|
|
223
217
|
isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
|
|
224
218
|
}
|
|
225
219
|
else {
|
|
226
|
-
// Standard HMAC verification
|
|
227
220
|
isValid = this.verifyHMAC(payload, signature, algorithm);
|
|
228
221
|
}
|
|
229
222
|
if (!isValid) {
|
|
@@ -234,7 +227,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
234
227
|
platform: this.platform,
|
|
235
228
|
};
|
|
236
229
|
}
|
|
237
|
-
// Parse payload
|
|
238
230
|
let parsedPayload;
|
|
239
231
|
try {
|
|
240
232
|
parsedPayload = JSON.parse(rawBody);
|
|
@@ -242,7 +234,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
242
234
|
catch (e) {
|
|
243
235
|
parsedPayload = rawBody;
|
|
244
236
|
}
|
|
245
|
-
// Extract platform-specific metadata
|
|
246
237
|
const metadata = this.extractMetadata(request);
|
|
247
238
|
return {
|
|
248
239
|
isValid: true,
|
|
@@ -262,7 +253,115 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
262
253
|
}
|
|
263
254
|
}
|
|
264
255
|
exports.GenericHMACVerifier = GenericHMACVerifier;
|
|
265
|
-
|
|
256
|
+
class Ed25519Verifier extends AlgorithmBasedVerifier {
|
|
257
|
+
async resolvePublicKey(request) {
|
|
258
|
+
const configPublicKey = this.config.customConfig?.publicKey;
|
|
259
|
+
if (configPublicKey) {
|
|
260
|
+
return configPublicKey;
|
|
261
|
+
}
|
|
262
|
+
if (this.secret && this.secret.trim().length > 0) {
|
|
263
|
+
return this.secret;
|
|
264
|
+
}
|
|
265
|
+
const jwksUrl = this.config.customConfig?.jwksUrl;
|
|
266
|
+
const kidHeader = this.config.customConfig?.kidHeader;
|
|
267
|
+
const kid = kidHeader ? request.headers.get(kidHeader) : null;
|
|
268
|
+
if (!jwksUrl || !kid) {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
const cacheKey = `${jwksUrl}:${kid}`;
|
|
272
|
+
if (ed25519KeyCache.has(cacheKey)) {
|
|
273
|
+
return ed25519KeyCache.get(cacheKey);
|
|
274
|
+
}
|
|
275
|
+
const response = await fetch(jwksUrl);
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const body = await response.json();
|
|
280
|
+
const key = body.keys?.find((entry) => entry.kid === kid);
|
|
281
|
+
if (!key) {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
const keyObject = (0, crypto_1.createPublicKey)({ key, format: 'jwk' });
|
|
285
|
+
const pem = keyObject.export({ type: 'spki', format: 'pem' }).toString();
|
|
286
|
+
ed25519KeyCache.set(cacheKey, pem);
|
|
287
|
+
return pem;
|
|
288
|
+
}
|
|
289
|
+
buildFalPayload(rawBody, request) {
|
|
290
|
+
const requestIdHeader = this.config.customConfig?.requestIdHeader || 'x-fal-request-id';
|
|
291
|
+
const userIdHeader = this.config.customConfig?.userIdHeader || 'x-fal-user-id';
|
|
292
|
+
const timestampHeader = this.config.customConfig?.timestampHeader || 'x-fal-webhook-timestamp';
|
|
293
|
+
const requestId = request.headers.get(requestIdHeader) || '';
|
|
294
|
+
const userId = request.headers.get(userIdHeader) || '';
|
|
295
|
+
const timestamp = request.headers.get(timestampHeader) || '';
|
|
296
|
+
const bodyHash = (0, crypto_1.createHash)('sha256').update(rawBody).digest('hex');
|
|
297
|
+
return `${requestId}.${userId}.${timestamp}.${bodyHash}`;
|
|
298
|
+
}
|
|
299
|
+
async verify(request) {
|
|
300
|
+
try {
|
|
301
|
+
const signature = this.extractSignature(request);
|
|
302
|
+
if (!signature) {
|
|
303
|
+
return {
|
|
304
|
+
isValid: false,
|
|
305
|
+
error: `Missing signature header: ${this.config.headerName}`,
|
|
306
|
+
errorCode: 'MISSING_SIGNATURE',
|
|
307
|
+
platform: this.platform,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const rawBody = await request.text();
|
|
311
|
+
const payload = this.platform === 'falai'
|
|
312
|
+
? this.buildFalPayload(rawBody, request)
|
|
313
|
+
: this.formatPayload(rawBody, request);
|
|
314
|
+
const publicKey = await this.resolvePublicKey(request);
|
|
315
|
+
if (!publicKey) {
|
|
316
|
+
return {
|
|
317
|
+
isValid: false,
|
|
318
|
+
error: 'Missing public key for ED25519 verification',
|
|
319
|
+
errorCode: 'VERIFICATION_ERROR',
|
|
320
|
+
platform: this.platform,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
const signatureBytes = new Uint8Array(Buffer.from(signature, 'base64'));
|
|
324
|
+
const keyObject = (0, crypto_1.createPublicKey)(publicKey);
|
|
325
|
+
const payloadBytes = new Uint8Array(Buffer.from(payload));
|
|
326
|
+
const isValid = (0, crypto_1.verify)(null, payloadBytes, keyObject, signatureBytes);
|
|
327
|
+
if (!isValid) {
|
|
328
|
+
return {
|
|
329
|
+
isValid: false,
|
|
330
|
+
error: 'Invalid signature',
|
|
331
|
+
errorCode: 'INVALID_SIGNATURE',
|
|
332
|
+
platform: this.platform,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
let parsedPayload;
|
|
336
|
+
try {
|
|
337
|
+
parsedPayload = JSON.parse(rawBody);
|
|
338
|
+
}
|
|
339
|
+
catch (e) {
|
|
340
|
+
parsedPayload = rawBody;
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
isValid: true,
|
|
344
|
+
platform: this.platform,
|
|
345
|
+
payload: parsedPayload,
|
|
346
|
+
metadata: {
|
|
347
|
+
algorithm: this.config.algorithm,
|
|
348
|
+
requestId: request.headers.get(this.config.customConfig?.requestIdHeader || 'x-fal-request-id'),
|
|
349
|
+
userId: request.headers.get(this.config.customConfig?.userIdHeader || 'x-fal-user-id'),
|
|
350
|
+
timestamp: request.headers.get(this.config.customConfig?.timestampHeader || 'x-fal-webhook-timestamp') || undefined,
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
return {
|
|
356
|
+
isValid: false,
|
|
357
|
+
error: `${this.platform} verification error: ${error.message}`,
|
|
358
|
+
errorCode: 'VERIFICATION_ERROR',
|
|
359
|
+
platform: this.platform,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
exports.Ed25519Verifier = Ed25519Verifier;
|
|
266
365
|
class HMACSHA256Verifier extends GenericHMACVerifier {
|
|
267
366
|
constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
|
|
268
367
|
super(secret, config, platform, toleranceInSeconds);
|
|
@@ -281,17 +380,16 @@ class HMACSHA512Verifier extends GenericHMACVerifier {
|
|
|
281
380
|
}
|
|
282
381
|
}
|
|
283
382
|
exports.HMACSHA512Verifier = HMACSHA512Verifier;
|
|
284
|
-
// Factory function to create verifiers based on algorithm
|
|
285
383
|
function createAlgorithmVerifier(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
|
|
286
384
|
switch (config.algorithm) {
|
|
287
385
|
case 'hmac-sha256':
|
|
288
386
|
case 'hmac-sha1':
|
|
289
387
|
case 'hmac-sha512':
|
|
290
388
|
return new GenericHMACVerifier(secret, config, platform, toleranceInSeconds);
|
|
291
|
-
case 'rsa-sha256':
|
|
292
389
|
case 'ed25519':
|
|
390
|
+
return new Ed25519Verifier(secret, config, platform, toleranceInSeconds);
|
|
391
|
+
case 'rsa-sha256':
|
|
293
392
|
case 'custom':
|
|
294
|
-
// These can be implemented as needed
|
|
295
393
|
throw new Error(`Algorithm ${config.algorithm} not yet implemented`);
|
|
296
394
|
default:
|
|
297
395
|
throw new Error(`Unknown algorithm: ${config.algorithm}`);
|
package/package.json
CHANGED