hono-webhooks 0.2.0 → 0.3.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 +42 -13
- package/dist/cli.js +75 -10
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -4
- package/dist/signatures.d.ts +13 -0
- package/dist/signatures.js +44 -3
- package/package.json +1 -1
- package/src/cli.ts +43 -4
- package/src/index.ts +21 -16
- package/src/signatures.ts +74 -5
package/README.md
CHANGED
|
@@ -179,10 +179,11 @@ Malformed JSON is a `400` with `issues: [{ message: 'Malformed JSON' }]`.
|
|
|
179
179
|
|
|
180
180
|
## Signature verification
|
|
181
181
|
|
|
182
|
-
`verify` runs
|
|
183
|
-
|
|
184
|
-
reaches
|
|
185
|
-
|
|
182
|
+
`verify` runs on the raw body **right after routing, before anything is
|
|
183
|
+
validated or parsed**. A request with a bad signature gets a 401 and never
|
|
184
|
+
reaches the params, query, headers or body schemas, so unauthenticated
|
|
185
|
+
callers never see a description of your schemas. Signed webhooks can use
|
|
186
|
+
`headers` and `body` schemas like any other webhook:
|
|
186
187
|
|
|
187
188
|
```ts
|
|
188
189
|
import { shopify } from 'hono-webhooks/signatures'
|
|
@@ -211,14 +212,15 @@ When `verify` returns `false`, the answer is
|
|
|
211
212
|
| `github` | `x-hub-signature-256: sha256=<hex>` | body |
|
|
212
213
|
| `shopify` | `x-shopify-hmac-sha256: <base64>` | body |
|
|
213
214
|
| `stripe` | `stripe-signature: t=<unix>,v1=<hex>` | `<t>.<body>` |
|
|
215
|
+
| `svix` (Resend, Clerk, Standard Webhooks) | `svix-id`, `svix-timestamp`, `svix-signature: v1,<base64> ...` (or `webhook-*`) | `<id>.<timestamp>.<body>`, key from `whsec_<base64>` |
|
|
214
216
|
| `hmac({ header, encoding, prefix?, hash? })` | any header, hex or base64, SHA-1/256/512 | body |
|
|
215
217
|
|
|
216
218
|
- `scheme.verify(secret)` gives a `verify` function. `secret` is a string or
|
|
217
219
|
`(c) => string | Promise<string>`, so it can come from `c.env`. An empty
|
|
218
220
|
secret throws (a 500), instead of silently accepting or rejecting everything.
|
|
219
|
-
- `stripe.verify(secret, { toleranceSeconds })`
|
|
220
|
-
than 300 seconds away by default, and
|
|
221
|
-
rotation works.
|
|
221
|
+
- `stripe.verify(secret, { toleranceSeconds })` and `svix.verify(...)` also
|
|
222
|
+
reject timestamps more than 300 seconds away by default, and accept any
|
|
223
|
+
matching `v1` so secret rotation works.
|
|
222
224
|
- `scheme.sign(rawBody, secret)` returns the headers the provider would send,
|
|
223
225
|
for tests and the CLI.
|
|
224
226
|
- Signatures are compared with `crypto.subtle.verify`, which runs in constant
|
|
@@ -386,7 +388,7 @@ summarizeEvent(event)
|
|
|
386
388
|
// ip: '203.0.113.9', // x-forwarded-for, x-real-ip or cf-connecting-ip
|
|
387
389
|
// userAgent: 'Shopify-Captain-Hook',
|
|
388
390
|
// params: { id: '42' }, query: {}, body: null, response: null,
|
|
389
|
-
// error: null, // or { name, message, stack }
|
|
391
|
+
// error: null, // or { name, message, stack }; null when validation is set
|
|
390
392
|
// validation: {
|
|
391
393
|
// target: 'body',
|
|
392
394
|
// message: 'total: Expected number',
|
|
@@ -486,6 +488,27 @@ export const githubPush = defineWebhook(
|
|
|
486
488
|
)
|
|
487
489
|
```
|
|
488
490
|
|
|
491
|
+
### Resend (Svix) events
|
|
492
|
+
|
|
493
|
+
```ts
|
|
494
|
+
import { svix } from 'hono-webhooks/signatures'
|
|
495
|
+
|
|
496
|
+
export const resendEvents = defineWebhook(
|
|
497
|
+
{
|
|
498
|
+
method: 'POST',
|
|
499
|
+
path: '/resend',
|
|
500
|
+
verify: svix.verify((c) => c.env.RESEND_WEBHOOK_SECRET), // whsec_...
|
|
501
|
+
body: z.object({
|
|
502
|
+
type: z.string(),
|
|
503
|
+
data: z.object({ email_id: z.string() })
|
|
504
|
+
})
|
|
505
|
+
},
|
|
506
|
+
({ body }) => {
|
|
507
|
+
if (body.type === 'email.bounced') markBounced(body.data.email_id)
|
|
508
|
+
}
|
|
509
|
+
)
|
|
510
|
+
```
|
|
511
|
+
|
|
489
512
|
### Twilio form posts
|
|
490
513
|
|
|
491
514
|
```ts
|
|
@@ -691,7 +714,7 @@ Options for test:
|
|
|
691
714
|
-b, --body <text> Request body (JSON sets content-type: application/json)
|
|
692
715
|
-q, --query <params> Query string, e.g. "a=1&b=2"
|
|
693
716
|
-H, --header <k: v> Request header, repeatable
|
|
694
|
-
-s, --sign <scheme> Sign the body: github, shopify, stripe
|
|
717
|
+
-s, --sign <scheme> Sign the body: github, shopify, stripe, svix
|
|
695
718
|
-e, --secret-env <name> Environment variable holding the signing secret
|
|
696
719
|
```
|
|
697
720
|
|
|
@@ -712,14 +735,19 @@ bunx hono-webhooks ./src/webhooks/index.ts test POST /shopify/orders -b '{"id":1
|
|
|
712
735
|
- `test` sends a real request through the webhooks app (without the parent
|
|
713
736
|
app's middleware) and exits with code 1 on a 4xx or 5xx response.
|
|
714
737
|
- `--preload` imports modules in order, before the webhooks module. It takes
|
|
715
|
-
a file path or a package name
|
|
738
|
+
a file path or a package name.
|
|
716
739
|
- `--sign` signs the exact `--body` with the secret read from the environment
|
|
717
740
|
variable named by `--secret-env`, after preloading, so a preloaded module
|
|
718
741
|
can set it.
|
|
719
742
|
- `schema` prints the JSON Schemas from
|
|
720
743
|
[`describeWebhooks`](#describing-webhooks).
|
|
721
|
-
- The CLI imports your TypeScript file.
|
|
722
|
-
22.18+
|
|
744
|
+
- The CLI imports your TypeScript file. `bunx hono-webhooks` starts the CLI
|
|
745
|
+
under Node (its shebang is `node`), and Node 22.18+ can load plain
|
|
746
|
+
TypeScript. It can't load everything Bun can, such as extensionless
|
|
747
|
+
imports (`import { x } from './x'`). When Node fails to load your module
|
|
748
|
+
and Bun is installed, the CLI reruns the same command under Bun
|
|
749
|
+
automatically. To skip the failed Node attempt in a Bun project, run
|
|
750
|
+
`bunx --bun hono-webhooks ...`.
|
|
723
751
|
|
|
724
752
|
## API reference
|
|
725
753
|
|
|
@@ -738,7 +766,8 @@ import {
|
|
|
738
766
|
import {
|
|
739
767
|
github,
|
|
740
768
|
shopify,
|
|
741
|
-
stripe,
|
|
769
|
+
stripe,
|
|
770
|
+
svix, // each: .verify(secret, options?) and .sign(rawBody, secret, options?)
|
|
742
771
|
hmac // (options) => a scheme like the ones above
|
|
743
772
|
} from 'hono-webhooks/signatures'
|
|
744
773
|
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
4
5
|
import { existsSync } from "node:fs";
|
|
5
6
|
import { resolve } from "node:path";
|
|
6
7
|
import { pathToFileURL } from "node:url";
|
|
@@ -126,9 +127,6 @@ function makeCreateWebhooks() {
|
|
|
126
127
|
const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
|
|
127
128
|
for (const { config, handler } of ordered) {
|
|
128
129
|
app.on(config.method, config.path, handle(config.path, async (c, event) => {
|
|
129
|
-
event.params = await parse(config.params, c.req.param(), "params");
|
|
130
|
-
event.query = await parse(config.query, c.req.query(), "query");
|
|
131
|
-
event.headers = await parse(config.headers, c.req.header(), "headers");
|
|
132
130
|
event.rawBody = await c.req.text();
|
|
133
131
|
if (config.verify && !await config.verify({
|
|
134
132
|
c,
|
|
@@ -139,6 +137,9 @@ function makeCreateWebhooks() {
|
|
|
139
137
|
{ message: "Signature does not match" }
|
|
140
138
|
]);
|
|
141
139
|
}
|
|
140
|
+
event.params = await parse(config.params, c.req.param(), "params");
|
|
141
|
+
event.query = await parse(config.query, c.req.query(), "query");
|
|
142
|
+
event.headers = await parse(config.headers, c.req.header(), "headers");
|
|
142
143
|
event.body = await parse(config.body, parseBody(event.rawBody, config.bodyType ?? "auto", c.req.header("content-type")), "body");
|
|
143
144
|
const result = await handler({
|
|
144
145
|
c,
|
|
@@ -213,7 +214,7 @@ function summarizeEvent(event) {
|
|
|
213
214
|
query: event.query ?? null,
|
|
214
215
|
body: event.body ?? null,
|
|
215
216
|
response: event.response ?? null,
|
|
216
|
-
error: error === undefined ? null : error instanceof Error ? {
|
|
217
|
+
error: error === undefined || error instanceof WebhookValidationError ? null : error instanceof Error ? {
|
|
217
218
|
name: error.name,
|
|
218
219
|
message: error.message,
|
|
219
220
|
stack: error.stack ?? null
|
|
@@ -290,9 +291,10 @@ async function testWebhook(webhook, input = {}) {
|
|
|
290
291
|
// src/signatures.ts
|
|
291
292
|
var encoder = new TextEncoder;
|
|
292
293
|
function hmacKey(secret, hash) {
|
|
293
|
-
if (!secret)
|
|
294
|
+
if (!secret.length) {
|
|
294
295
|
throw new Error("hono-webhooks: the signing secret is empty");
|
|
295
|
-
|
|
296
|
+
}
|
|
297
|
+
return crypto.subtle.importKey("raw", typeof secret === "string" ? encoder.encode(secret) : secret, { name: "HMAC", hash }, false, ["sign", "verify"]);
|
|
296
298
|
}
|
|
297
299
|
async function hmacSign(secret, payload, hash = "SHA-256") {
|
|
298
300
|
const key = await hmacKey(secret, hash);
|
|
@@ -372,12 +374,52 @@ var stripe = {
|
|
|
372
374
|
return { "stripe-signature": `t=${t},v1=${toHex(signature)}` };
|
|
373
375
|
}
|
|
374
376
|
};
|
|
377
|
+
function svixKey(secret) {
|
|
378
|
+
const key = fromBase64(secret.replace(/^whsec_/, ""));
|
|
379
|
+
if (!key?.length) {
|
|
380
|
+
throw new Error("hono-webhooks: the Svix secret must be whsec_<base64>");
|
|
381
|
+
}
|
|
382
|
+
return key;
|
|
383
|
+
}
|
|
384
|
+
var svix = {
|
|
385
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
|
|
386
|
+
const prefix = headers["svix-id"] ? "svix" : "webhook";
|
|
387
|
+
const id = headers[`${prefix}-id`];
|
|
388
|
+
const timestamp = headers[`${prefix}-timestamp`] ?? "";
|
|
389
|
+
const signatures = headers[`${prefix}-signature`];
|
|
390
|
+
if (!id || !signatures || !/^\d+$/.test(timestamp))
|
|
391
|
+
return false;
|
|
392
|
+
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
const key = svixKey(await resolveSecret(secret, c));
|
|
396
|
+
for (const entry of signatures.split(" ")) {
|
|
397
|
+
const [version, encoded] = entry.split(",");
|
|
398
|
+
const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
|
|
399
|
+
if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return false;
|
|
404
|
+
},
|
|
405
|
+
async sign(rawBody, secret, { id, timestamp } = {}) {
|
|
406
|
+
const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
407
|
+
const t = timestamp ?? Math.floor(Date.now() / 1000);
|
|
408
|
+
const signature = await hmacSign(svixKey(secret), `${msgId}.${t}.${rawBody}`);
|
|
409
|
+
return {
|
|
410
|
+
"svix-id": msgId,
|
|
411
|
+
"svix-timestamp": String(t),
|
|
412
|
+
"svix-signature": `v1,${toBase64(signature)}`
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
};
|
|
375
416
|
|
|
376
417
|
// src/cli.ts
|
|
377
418
|
var schemes = {
|
|
378
419
|
github,
|
|
379
420
|
shopify,
|
|
380
|
-
stripe
|
|
421
|
+
stripe,
|
|
422
|
+
svix
|
|
381
423
|
};
|
|
382
424
|
var HELP = `Usage: hono-webhooks <module> <command> [options]
|
|
383
425
|
|
|
@@ -441,6 +483,24 @@ async function loadApp(path) {
|
|
|
441
483
|
return createWebhooks(webhooks);
|
|
442
484
|
return fail(`${path} exports neither a createWebhooks() app nor an array of webhooks`);
|
|
443
485
|
}
|
|
486
|
+
var BUN_ONLY_ERRORS = new Set([
|
|
487
|
+
"ERR_MODULE_NOT_FOUND",
|
|
488
|
+
"ERR_UNKNOWN_FILE_EXTENSION",
|
|
489
|
+
"ERR_UNSUPPORTED_DIR_IMPORT",
|
|
490
|
+
"ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX",
|
|
491
|
+
"ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING"
|
|
492
|
+
]);
|
|
493
|
+
function rerunUnderBun(error) {
|
|
494
|
+
const code = error.code;
|
|
495
|
+
if (process.versions.bun || !code || !BUN_ONLY_ERRORS.has(code))
|
|
496
|
+
throw error;
|
|
497
|
+
console.error(`Node can't load the module (${code}), trying Bun`);
|
|
498
|
+
const run = spawnSync("bun", process.argv.slice(1), { stdio: "inherit" });
|
|
499
|
+
if (run.error) {
|
|
500
|
+
fail("Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...");
|
|
501
|
+
}
|
|
502
|
+
process.exit(run.status ?? 1);
|
|
503
|
+
}
|
|
444
504
|
async function signatureHeaders(rawBody) {
|
|
445
505
|
if (!values.sign)
|
|
446
506
|
return {};
|
|
@@ -454,9 +514,14 @@ async function main() {
|
|
|
454
514
|
console.log(HELP);
|
|
455
515
|
return;
|
|
456
516
|
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
517
|
+
let app;
|
|
518
|
+
try {
|
|
519
|
+
for (const preload of values.preload ?? [])
|
|
520
|
+
await importModule(preload);
|
|
521
|
+
app = await loadApp(modulePath);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
rerunUnderBun(error);
|
|
524
|
+
}
|
|
460
525
|
if (command === "list") {
|
|
461
526
|
for (const { config } of app.webhooks) {
|
|
462
527
|
const validates = [
|
package/dist/index.d.ts
CHANGED
|
@@ -48,8 +48,8 @@ export interface WebhookConfig<P extends string = string, TParams extends Schema
|
|
|
48
48
|
headers?: THeaders;
|
|
49
49
|
body?: TBody;
|
|
50
50
|
/**
|
|
51
|
-
* Checks the signature
|
|
52
|
-
* `false` answers `401 { error: 'Invalid signature' }`.
|
|
51
|
+
* Checks the signature right after routing, before anything is validated
|
|
52
|
+
* or parsed. Returning `false` answers `401 { error: 'Invalid signature' }`.
|
|
53
53
|
*
|
|
54
54
|
* @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
|
|
55
55
|
*/
|
|
@@ -215,6 +215,7 @@ export interface WebhookEventSummary {
|
|
|
215
215
|
query: Record<string, unknown> | null;
|
|
216
216
|
body: unknown;
|
|
217
217
|
response: unknown;
|
|
218
|
+
/** `null` for validation failures, which `validation` describes. */
|
|
218
219
|
error: {
|
|
219
220
|
name: string;
|
|
220
221
|
message: string;
|
package/dist/index.js
CHANGED
|
@@ -118,9 +118,6 @@ function makeCreateWebhooks() {
|
|
|
118
118
|
const ordered = [...webhooks].sort((a, b) => Number(isDynamic(a.config.path)) - Number(isDynamic(b.config.path)));
|
|
119
119
|
for (const { config, handler } of ordered) {
|
|
120
120
|
app.on(config.method, config.path, handle(config.path, async (c, event) => {
|
|
121
|
-
event.params = await parse(config.params, c.req.param(), "params");
|
|
122
|
-
event.query = await parse(config.query, c.req.query(), "query");
|
|
123
|
-
event.headers = await parse(config.headers, c.req.header(), "headers");
|
|
124
121
|
event.rawBody = await c.req.text();
|
|
125
122
|
if (config.verify && !await config.verify({
|
|
126
123
|
c,
|
|
@@ -131,6 +128,9 @@ function makeCreateWebhooks() {
|
|
|
131
128
|
{ message: "Signature does not match" }
|
|
132
129
|
]);
|
|
133
130
|
}
|
|
131
|
+
event.params = await parse(config.params, c.req.param(), "params");
|
|
132
|
+
event.query = await parse(config.query, c.req.query(), "query");
|
|
133
|
+
event.headers = await parse(config.headers, c.req.header(), "headers");
|
|
134
134
|
event.body = await parse(config.body, parseBody(event.rawBody, config.bodyType ?? "auto", c.req.header("content-type")), "body");
|
|
135
135
|
const result = await handler({
|
|
136
136
|
c,
|
|
@@ -205,7 +205,7 @@ function summarizeEvent(event) {
|
|
|
205
205
|
query: event.query ?? null,
|
|
206
206
|
body: event.body ?? null,
|
|
207
207
|
response: event.response ?? null,
|
|
208
|
-
error: error === undefined ? null : error instanceof Error ? {
|
|
208
|
+
error: error === undefined || error instanceof WebhookValidationError ? null : error instanceof Error ? {
|
|
209
209
|
name: error.name,
|
|
210
210
|
message: error.message,
|
|
211
211
|
stack: error.stack ?? null
|
package/dist/signatures.d.ts
CHANGED
|
@@ -38,3 +38,16 @@ export declare const stripe: SignatureScheme<{
|
|
|
38
38
|
}, {
|
|
39
39
|
timestamp?: number;
|
|
40
40
|
}>;
|
|
41
|
+
/**
|
|
42
|
+
* Svix and Standard Webhooks (Resend, Clerk, ...): `svix-id`,
|
|
43
|
+
* `svix-timestamp` and `svix-signature: v1,<base64> v1,<base64>`, signed over
|
|
44
|
+
* `<id>.<timestamp>.<body>` with the base64 key in a `whsec_` secret. The
|
|
45
|
+
* `webhook-*` header names of Standard Webhooks work too. Rejects timestamps
|
|
46
|
+
* more than `toleranceSeconds` (default 300) away.
|
|
47
|
+
*/
|
|
48
|
+
export declare const svix: SignatureScheme<{
|
|
49
|
+
toleranceSeconds?: number;
|
|
50
|
+
}, {
|
|
51
|
+
id?: string;
|
|
52
|
+
timestamp?: number;
|
|
53
|
+
}>;
|
package/dist/signatures.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// src/signatures.ts
|
|
2
2
|
var encoder = new TextEncoder;
|
|
3
3
|
function hmacKey(secret, hash) {
|
|
4
|
-
if (!secret)
|
|
4
|
+
if (!secret.length) {
|
|
5
5
|
throw new Error("hono-webhooks: the signing secret is empty");
|
|
6
|
-
|
|
6
|
+
}
|
|
7
|
+
return crypto.subtle.importKey("raw", typeof secret === "string" ? encoder.encode(secret) : secret, { name: "HMAC", hash }, false, ["sign", "verify"]);
|
|
7
8
|
}
|
|
8
9
|
async function hmacSign(secret, payload, hash = "SHA-256") {
|
|
9
10
|
const key = await hmacKey(secret, hash);
|
|
@@ -83,9 +84,49 @@ var stripe = {
|
|
|
83
84
|
return { "stripe-signature": `t=${t},v1=${toHex(signature)}` };
|
|
84
85
|
}
|
|
85
86
|
};
|
|
87
|
+
function svixKey(secret) {
|
|
88
|
+
const key = fromBase64(secret.replace(/^whsec_/, ""));
|
|
89
|
+
if (!key?.length) {
|
|
90
|
+
throw new Error("hono-webhooks: the Svix secret must be whsec_<base64>");
|
|
91
|
+
}
|
|
92
|
+
return key;
|
|
93
|
+
}
|
|
94
|
+
var svix = {
|
|
95
|
+
verify: (secret, { toleranceSeconds = 300 } = {}) => async ({ c, rawBody, headers }) => {
|
|
96
|
+
const prefix = headers["svix-id"] ? "svix" : "webhook";
|
|
97
|
+
const id = headers[`${prefix}-id`];
|
|
98
|
+
const timestamp = headers[`${prefix}-timestamp`] ?? "";
|
|
99
|
+
const signatures = headers[`${prefix}-signature`];
|
|
100
|
+
if (!id || !signatures || !/^\d+$/.test(timestamp))
|
|
101
|
+
return false;
|
|
102
|
+
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const key = svixKey(await resolveSecret(secret, c));
|
|
106
|
+
for (const entry of signatures.split(" ")) {
|
|
107
|
+
const [version, encoded] = entry.split(",");
|
|
108
|
+
const signature = version === "v1" && encoded ? fromBase64(encoded) : undefined;
|
|
109
|
+
if (signature && await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature)) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
},
|
|
115
|
+
async sign(rawBody, secret, { id, timestamp } = {}) {
|
|
116
|
+
const msgId = id ?? `msg_${crypto.randomUUID().replaceAll("-", "")}`;
|
|
117
|
+
const t = timestamp ?? Math.floor(Date.now() / 1000);
|
|
118
|
+
const signature = await hmacSign(svixKey(secret), `${msgId}.${t}.${rawBody}`);
|
|
119
|
+
return {
|
|
120
|
+
"svix-id": msgId,
|
|
121
|
+
"svix-timestamp": String(t),
|
|
122
|
+
"svix-signature": `v1,${toBase64(signature)}`
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
};
|
|
86
126
|
export {
|
|
87
127
|
github,
|
|
88
128
|
hmac,
|
|
89
129
|
shopify,
|
|
90
|
-
stripe
|
|
130
|
+
stripe,
|
|
131
|
+
svix
|
|
91
132
|
};
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
2
3
|
import { existsSync } from 'node:fs'
|
|
3
4
|
import { resolve } from 'node:path'
|
|
4
5
|
import { pathToFileURL } from 'node:url'
|
|
@@ -9,12 +10,19 @@ import {
|
|
|
9
10
|
type Webhook,
|
|
10
11
|
type WebhooksApp
|
|
11
12
|
} from './index'
|
|
12
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
github,
|
|
15
|
+
type SignatureScheme,
|
|
16
|
+
shopify,
|
|
17
|
+
stripe,
|
|
18
|
+
svix
|
|
19
|
+
} from './signatures'
|
|
13
20
|
|
|
14
21
|
const schemes: Record<string, SignatureScheme<any, any>> = {
|
|
15
22
|
github,
|
|
16
23
|
shopify,
|
|
17
|
-
stripe
|
|
24
|
+
stripe,
|
|
25
|
+
svix
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
const HELP = `Usage: hono-webhooks <module> <command> [options]
|
|
@@ -106,6 +114,32 @@ async function loadApp(path: string): Promise<WebhooksApp> {
|
|
|
106
114
|
)
|
|
107
115
|
}
|
|
108
116
|
|
|
117
|
+
/** Errors Node gives for TypeScript it can't load but Bun can. */
|
|
118
|
+
const BUN_ONLY_ERRORS = new Set([
|
|
119
|
+
'ERR_MODULE_NOT_FOUND',
|
|
120
|
+
'ERR_UNKNOWN_FILE_EXTENSION',
|
|
121
|
+
'ERR_UNSUPPORTED_DIR_IMPORT',
|
|
122
|
+
'ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX',
|
|
123
|
+
'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING'
|
|
124
|
+
])
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Under Node, a Bun project's TypeScript (extensionless imports, for one)
|
|
128
|
+
* fails to load. Rerun the same command under Bun when it is installed.
|
|
129
|
+
*/
|
|
130
|
+
function rerunUnderBun(error: unknown): never {
|
|
131
|
+
const code = (error as { code?: string }).code
|
|
132
|
+
if (process.versions.bun || !code || !BUN_ONLY_ERRORS.has(code)) throw error
|
|
133
|
+
console.error(`Node can't load the module (${code}), trying Bun`)
|
|
134
|
+
const run = spawnSync('bun', process.argv.slice(1), { stdio: 'inherit' })
|
|
135
|
+
if (run.error) {
|
|
136
|
+
fail(
|
|
137
|
+
'Bun is not installed either. Run the CLI with a runtime that can load your code, e.g. bunx --bun hono-webhooks ...'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
process.exit(run.status ?? 1)
|
|
141
|
+
}
|
|
142
|
+
|
|
109
143
|
async function signatureHeaders(rawBody: string) {
|
|
110
144
|
if (!values.sign) return {}
|
|
111
145
|
const scheme =
|
|
@@ -123,8 +157,13 @@ async function main() {
|
|
|
123
157
|
console.log(HELP)
|
|
124
158
|
return
|
|
125
159
|
}
|
|
126
|
-
|
|
127
|
-
|
|
160
|
+
let app: WebhooksApp
|
|
161
|
+
try {
|
|
162
|
+
for (const preload of values.preload ?? []) await importModule(preload)
|
|
163
|
+
app = await loadApp(modulePath)
|
|
164
|
+
} catch (error) {
|
|
165
|
+
rerunUnderBun(error)
|
|
166
|
+
}
|
|
128
167
|
|
|
129
168
|
if (command === 'list') {
|
|
130
169
|
for (const { config } of app.webhooks) {
|
package/src/index.ts
CHANGED
|
@@ -92,8 +92,8 @@ export interface WebhookConfig<
|
|
|
92
92
|
headers?: THeaders
|
|
93
93
|
body?: TBody
|
|
94
94
|
/**
|
|
95
|
-
* Checks the signature
|
|
96
|
-
* `false` answers `401 { error: 'Invalid signature' }`.
|
|
95
|
+
* Checks the signature right after routing, before anything is validated
|
|
96
|
+
* or parsed. Returning `false` answers `401 { error: 'Invalid signature' }`.
|
|
97
97
|
*
|
|
98
98
|
* @example verify: shopify.verify((c) => c.env.SHOPIFY_SECRET)
|
|
99
99
|
*/
|
|
@@ -392,6 +392,21 @@ function makeCreateWebhooks<E extends Env>() {
|
|
|
392
392
|
config.method,
|
|
393
393
|
config.path,
|
|
394
394
|
handle(config.path, async (c, event) => {
|
|
395
|
+
// The signature is checked first, so unauthenticated callers only
|
|
396
|
+
// ever see 401, never a description of the schemas.
|
|
397
|
+
event.rawBody = await c.req.text()
|
|
398
|
+
if (
|
|
399
|
+
config.verify &&
|
|
400
|
+
!(await config.verify({
|
|
401
|
+
c,
|
|
402
|
+
rawBody: event.rawBody,
|
|
403
|
+
headers: c.req.header()
|
|
404
|
+
}))
|
|
405
|
+
) {
|
|
406
|
+
throw new WebhookValidationError('signature', [
|
|
407
|
+
{ message: 'Signature does not match' }
|
|
408
|
+
])
|
|
409
|
+
}
|
|
395
410
|
type Parts = Record<string, unknown>
|
|
396
411
|
event.params = (await parse(
|
|
397
412
|
config.params,
|
|
@@ -408,19 +423,6 @@ function makeCreateWebhooks<E extends Env>() {
|
|
|
408
423
|
c.req.header(),
|
|
409
424
|
'headers'
|
|
410
425
|
)) as Parts
|
|
411
|
-
event.rawBody = await c.req.text()
|
|
412
|
-
if (
|
|
413
|
-
config.verify &&
|
|
414
|
-
!(await config.verify({
|
|
415
|
-
c,
|
|
416
|
-
rawBody: event.rawBody,
|
|
417
|
-
headers: c.req.header()
|
|
418
|
-
}))
|
|
419
|
-
) {
|
|
420
|
-
throw new WebhookValidationError('signature', [
|
|
421
|
-
{ message: 'Signature does not match' }
|
|
422
|
-
])
|
|
423
|
-
}
|
|
424
426
|
event.body = await parse(
|
|
425
427
|
config.body,
|
|
426
428
|
parseBody(
|
|
@@ -555,6 +557,7 @@ export interface WebhookEventSummary {
|
|
|
555
557
|
query: Record<string, unknown> | null
|
|
556
558
|
body: unknown
|
|
557
559
|
response: unknown
|
|
560
|
+
/** `null` for validation failures, which `validation` describes. */
|
|
558
561
|
error: { name: string; message: string; stack: string | null } | null
|
|
559
562
|
validation: {
|
|
560
563
|
target: ValidationTarget
|
|
@@ -589,8 +592,10 @@ export function summarizeEvent(event: WebhookEvent): WebhookEventSummary {
|
|
|
589
592
|
query: event.query ?? null,
|
|
590
593
|
body: event.body ?? null,
|
|
591
594
|
response: event.response ?? null,
|
|
595
|
+
// A validation failure is described by `validation`; its error adds only
|
|
596
|
+
// a library stack trace.
|
|
592
597
|
error:
|
|
593
|
-
error === undefined
|
|
598
|
+
error === undefined || error instanceof WebhookValidationError
|
|
594
599
|
? null
|
|
595
600
|
: error instanceof Error
|
|
596
601
|
? {
|
package/src/signatures.ts
CHANGED
|
@@ -27,18 +27,27 @@ export interface HmacOptions {
|
|
|
27
27
|
|
|
28
28
|
const encoder = new TextEncoder()
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
/** A string secret is used as UTF-8; bytes are used as is. */
|
|
31
|
+
function hmacKey(secret: string | Uint8Array, hash: string) {
|
|
32
|
+
if (!secret.length) {
|
|
33
|
+
throw new Error('hono-webhooks: the signing secret is empty')
|
|
34
|
+
}
|
|
32
35
|
return crypto.subtle.importKey(
|
|
33
36
|
'raw',
|
|
34
|
-
|
|
37
|
+
(typeof secret === 'string'
|
|
38
|
+
? encoder.encode(secret)
|
|
39
|
+
: secret) as Uint8Array<ArrayBuffer>,
|
|
35
40
|
{ name: 'HMAC', hash },
|
|
36
41
|
false,
|
|
37
42
|
['sign', 'verify']
|
|
38
43
|
)
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
async function hmacSign(
|
|
46
|
+
async function hmacSign(
|
|
47
|
+
secret: string | Uint8Array,
|
|
48
|
+
payload: string,
|
|
49
|
+
hash = 'SHA-256'
|
|
50
|
+
) {
|
|
42
51
|
const key = await hmacKey(secret, hash)
|
|
43
52
|
return new Uint8Array(
|
|
44
53
|
await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
|
|
@@ -47,7 +56,7 @@ async function hmacSign(secret: string, payload: string, hash = 'SHA-256') {
|
|
|
47
56
|
|
|
48
57
|
/** `crypto.subtle.verify` compares in constant time. */
|
|
49
58
|
async function hmacVerify(
|
|
50
|
-
secret: string,
|
|
59
|
+
secret: string | Uint8Array,
|
|
51
60
|
payload: string,
|
|
52
61
|
signature: Uint8Array,
|
|
53
62
|
hash = 'SHA-256'
|
|
@@ -169,3 +178,63 @@ export const stripe: SignatureScheme<
|
|
|
169
178
|
return { 'stripe-signature': `t=${t},v1=${toHex(signature)}` }
|
|
170
179
|
}
|
|
171
180
|
}
|
|
181
|
+
|
|
182
|
+
/** Svix secrets are `whsec_` followed by the base64 key. */
|
|
183
|
+
function svixKey(secret: string) {
|
|
184
|
+
const key = fromBase64(secret.replace(/^whsec_/, ''))
|
|
185
|
+
if (!key?.length) {
|
|
186
|
+
throw new Error('hono-webhooks: the Svix secret must be whsec_<base64>')
|
|
187
|
+
}
|
|
188
|
+
return key
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Svix and Standard Webhooks (Resend, Clerk, ...): `svix-id`,
|
|
193
|
+
* `svix-timestamp` and `svix-signature: v1,<base64> v1,<base64>`, signed over
|
|
194
|
+
* `<id>.<timestamp>.<body>` with the base64 key in a `whsec_` secret. The
|
|
195
|
+
* `webhook-*` header names of Standard Webhooks work too. Rejects timestamps
|
|
196
|
+
* more than `toleranceSeconds` (default 300) away.
|
|
197
|
+
*/
|
|
198
|
+
export const svix: SignatureScheme<
|
|
199
|
+
{ toleranceSeconds?: number },
|
|
200
|
+
{ id?: string; timestamp?: number }
|
|
201
|
+
> = {
|
|
202
|
+
verify:
|
|
203
|
+
(secret, { toleranceSeconds = 300 } = {}) =>
|
|
204
|
+
async ({ c, rawBody, headers }) => {
|
|
205
|
+
const prefix = headers['svix-id'] ? 'svix' : 'webhook'
|
|
206
|
+
const id = headers[`${prefix}-id`]
|
|
207
|
+
const timestamp = headers[`${prefix}-timestamp`] ?? ''
|
|
208
|
+
const signatures = headers[`${prefix}-signature`]
|
|
209
|
+
if (!id || !signatures || !/^\d+$/.test(timestamp)) return false
|
|
210
|
+
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) {
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
const key = svixKey(await resolveSecret(secret, c))
|
|
214
|
+
for (const entry of signatures.split(' ')) {
|
|
215
|
+
const [version, encoded] = entry.split(',')
|
|
216
|
+
const signature =
|
|
217
|
+
version === 'v1' && encoded ? fromBase64(encoded) : undefined
|
|
218
|
+
if (
|
|
219
|
+
signature &&
|
|
220
|
+
(await hmacVerify(key, `${id}.${timestamp}.${rawBody}`, signature))
|
|
221
|
+
) {
|
|
222
|
+
return true
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return false
|
|
226
|
+
},
|
|
227
|
+
async sign(rawBody, secret, { id, timestamp } = {}) {
|
|
228
|
+
const msgId = id ?? `msg_${crypto.randomUUID().replaceAll('-', '')}`
|
|
229
|
+
const t = timestamp ?? Math.floor(Date.now() / 1000)
|
|
230
|
+
const signature = await hmacSign(
|
|
231
|
+
svixKey(secret),
|
|
232
|
+
`${msgId}.${t}.${rawBody}`
|
|
233
|
+
)
|
|
234
|
+
return {
|
|
235
|
+
'svix-id': msgId,
|
|
236
|
+
'svix-timestamp': String(t),
|
|
237
|
+
'svix-signature': `v1,${toBase64(signature)}`
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|