@hookflo/tern 2.0.3-experimental.0 → 2.2.3
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 +61 -2
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/express.js +1 -1
- package/dist/adapters/nextjs.js +1 -1
- package/dist/index.js +37 -3
- package/dist/platforms/algorithms.js +130 -7
- package/dist/test.js +241 -0
- package/dist/types.d.ts +9 -1
- package/dist/types.js +8 -0
- package/dist/utils.js +31 -0
- package/dist/verifiers/algorithms.d.ts +6 -0
- package/dist/verifiers/algorithms.js +170 -68
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -210,7 +210,15 @@ const result = await WebhookVerificationService.verify(request, stripeConfig);
|
|
|
210
210
|
|
|
211
211
|
### Other Platforms
|
|
212
212
|
- **Dodo Payments**: HMAC-SHA256
|
|
213
|
-
- **
|
|
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`)
|
|
221
|
+
- **Shopify**: HMAC-SHA256 (base64 signature)
|
|
214
222
|
- **Vercel**: HMAC-SHA256
|
|
215
223
|
- **Polar**: HMAC-SHA256
|
|
216
224
|
- **Supabase**: Token-based authentication
|
|
@@ -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
|
-
|
|
@@ -12,7 +12,7 @@ function createWebhookHandler(options) {
|
|
|
12
12
|
}
|
|
13
13
|
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, options.platform, secret, options.toleranceInSeconds, options.normalize);
|
|
14
14
|
if (!result.isValid) {
|
|
15
|
-
return Response.json({ error: result.error, platform: result.platform }, { status: 400 });
|
|
15
|
+
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
|
|
16
16
|
}
|
|
17
17
|
const data = await options.handler(result.payload, env, result.metadata || {});
|
|
18
18
|
return Response.json(data);
|
package/dist/adapters/express.js
CHANGED
|
@@ -9,7 +9,7 @@ function createWebhookMiddleware(options) {
|
|
|
9
9
|
const webRequest = await (0, shared_1.toWebRequest)(req);
|
|
10
10
|
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(webRequest, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
|
|
11
11
|
if (!result.isValid) {
|
|
12
|
-
res.status(400).json({ error: result.error, platform: result.platform });
|
|
12
|
+
res.status(400).json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata });
|
|
13
13
|
return;
|
|
14
14
|
}
|
|
15
15
|
req.webhook = result;
|
package/dist/adapters/nextjs.js
CHANGED
|
@@ -7,7 +7,7 @@ function createWebhookHandler(options) {
|
|
|
7
7
|
try {
|
|
8
8
|
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, options.platform, options.secret, options.toleranceInSeconds, options.normalize);
|
|
9
9
|
if (!result.isValid) {
|
|
10
|
-
return Response.json({ error: result.error, platform: result.platform }, { status: 400 });
|
|
10
|
+
return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 });
|
|
11
11
|
}
|
|
12
12
|
const data = await options.handler(result.payload, result.metadata || {});
|
|
13
13
|
return Response.json(data);
|
package/dist/index.js
CHANGED
|
@@ -79,6 +79,7 @@ class WebhookVerificationService {
|
|
|
79
79
|
if (detectedPlatform !== 'unknown' && secrets[detectedPlatform]) {
|
|
80
80
|
return this.verifyWithPlatformConfig(requestClone, detectedPlatform, secrets[detectedPlatform], toleranceInSeconds, normalize);
|
|
81
81
|
}
|
|
82
|
+
const failedAttempts = [];
|
|
82
83
|
for (const [platform, secret] of Object.entries(secrets)) {
|
|
83
84
|
if (!secret) {
|
|
84
85
|
continue;
|
|
@@ -87,12 +88,25 @@ class WebhookVerificationService {
|
|
|
87
88
|
if (result.isValid) {
|
|
88
89
|
return result;
|
|
89
90
|
}
|
|
91
|
+
failedAttempts.push({
|
|
92
|
+
platform: platform.toLowerCase(),
|
|
93
|
+
error: result.error,
|
|
94
|
+
errorCode: result.errorCode,
|
|
95
|
+
});
|
|
90
96
|
}
|
|
97
|
+
const details = failedAttempts
|
|
98
|
+
.map((attempt) => `${attempt.platform}: ${attempt.error || 'verification failed'}`)
|
|
99
|
+
.join('; ');
|
|
91
100
|
return {
|
|
92
101
|
isValid: false,
|
|
93
|
-
error:
|
|
94
|
-
|
|
102
|
+
error: details
|
|
103
|
+
? `Unable to verify webhook with provided platform secrets. Attempts -> ${details}`
|
|
104
|
+
: 'Unable to verify webhook with provided platform secrets',
|
|
105
|
+
errorCode: failedAttempts.find((attempt) => attempt.errorCode)?.errorCode || 'VERIFICATION_ERROR',
|
|
95
106
|
platform: detectedPlatform,
|
|
107
|
+
metadata: {
|
|
108
|
+
attempts: failedAttempts,
|
|
109
|
+
},
|
|
96
110
|
};
|
|
97
111
|
}
|
|
98
112
|
static detectPlatform(request) {
|
|
@@ -103,12 +117,32 @@ class WebhookVerificationService {
|
|
|
103
117
|
return 'github';
|
|
104
118
|
if (headers.has('svix-signature'))
|
|
105
119
|
return 'clerk';
|
|
106
|
-
if (headers.has('
|
|
120
|
+
if (headers.has('workos-signature'))
|
|
121
|
+
return 'workos';
|
|
122
|
+
if (headers.has('webhook-signature')) {
|
|
123
|
+
const userAgent = headers.get('user-agent')?.toLowerCase() || '';
|
|
124
|
+
if (userAgent.includes('polar'))
|
|
125
|
+
return 'polar';
|
|
126
|
+
if (userAgent.includes('replicate'))
|
|
127
|
+
return 'replicateai';
|
|
107
128
|
return 'dodopayments';
|
|
129
|
+
}
|
|
108
130
|
if (headers.has('x-gitlab-token'))
|
|
109
131
|
return 'gitlab';
|
|
110
132
|
if (headers.has('x-polar-signature'))
|
|
111
133
|
return 'polar';
|
|
134
|
+
if (headers.has('paddle-signature'))
|
|
135
|
+
return 'paddle';
|
|
136
|
+
if (headers.has('x-razorpay-signature'))
|
|
137
|
+
return 'razorpay';
|
|
138
|
+
if (headers.has('x-signature'))
|
|
139
|
+
return 'lemonsqueezy';
|
|
140
|
+
if (headers.has('x-auth0-signature'))
|
|
141
|
+
return 'auth0';
|
|
142
|
+
if (headers.has('x-wc-webhook-signature'))
|
|
143
|
+
return 'woocommerce';
|
|
144
|
+
if (headers.has('x-fal-signature') || headers.has('x-fal-webhook-signature'))
|
|
145
|
+
return 'falai';
|
|
112
146
|
if (headers.has('x-shopify-hmac-sha256'))
|
|
113
147
|
return 'shopify';
|
|
114
148
|
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
|
},
|
|
@@ -74,10 +76,13 @@ exports.platformAlgorithmConfigs = {
|
|
|
74
76
|
algorithm: 'hmac-sha256',
|
|
75
77
|
headerName: 'x-shopify-hmac-sha256',
|
|
76
78
|
headerFormat: 'raw',
|
|
77
|
-
timestampHeader: 'x-shopify-shop-domain',
|
|
78
79
|
payloadFormat: 'raw',
|
|
80
|
+
customConfig: {
|
|
81
|
+
encoding: 'base64',
|
|
82
|
+
secretEncoding: 'utf8',
|
|
83
|
+
},
|
|
79
84
|
},
|
|
80
|
-
description: 'Shopify webhooks use HMAC-SHA256',
|
|
85
|
+
description: 'Shopify webhooks use HMAC-SHA256 with base64 encoded signature',
|
|
81
86
|
},
|
|
82
87
|
vercel: {
|
|
83
88
|
platform: 'vercel',
|
|
@@ -95,13 +100,20 @@ exports.platformAlgorithmConfigs = {
|
|
|
95
100
|
platform: 'polar',
|
|
96
101
|
signatureConfig: {
|
|
97
102
|
algorithm: 'hmac-sha256',
|
|
98
|
-
headerName: '
|
|
103
|
+
headerName: 'webhook-signature',
|
|
99
104
|
headerFormat: 'raw',
|
|
100
|
-
timestampHeader: '
|
|
105
|
+
timestampHeader: 'webhook-timestamp',
|
|
101
106
|
timestampFormat: 'unix',
|
|
102
|
-
payloadFormat: '
|
|
107
|
+
payloadFormat: 'custom',
|
|
108
|
+
customConfig: {
|
|
109
|
+
signatureFormat: 'v1={signature}',
|
|
110
|
+
payloadFormat: '{id}.{timestamp}.{body}',
|
|
111
|
+
encoding: 'base64',
|
|
112
|
+
secretEncoding: 'base64',
|
|
113
|
+
idHeader: 'webhook-id',
|
|
114
|
+
},
|
|
103
115
|
},
|
|
104
|
-
description: 'Polar webhooks use HMAC-SHA256',
|
|
116
|
+
description: 'Polar webhooks use HMAC-SHA256 with Standard Webhooks format',
|
|
105
117
|
},
|
|
106
118
|
supabase: {
|
|
107
119
|
platform: 'supabase',
|
|
@@ -131,6 +143,116 @@ exports.platformAlgorithmConfigs = {
|
|
|
131
143
|
},
|
|
132
144
|
description: 'GitLab webhooks use HMAC-SHA256 with X-Gitlab-Token header',
|
|
133
145
|
},
|
|
146
|
+
paddle: {
|
|
147
|
+
platform: 'paddle',
|
|
148
|
+
signatureConfig: {
|
|
149
|
+
algorithm: 'hmac-sha256',
|
|
150
|
+
headerName: 'paddle-signature',
|
|
151
|
+
headerFormat: 'comma-separated',
|
|
152
|
+
payloadFormat: 'custom',
|
|
153
|
+
customConfig: {
|
|
154
|
+
timestampKey: 'ts',
|
|
155
|
+
signatureKey: 'h1',
|
|
156
|
+
payloadFormat: '{timestamp}:{body}',
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
description: 'Paddle webhooks use HMAC-SHA256 with Paddle-Signature (ts/h1) header format',
|
|
160
|
+
},
|
|
161
|
+
razorpay: {
|
|
162
|
+
platform: 'razorpay',
|
|
163
|
+
signatureConfig: {
|
|
164
|
+
algorithm: 'hmac-sha256',
|
|
165
|
+
headerName: 'x-razorpay-signature',
|
|
166
|
+
headerFormat: 'raw',
|
|
167
|
+
payloadFormat: 'raw',
|
|
168
|
+
},
|
|
169
|
+
description: 'Razorpay webhooks use HMAC-SHA256 with X-Razorpay-Signature header',
|
|
170
|
+
},
|
|
171
|
+
lemonsqueezy: {
|
|
172
|
+
platform: 'lemonsqueezy',
|
|
173
|
+
signatureConfig: {
|
|
174
|
+
algorithm: 'hmac-sha256',
|
|
175
|
+
headerName: 'x-signature',
|
|
176
|
+
headerFormat: 'raw',
|
|
177
|
+
payloadFormat: 'raw',
|
|
178
|
+
},
|
|
179
|
+
description: 'Lemon Squeezy webhooks use HMAC-SHA256 with X-Signature header',
|
|
180
|
+
},
|
|
181
|
+
auth0: {
|
|
182
|
+
platform: 'auth0',
|
|
183
|
+
signatureConfig: {
|
|
184
|
+
algorithm: 'hmac-sha256',
|
|
185
|
+
headerName: 'x-auth0-signature',
|
|
186
|
+
headerFormat: 'raw',
|
|
187
|
+
payloadFormat: 'raw',
|
|
188
|
+
},
|
|
189
|
+
description: 'Auth0 webhooks use HMAC-SHA256 with X-Auth0-Signature header',
|
|
190
|
+
},
|
|
191
|
+
workos: {
|
|
192
|
+
platform: 'workos',
|
|
193
|
+
signatureConfig: {
|
|
194
|
+
algorithm: 'hmac-sha256',
|
|
195
|
+
headerName: 'workos-signature',
|
|
196
|
+
headerFormat: 'comma-separated',
|
|
197
|
+
payloadFormat: 'custom',
|
|
198
|
+
customConfig: {
|
|
199
|
+
timestampKey: 't',
|
|
200
|
+
signatureKey: 'v1',
|
|
201
|
+
payloadFormat: '{timestamp}.{body}',
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
description: 'WorkOS webhooks use HMAC-SHA256 with WorkOS-Signature (t/v1) format',
|
|
205
|
+
},
|
|
206
|
+
woocommerce: {
|
|
207
|
+
platform: 'woocommerce',
|
|
208
|
+
signatureConfig: {
|
|
209
|
+
algorithm: 'hmac-sha256',
|
|
210
|
+
headerName: 'x-wc-webhook-signature',
|
|
211
|
+
headerFormat: 'raw',
|
|
212
|
+
payloadFormat: 'raw',
|
|
213
|
+
customConfig: {
|
|
214
|
+
encoding: 'base64',
|
|
215
|
+
secretEncoding: 'utf8',
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
description: 'WooCommerce webhooks use HMAC-SHA256 with base64 encoded signature',
|
|
219
|
+
},
|
|
220
|
+
replicateai: {
|
|
221
|
+
platform: 'replicateai',
|
|
222
|
+
signatureConfig: {
|
|
223
|
+
algorithm: 'hmac-sha256',
|
|
224
|
+
headerName: 'webhook-signature',
|
|
225
|
+
headerFormat: 'raw',
|
|
226
|
+
timestampHeader: 'webhook-timestamp',
|
|
227
|
+
timestampFormat: 'unix',
|
|
228
|
+
payloadFormat: 'custom',
|
|
229
|
+
customConfig: {
|
|
230
|
+
signatureFormat: 'v1={signature}',
|
|
231
|
+
payloadFormat: '{id}.{timestamp}.{body}',
|
|
232
|
+
encoding: 'base64',
|
|
233
|
+
secretEncoding: 'base64',
|
|
234
|
+
idHeader: 'webhook-id',
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
description: 'Replicate webhooks use HMAC-SHA256 with Standard Webhooks (svix-style) format',
|
|
238
|
+
},
|
|
239
|
+
falai: {
|
|
240
|
+
platform: 'falai',
|
|
241
|
+
signatureConfig: {
|
|
242
|
+
algorithm: 'ed25519',
|
|
243
|
+
headerName: 'x-fal-webhook-signature',
|
|
244
|
+
headerFormat: 'raw',
|
|
245
|
+
payloadFormat: 'custom',
|
|
246
|
+
customConfig: {
|
|
247
|
+
requestIdHeader: 'x-fal-request-id',
|
|
248
|
+
userIdHeader: 'x-fal-user-id',
|
|
249
|
+
timestampHeader: 'x-fal-webhook-timestamp',
|
|
250
|
+
kidHeader: 'x-fal-webhook-key-id',
|
|
251
|
+
jwksUrl: 'https://rest.alpha.fal.ai/.well-known/jwks.json',
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
description: 'fal.ai webhooks use ED25519 with a signed request/user/timestamp/body-hash payload',
|
|
255
|
+
},
|
|
134
256
|
custom: {
|
|
135
257
|
platform: 'custom',
|
|
136
258
|
signatureConfig: {
|
|
@@ -178,8 +300,9 @@ function validateSignatureConfig(config) {
|
|
|
178
300
|
case 'hmac-sha512':
|
|
179
301
|
return true;
|
|
180
302
|
case 'rsa-sha256':
|
|
181
|
-
case 'ed25519':
|
|
182
303
|
return !!config.customConfig?.publicKey;
|
|
304
|
+
case 'ed25519':
|
|
305
|
+
return !!config.customConfig?.publicKey || !!config.customConfig?.jwksUrl;
|
|
183
306
|
case 'custom':
|
|
184
307
|
return !!config.customConfig;
|
|
185
308
|
default:
|
package/dist/test.js
CHANGED
|
@@ -35,6 +35,40 @@ 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 createShopifySignature(body, secret) {
|
|
53
|
+
const hmac = (0, crypto_1.createHmac)('sha256', secret);
|
|
54
|
+
hmac.update(body);
|
|
55
|
+
return hmac.digest('base64');
|
|
56
|
+
}
|
|
57
|
+
function createWooCommerceSignature(body, secret) {
|
|
58
|
+
const hmac = (0, crypto_1.createHmac)('sha256', secret);
|
|
59
|
+
hmac.update(body);
|
|
60
|
+
return hmac.digest('base64');
|
|
61
|
+
}
|
|
62
|
+
function createWorkOSSignature(body, secret, timestamp) {
|
|
63
|
+
const signedPayload = `${timestamp}.${body}`;
|
|
64
|
+
const hmac = (0, crypto_1.createHmac)('sha256', secret);
|
|
65
|
+
hmac.update(signedPayload);
|
|
66
|
+
return `t=${timestamp},v1=${hmac.digest('hex')}`;
|
|
67
|
+
}
|
|
68
|
+
function createFalPayloadToSign(body, requestId, userId, timestamp) {
|
|
69
|
+
const bodyHash = (0, crypto_1.createHash)('sha256').update(body).digest('hex');
|
|
70
|
+
return `${requestId}.${userId}.${timestamp}.${bodyHash}`;
|
|
71
|
+
}
|
|
38
72
|
async function runTests() {
|
|
39
73
|
console.log('🧪 Running Webhook Verification Tests...\n');
|
|
40
74
|
// Test 1: Stripe Webhook
|
|
@@ -248,6 +282,24 @@ async function runTests() {
|
|
|
248
282
|
catch (error) {
|
|
249
283
|
console.log(' ❌ verifyAny test failed:', error);
|
|
250
284
|
}
|
|
285
|
+
// Test 10.5: verifyAny error diagnostics
|
|
286
|
+
console.log('\n10.5. Testing verifyAny error diagnostics...');
|
|
287
|
+
try {
|
|
288
|
+
const unknownRequest = createMockRequest({
|
|
289
|
+
'content-type': 'application/json',
|
|
290
|
+
});
|
|
291
|
+
const invalidVerifyAny = await index_1.WebhookVerificationService.verifyAny(unknownRequest, {
|
|
292
|
+
stripe: testSecret,
|
|
293
|
+
shopify: testSecret,
|
|
294
|
+
});
|
|
295
|
+
const hasDetailedErrors = Boolean(invalidVerifyAny.error
|
|
296
|
+
&& invalidVerifyAny.error.includes('Attempts ->')
|
|
297
|
+
&& invalidVerifyAny.metadata?.attempts?.length === 2);
|
|
298
|
+
console.log(' ✅ verifyAny diagnostics:', hasDetailedErrors ? 'PASSED' : 'FAILED');
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
console.log(' ❌ verifyAny diagnostics test failed:', error);
|
|
302
|
+
}
|
|
251
303
|
// Test 11: Normalization for Stripe
|
|
252
304
|
console.log('\n11. Testing payload normalization...');
|
|
253
305
|
try {
|
|
@@ -289,6 +341,195 @@ async function runTests() {
|
|
|
289
341
|
catch (error) {
|
|
290
342
|
console.log(' ❌ Category registry test failed:', error);
|
|
291
343
|
}
|
|
344
|
+
// Test 13: Razorpay
|
|
345
|
+
console.log('\n13. Testing Razorpay webhook...');
|
|
346
|
+
try {
|
|
347
|
+
const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
|
|
348
|
+
hmac.update(testBody);
|
|
349
|
+
const signature = hmac.digest('hex');
|
|
350
|
+
const request = createMockRequest({
|
|
351
|
+
'x-razorpay-signature': signature,
|
|
352
|
+
'content-type': 'application/json',
|
|
353
|
+
});
|
|
354
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'razorpay', testSecret);
|
|
355
|
+
console.log(' ✅ Razorpay:', result.isValid ? 'PASSED' : 'FAILED');
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
console.log(' ❌ Razorpay test failed:', error);
|
|
359
|
+
}
|
|
360
|
+
// Test 14: Lemon Squeezy
|
|
361
|
+
console.log('\n14. Testing Lemon Squeezy webhook...');
|
|
362
|
+
try {
|
|
363
|
+
const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
|
|
364
|
+
hmac.update(testBody);
|
|
365
|
+
const signature = hmac.digest('hex');
|
|
366
|
+
const request = createMockRequest({
|
|
367
|
+
'x-signature': signature,
|
|
368
|
+
'content-type': 'application/json',
|
|
369
|
+
});
|
|
370
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'lemonsqueezy', testSecret);
|
|
371
|
+
console.log(' ✅ Lemon Squeezy:', result.isValid ? 'PASSED' : 'FAILED');
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
console.log(' ❌ Lemon Squeezy test failed:', error);
|
|
375
|
+
}
|
|
376
|
+
// Test 15: Paddle
|
|
377
|
+
console.log('\n15. Testing Paddle webhook...');
|
|
378
|
+
try {
|
|
379
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
380
|
+
const signature = createPaddleSignature(testBody, testSecret, timestamp);
|
|
381
|
+
const request = createMockRequest({
|
|
382
|
+
'paddle-signature': signature,
|
|
383
|
+
'content-type': 'application/json',
|
|
384
|
+
});
|
|
385
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'paddle', testSecret);
|
|
386
|
+
console.log(' ✅ Paddle:', result.isValid ? 'PASSED' : 'FAILED');
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
console.log(' ❌ Paddle test failed:', error);
|
|
390
|
+
}
|
|
391
|
+
// Test 16: Auth0
|
|
392
|
+
console.log('\n16. Testing Auth0 webhook...');
|
|
393
|
+
try {
|
|
394
|
+
const hmac = (0, crypto_1.createHmac)('sha256', testSecret);
|
|
395
|
+
hmac.update(testBody);
|
|
396
|
+
const signature = hmac.digest('hex');
|
|
397
|
+
const request = createMockRequest({
|
|
398
|
+
'x-auth0-signature': signature,
|
|
399
|
+
'content-type': 'application/json',
|
|
400
|
+
});
|
|
401
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'auth0', testSecret);
|
|
402
|
+
console.log(' ✅ Auth0:', result.isValid ? 'PASSED' : 'FAILED');
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
console.log(' ❌ Auth0 test failed:', error);
|
|
406
|
+
}
|
|
407
|
+
// Test 17: WorkOS
|
|
408
|
+
console.log('\n17. Testing WorkOS webhook...');
|
|
409
|
+
try {
|
|
410
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
411
|
+
const signature = createWorkOSSignature(testBody, testSecret, timestamp);
|
|
412
|
+
const request = createMockRequest({
|
|
413
|
+
'workos-signature': signature,
|
|
414
|
+
'content-type': 'application/json',
|
|
415
|
+
});
|
|
416
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'workos', testSecret);
|
|
417
|
+
console.log(' ✅ WorkOS:', result.isValid ? 'PASSED' : 'FAILED');
|
|
418
|
+
}
|
|
419
|
+
catch (error) {
|
|
420
|
+
console.log(' ❌ WorkOS test failed:', error);
|
|
421
|
+
}
|
|
422
|
+
// Test 17.5: Shopify
|
|
423
|
+
console.log('\n17.5. Testing Shopify webhook...');
|
|
424
|
+
try {
|
|
425
|
+
const signature = createShopifySignature(testBody, testSecret);
|
|
426
|
+
const request = createMockRequest({
|
|
427
|
+
'x-shopify-hmac-sha256': signature,
|
|
428
|
+
'content-type': 'application/json',
|
|
429
|
+
});
|
|
430
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'shopify', testSecret);
|
|
431
|
+
console.log(' ✅ Shopify:', result.isValid ? 'PASSED' : 'FAILED');
|
|
432
|
+
if (!result.isValid) {
|
|
433
|
+
console.log(' ❌ Error:', result.error);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
catch (error) {
|
|
437
|
+
console.log(' ❌ Shopify test failed:', error);
|
|
438
|
+
}
|
|
439
|
+
// Test 18: WooCommerce
|
|
440
|
+
console.log('\n18. Testing WooCommerce webhook...');
|
|
441
|
+
try {
|
|
442
|
+
const signature = createWooCommerceSignature(testBody, testSecret);
|
|
443
|
+
const request = createMockRequest({
|
|
444
|
+
'x-wc-webhook-signature': signature,
|
|
445
|
+
'content-type': 'application/json',
|
|
446
|
+
});
|
|
447
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'woocommerce', testSecret);
|
|
448
|
+
console.log(' ✅ WooCommerce:', result.isValid ? 'PASSED' : 'FAILED');
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
console.log(' ❌ WooCommerce test failed:', error);
|
|
452
|
+
}
|
|
453
|
+
// Test 18.5: Polar (Standard Webhooks)
|
|
454
|
+
console.log('\n18.5. Testing Polar webhook...');
|
|
455
|
+
try {
|
|
456
|
+
const secret = `whsec_${Buffer.from(testSecret).toString('base64')}`;
|
|
457
|
+
const webhookId = 'polar-webhook-id-1';
|
|
458
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
459
|
+
const signature = createStandardWebhooksSignature(testBody, secret, webhookId, timestamp);
|
|
460
|
+
const request = createMockRequest({
|
|
461
|
+
'webhook-signature': signature,
|
|
462
|
+
'webhook-id': webhookId,
|
|
463
|
+
'webhook-timestamp': timestamp.toString(),
|
|
464
|
+
'user-agent': 'Polar.sh Webhooks',
|
|
465
|
+
'content-type': 'application/json',
|
|
466
|
+
});
|
|
467
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'polar', secret);
|
|
468
|
+
const detectedPlatform = index_1.WebhookVerificationService.detectPlatform(request);
|
|
469
|
+
console.log(' ✅ Polar verification:', result.isValid ? 'PASSED' : 'FAILED');
|
|
470
|
+
console.log(' ✅ Polar auto-detect:', detectedPlatform === 'polar' ? 'PASSED' : 'FAILED');
|
|
471
|
+
}
|
|
472
|
+
catch (error) {
|
|
473
|
+
console.log(' ❌ Polar test failed:', error);
|
|
474
|
+
}
|
|
475
|
+
// Test 19: Replicate
|
|
476
|
+
console.log('\n19. Testing Replicate webhook...');
|
|
477
|
+
try {
|
|
478
|
+
const secret = `whsec_${Buffer.from(testSecret).toString('base64')}`;
|
|
479
|
+
const webhookId = 'replicate-webhook-id-1';
|
|
480
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
481
|
+
const signature = createStandardWebhooksSignature(testBody, secret, webhookId, timestamp);
|
|
482
|
+
const request = createMockRequest({
|
|
483
|
+
'webhook-signature': signature,
|
|
484
|
+
'webhook-id': webhookId,
|
|
485
|
+
'webhook-timestamp': timestamp.toString(),
|
|
486
|
+
'user-agent': 'Replicate-Webhooks/1.0',
|
|
487
|
+
'content-type': 'application/json',
|
|
488
|
+
});
|
|
489
|
+
const result = await index_1.WebhookVerificationService.verifyWithPlatformConfig(request, 'replicateai', secret);
|
|
490
|
+
console.log(' ✅ Replicate:', result.isValid ? 'PASSED' : 'FAILED');
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
console.log(' ❌ Replicate test failed:', error);
|
|
494
|
+
}
|
|
495
|
+
// Test 20: fal.ai
|
|
496
|
+
console.log('\n20. Testing fal.ai webhook...');
|
|
497
|
+
try {
|
|
498
|
+
const { privateKey, publicKey } = (0, crypto_1.generateKeyPairSync)('ed25519');
|
|
499
|
+
const requestId = 'fal-request-id';
|
|
500
|
+
const userId = 'fal-user-id';
|
|
501
|
+
const timestamp = Math.floor(Date.now() / 1000).toString();
|
|
502
|
+
const payloadToSign = createFalPayloadToSign(testBody, requestId, userId, timestamp);
|
|
503
|
+
const payloadBytes = new Uint8Array(Buffer.from(payloadToSign));
|
|
504
|
+
const signature = (0, crypto_1.sign)(null, payloadBytes, privateKey).toString('base64');
|
|
505
|
+
const request = createMockRequest({
|
|
506
|
+
'x-fal-webhook-signature': signature,
|
|
507
|
+
'x-fal-request-id': requestId,
|
|
508
|
+
'x-fal-user-id': userId,
|
|
509
|
+
'x-fal-webhook-timestamp': timestamp,
|
|
510
|
+
'content-type': 'application/json',
|
|
511
|
+
});
|
|
512
|
+
const result = await index_1.WebhookVerificationService.verify(request, {
|
|
513
|
+
platform: 'falai',
|
|
514
|
+
secret: '',
|
|
515
|
+
signatureConfig: {
|
|
516
|
+
algorithm: 'ed25519',
|
|
517
|
+
headerName: 'x-fal-webhook-signature',
|
|
518
|
+
headerFormat: 'raw',
|
|
519
|
+
payloadFormat: 'custom',
|
|
520
|
+
customConfig: {
|
|
521
|
+
publicKey: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
|
|
522
|
+
requestIdHeader: 'x-fal-request-id',
|
|
523
|
+
userIdHeader: 'x-fal-user-id',
|
|
524
|
+
timestampHeader: 'x-fal-webhook-timestamp',
|
|
525
|
+
},
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
console.log(' ✅ fal.ai:', result.isValid ? 'PASSED' : 'FAILED');
|
|
529
|
+
}
|
|
530
|
+
catch (error) {
|
|
531
|
+
console.log(' ❌ fal.ai test failed:', error);
|
|
532
|
+
}
|
|
292
533
|
console.log('\n🎉 All tests completed!');
|
|
293
534
|
}
|
|
294
535
|
// 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';
|
|
@@ -127,6 +152,12 @@ function detectPlatformFromHeaders(headers) {
|
|
|
127
152
|
if (headerMap.has('x-polar-signature')) {
|
|
128
153
|
return 'polar';
|
|
129
154
|
}
|
|
155
|
+
if (headerMap.has('webhook-signature')) {
|
|
156
|
+
const userAgent = (headerMap.get('user-agent') || '').toLowerCase();
|
|
157
|
+
if (userAgent.includes('polar')) {
|
|
158
|
+
return 'polar';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
130
161
|
// Supabase
|
|
131
162
|
if (headerMap.has('x-webhook-token')) {
|
|
132
163
|
return 'supabase';
|
|
@@ -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,15 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
131
134
|
return this.safeCompare(signature, expectedSignature);
|
|
132
135
|
}
|
|
133
136
|
verifyHMACWithBase64(payload, signature, algorithm = 'sha256') {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
const secretEncoding = this.config.customConfig?.secretEncoding || 'base64';
|
|
138
|
+
let secretMaterial = this.secret;
|
|
139
|
+
if (secretEncoding === 'base64') {
|
|
140
|
+
const base64Secret = this.secret.includes('_')
|
|
141
|
+
? this.secret.split('_').slice(1).join('_')
|
|
142
|
+
: this.secret;
|
|
143
|
+
secretMaterial = new Uint8Array(Buffer.from(base64Secret, 'base64'));
|
|
144
|
+
}
|
|
145
|
+
const hmac = (0, crypto_1.createHmac)(algorithm, secretMaterial);
|
|
137
146
|
hmac.update(payload);
|
|
138
147
|
const expectedSignature = hmac.digest('base64');
|
|
139
148
|
return this.safeCompare(signature, expectedSignature);
|
|
@@ -142,41 +151,38 @@ class AlgorithmBasedVerifier extends base_1.WebhookVerifier {
|
|
|
142
151
|
const metadata = {
|
|
143
152
|
algorithm: this.config.algorithm,
|
|
144
153
|
};
|
|
145
|
-
|
|
146
|
-
const timestamp = this.extractTimestamp(request);
|
|
154
|
+
const timestamp = this.extractTimestamp(request) || this.extractTimestampFromSignature(request);
|
|
147
155
|
if (timestamp) {
|
|
148
156
|
metadata.timestamp = timestamp.toString();
|
|
149
157
|
}
|
|
150
|
-
// Add platform-specific metadata
|
|
151
158
|
switch (this.platform) {
|
|
152
159
|
case 'github':
|
|
153
160
|
metadata.event = request.headers.get('x-github-event');
|
|
154
161
|
metadata.delivery = request.headers.get('x-github-delivery');
|
|
155
162
|
break;
|
|
156
|
-
case 'stripe':
|
|
157
|
-
// Extract Stripe-specific metadata from signature
|
|
163
|
+
case 'stripe': {
|
|
158
164
|
const headerValue = request.headers.get(this.config.headerName);
|
|
159
165
|
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
|
-
}
|
|
166
|
+
const sigMap = this.parseDelimitedHeader(headerValue);
|
|
168
167
|
metadata.id = sigMap.id;
|
|
169
168
|
}
|
|
170
169
|
break;
|
|
170
|
+
}
|
|
171
171
|
case 'clerk':
|
|
172
|
-
|
|
172
|
+
case 'dodopayments':
|
|
173
|
+
case 'replicateai':
|
|
174
|
+
metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
|
|
175
|
+
break;
|
|
176
|
+
case 'workos':
|
|
177
|
+
metadata.id = request.headers.get(this.config.customConfig?.idHeader || 'webhook-id');
|
|
178
|
+
break;
|
|
179
|
+
default:
|
|
173
180
|
break;
|
|
174
181
|
}
|
|
175
182
|
return metadata;
|
|
176
183
|
}
|
|
177
184
|
}
|
|
178
185
|
exports.AlgorithmBasedVerifier = AlgorithmBasedVerifier;
|
|
179
|
-
// Generic HMAC Verifier that handles all HMAC-based algorithms
|
|
180
186
|
class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
181
187
|
async verify(request) {
|
|
182
188
|
try {
|
|
@@ -190,17 +196,13 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
190
196
|
};
|
|
191
197
|
}
|
|
192
198
|
const rawBody = await request.text();
|
|
193
|
-
// Extract timestamp based on platform configuration
|
|
194
199
|
let timestamp = null;
|
|
195
200
|
if (this.config.headerFormat === 'comma-separated') {
|
|
196
|
-
// For platforms like Stripe where timestamp is embedded in signature
|
|
197
201
|
timestamp = this.extractTimestampFromSignature(request);
|
|
198
202
|
}
|
|
199
203
|
else {
|
|
200
|
-
// For platforms with separate timestamp header
|
|
201
204
|
timestamp = this.extractTimestamp(request);
|
|
202
205
|
}
|
|
203
|
-
// Validate timestamp if required
|
|
204
206
|
if (timestamp && !this.isTimestampValid(timestamp)) {
|
|
205
207
|
return {
|
|
206
208
|
isValid: false,
|
|
@@ -209,21 +211,16 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
209
211
|
platform: this.platform,
|
|
210
212
|
};
|
|
211
213
|
}
|
|
212
|
-
// Format payload according to platform requirements
|
|
213
214
|
const payload = this.formatPayload(rawBody, request);
|
|
214
|
-
// Verify signature based on platform configuration
|
|
215
215
|
let isValid = false;
|
|
216
216
|
const algorithm = this.config.algorithm.replace('hmac-', '');
|
|
217
217
|
if (this.config.customConfig?.encoding === 'base64') {
|
|
218
|
-
// For platforms like Clerk that use base64 encoding
|
|
219
218
|
isValid = this.verifyHMACWithBase64(payload, signature, algorithm);
|
|
220
219
|
}
|
|
221
220
|
else if (this.config.headerFormat === 'prefixed') {
|
|
222
|
-
// For platforms like GitHub that use prefixed signatures
|
|
223
221
|
isValid = this.verifyHMACWithPrefix(payload, signature, algorithm);
|
|
224
222
|
}
|
|
225
223
|
else {
|
|
226
|
-
// Standard HMAC verification
|
|
227
224
|
isValid = this.verifyHMAC(payload, signature, algorithm);
|
|
228
225
|
}
|
|
229
226
|
if (!isValid) {
|
|
@@ -234,7 +231,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
234
231
|
platform: this.platform,
|
|
235
232
|
};
|
|
236
233
|
}
|
|
237
|
-
// Parse payload
|
|
238
234
|
let parsedPayload;
|
|
239
235
|
try {
|
|
240
236
|
parsedPayload = JSON.parse(rawBody);
|
|
@@ -242,7 +238,6 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
242
238
|
catch (e) {
|
|
243
239
|
parsedPayload = rawBody;
|
|
244
240
|
}
|
|
245
|
-
// Extract platform-specific metadata
|
|
246
241
|
const metadata = this.extractMetadata(request);
|
|
247
242
|
return {
|
|
248
243
|
isValid: true,
|
|
@@ -262,7 +257,115 @@ class GenericHMACVerifier extends AlgorithmBasedVerifier {
|
|
|
262
257
|
}
|
|
263
258
|
}
|
|
264
259
|
exports.GenericHMACVerifier = GenericHMACVerifier;
|
|
265
|
-
|
|
260
|
+
class Ed25519Verifier extends AlgorithmBasedVerifier {
|
|
261
|
+
async resolvePublicKey(request) {
|
|
262
|
+
const configPublicKey = this.config.customConfig?.publicKey;
|
|
263
|
+
if (configPublicKey) {
|
|
264
|
+
return configPublicKey;
|
|
265
|
+
}
|
|
266
|
+
if (this.secret && this.secret.trim().length > 0) {
|
|
267
|
+
return this.secret;
|
|
268
|
+
}
|
|
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;
|
|
274
|
+
}
|
|
275
|
+
const cacheKey = `${jwksUrl}:${kid}`;
|
|
276
|
+
if (ed25519KeyCache.has(cacheKey)) {
|
|
277
|
+
return ed25519KeyCache.get(cacheKey);
|
|
278
|
+
}
|
|
279
|
+
const response = await fetch(jwksUrl);
|
|
280
|
+
if (!response.ok) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const body = await response.json();
|
|
284
|
+
const key = body.keys?.find((entry) => entry.kid === kid);
|
|
285
|
+
if (!key) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
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;
|
|
292
|
+
}
|
|
293
|
+
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}`;
|
|
302
|
+
}
|
|
303
|
+
async verify(request) {
|
|
304
|
+
try {
|
|
305
|
+
const signature = this.extractSignature(request);
|
|
306
|
+
if (!signature) {
|
|
307
|
+
return {
|
|
308
|
+
isValid: false,
|
|
309
|
+
error: `Missing signature header: ${this.config.headerName}`,
|
|
310
|
+
errorCode: 'MISSING_SIGNATURE',
|
|
311
|
+
platform: this.platform,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const rawBody = await request.text();
|
|
315
|
+
const payload = this.platform === 'falai'
|
|
316
|
+
? this.buildFalPayload(rawBody, request)
|
|
317
|
+
: this.formatPayload(rawBody, request);
|
|
318
|
+
const publicKey = await this.resolvePublicKey(request);
|
|
319
|
+
if (!publicKey) {
|
|
320
|
+
return {
|
|
321
|
+
isValid: false,
|
|
322
|
+
error: 'Missing public key for ED25519 verification',
|
|
323
|
+
errorCode: 'VERIFICATION_ERROR',
|
|
324
|
+
platform: this.platform,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
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);
|
|
331
|
+
if (!isValid) {
|
|
332
|
+
return {
|
|
333
|
+
isValid: false,
|
|
334
|
+
error: 'Invalid signature',
|
|
335
|
+
errorCode: 'INVALID_SIGNATURE',
|
|
336
|
+
platform: this.platform,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
let parsedPayload;
|
|
340
|
+
try {
|
|
341
|
+
parsedPayload = JSON.parse(rawBody);
|
|
342
|
+
}
|
|
343
|
+
catch (e) {
|
|
344
|
+
parsedPayload = rawBody;
|
|
345
|
+
}
|
|
346
|
+
return {
|
|
347
|
+
isValid: true,
|
|
348
|
+
platform: this.platform,
|
|
349
|
+
payload: parsedPayload,
|
|
350
|
+
metadata: {
|
|
351
|
+
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,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
return {
|
|
360
|
+
isValid: false,
|
|
361
|
+
error: `${this.platform} verification error: ${error.message}`,
|
|
362
|
+
errorCode: 'VERIFICATION_ERROR',
|
|
363
|
+
platform: this.platform,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
exports.Ed25519Verifier = Ed25519Verifier;
|
|
266
369
|
class HMACSHA256Verifier extends GenericHMACVerifier {
|
|
267
370
|
constructor(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
|
|
268
371
|
super(secret, config, platform, toleranceInSeconds);
|
|
@@ -281,17 +384,16 @@ class HMACSHA512Verifier extends GenericHMACVerifier {
|
|
|
281
384
|
}
|
|
282
385
|
}
|
|
283
386
|
exports.HMACSHA512Verifier = HMACSHA512Verifier;
|
|
284
|
-
// Factory function to create verifiers based on algorithm
|
|
285
387
|
function createAlgorithmVerifier(secret, config, platform = 'unknown', toleranceInSeconds = 300) {
|
|
286
388
|
switch (config.algorithm) {
|
|
287
389
|
case 'hmac-sha256':
|
|
288
390
|
case 'hmac-sha1':
|
|
289
391
|
case 'hmac-sha512':
|
|
290
392
|
return new GenericHMACVerifier(secret, config, platform, toleranceInSeconds);
|
|
291
|
-
case 'rsa-sha256':
|
|
292
393
|
case 'ed25519':
|
|
394
|
+
return new Ed25519Verifier(secret, config, platform, toleranceInSeconds);
|
|
395
|
+
case 'rsa-sha256':
|
|
293
396
|
case 'custom':
|
|
294
|
-
// These can be implemented as needed
|
|
295
397
|
throw new Error(`Algorithm ${config.algorithm} not yet implemented`);
|
|
296
398
|
default:
|
|
297
399
|
throw new Error(`Unknown algorithm: ${config.algorithm}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hookflo/tern",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.3",
|
|
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",
|