@theholocron/holocron-plugin-clerk 2.0.0-alpha.59 → 2.0.0-alpha.60

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.
Files changed (2) hide show
  1. package/dist/index.mjs +40 -28
  2. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AuthError, ProviderApiError, WebhookVerificationError, createResolveToken } from "@theholocron/cli";
2
2
  import { createClerkClient } from "@theholocron/clerk-client";
3
+ import { createHmac, timingSafeEqual } from "node:crypto";
3
4
  //#region src/auth.ts
4
5
  const resolveToken = createResolveToken({
5
6
  envName: "HOLOCRON_CLERK_SECRET_KEY",
@@ -98,27 +99,23 @@ function isAlreadyExistsError(err) {
98
99
  //#region src/parse-webhook.ts
99
100
  /**
100
101
  * Translates a Clerk webhook delivery into the normalized `AuthEvent`
101
- * shape defined in `@theholocron/cli`. The first concrete example of
102
- * the cross-provider sync pattern (see `AuthEvent` JSDoc in core).
102
+ * shape defined in `@theholocron/cli`, with full Svix HMAC-SHA256
103
+ * signature verification (closes #80).
103
104
  *
104
- * Status: signature SHIPPED, body STUBBED. Real Svix signature
105
- * verification (HMAC-SHA256 over `svix-id.svix-timestamp.body` with
106
- * the `whsec_…` signing secret, base64-decoded) lands in a follow-up
107
- * (tracked at #80). For now, parseWebhook trusts the request and
108
- * focuses on shape translation — usable in environments where
109
- * signature verification happens upstream (e.g., Vercel middleware,
110
- * an API gateway), and an explicit error in production-grade
111
- * deployments until the verification body lands.
112
- *
113
- * Reference event shapes:
114
- * https://clerk.com/docs/integrations/webhooks/overview
115
- * https://clerk.com/docs/reference/backend-api/tag/Webhooks
105
+ * Verification algorithm (https://docs.svix.com/receiving/verifying-payloads/how-manual):
106
+ * 1. Require svix-id, svix-timestamp, svix-signature headers
107
+ * 2. Reject if svix-timestamp is outside the ±5-minute replay window
108
+ * 3. Decode the whsec_<base64> signing secret
109
+ * 4. Compute HMAC-SHA256(secretBytes, "${id}.${timestamp}.${body}")
110
+ * 5. Constant-time compare against each v1,<base64> signature in the
111
+ * space-separated svix-signature header (supports key rotation)
116
112
  */
117
113
  const CLERK_TO_NORMALIZED = {
118
114
  "user.created": "user.created",
119
115
  "user.updated": "user.updated",
120
116
  "user.deleted": "user.deleted"
121
117
  };
118
+ const REPLAY_WINDOW_SECONDS = 300;
122
119
  async function parseWebhook(input) {
123
120
  await verifySignature(input);
124
121
  const bodyStr = typeof input.body === "string" ? input.body : input.body.toString("utf8");
@@ -144,22 +141,37 @@ async function parseWebhook(input) {
144
141
  occurredAt
145
142
  };
146
143
  }
147
- /**
148
- * Stub for Svix signature verification. The real implementation
149
- * lands at #80 and will:
150
- *
151
- * 1. Read svix-id, svix-timestamp, svix-signature headers
152
- * 2. Verify svix-timestamp is within the replay window (±5 min)
153
- * 3. Compute HMAC-SHA256(<signing_secret>, `${id}.${timestamp}.${body}`)
154
- * 4. Compare (constant-time) against the base64 sig in svix-signature
155
- *
156
- * For now: throw WebhookVerificationError when the signing secret is
157
- * missing, so consumers see the contract; let the call through
158
- * otherwise (signature-validation TODO).
159
- */
160
144
  async function verifySignature(input) {
161
145
  if (!input.signingSecret) throw new WebhookVerificationError("Clerk webhook signingSecret is required (use the Svix whsec_… value from the Clerk dashboard)");
162
- return Promise.resolve();
146
+ const lower = (s) => s.toLowerCase();
147
+ const h = (name) => {
148
+ const target = lower(name);
149
+ for (const [k, v] of Object.entries(input.headers)) if (lower(k) === target) return Array.isArray(v) ? v[0] : v;
150
+ };
151
+ const svixId = h("svix-id");
152
+ const svixTimestamp = h("svix-timestamp");
153
+ const svixSignature = h("svix-signature");
154
+ if (!svixId || !svixTimestamp || !svixSignature) throw new WebhookVerificationError("Missing required Svix headers: svix-id, svix-timestamp, svix-signature");
155
+ const ts = parseInt(svixTimestamp, 10);
156
+ if (isNaN(ts)) throw new WebhookVerificationError(`Invalid svix-timestamp: "${svixTimestamp}"`);
157
+ const nowSeconds = Math.floor(Date.now() / 1e3);
158
+ if (Math.abs(nowSeconds - ts) > REPLAY_WINDOW_SECONDS) throw new WebhookVerificationError(`Message timestamp is outside the ${REPLAY_WINDOW_SECONDS / 60}-minute replay window`);
159
+ const secretBase64 = input.signingSecret.startsWith("whsec_") ? input.signingSecret.slice(6) : input.signingSecret;
160
+ const secretBytes = Buffer.from(secretBase64, "base64");
161
+ const toSign = `${svixId}.${svixTimestamp}.${typeof input.body === "string" ? input.body : input.body.toString("utf8")}`;
162
+ const computed = createHmac("sha256", secretBytes).update(toSign).digest();
163
+ if (!svixSignature.split(" ").some((sig) => {
164
+ const comma = sig.indexOf(",");
165
+ if (comma === -1 || sig.slice(0, comma) !== "v1") return false;
166
+ let sigBytes;
167
+ try {
168
+ sigBytes = Buffer.from(sig.slice(comma + 1), "base64");
169
+ } catch {
170
+ return false;
171
+ }
172
+ if (sigBytes.length !== computed.length) return false;
173
+ return timingSafeEqual(sigBytes, computed);
174
+ })) throw new WebhookVerificationError("Svix signature verification failed");
163
175
  }
164
176
  //#endregion
165
177
  //#region src/verify-token.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/holocron-plugin-clerk",
3
- "version": "2.0.0-alpha.59",
3
+ "version": "2.0.0-alpha.60",
4
4
  "description": "Holocron plugin for Clerk. Implements the auth capability against Clerk's Backend REST API.",
5
5
  "homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-clerk#readme",
6
6
  "bugs": "https://github.com/theholocron/holocron/issues",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@theholocron/clerk-client": "^0.11.3",
25
- "@theholocron/cli": "2.0.0-alpha.59"
25
+ "@theholocron/cli": "2.0.0-alpha.60"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@theholocron/clerk-client": "^0.11.3",
@@ -40,7 +40,7 @@
40
40
  "tsx": "^4.22.4",
41
41
  "typescript": "^5.9.3",
42
42
  "vitest": "^4.1.10",
43
- "@theholocron/cli": "2.0.0-alpha.59"
43
+ "@theholocron/cli": "2.0.0-alpha.60"
44
44
  },
45
45
  "publishConfig": {
46
46
  "access": "public"