diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 968a217..e86d048 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -162,9 +162,9 @@ sequenceDiagram | GitHub | Signature | `x-hub-signature-256` | HMAC-SHA256 with `sha256=` prefix | None | (raw/fallback) | Strong verifier support | | Clerk | Signature | `svix-signature` | HMAC-SHA256 (base64 secret derivation) | `svix-timestamp` | `auth` | Svix-style payload format | | Dodo Payments | Signature | `webhook-signature` | HMAC-SHA256 (svix-style/base64) | `webhook-timestamp` | (raw/fallback) | Verifier implemented | -| Shopify | Signature | `x-shopify-hmac-sha256` | HMAC-SHA256 | none/custom | (raw/fallback) | Platform config present | +| Shopify | Signature | `x-shopify-hmac-sha256` | HMAC-SHA256 (base64 signature) | none | (raw/fallback) | Platform config present | | Vercel | Signature | `x-vercel-signature` | HMAC-SHA256 | `x-vercel-timestamp` | `infrastructure` | Typed normalization present | -| Polar | Signature | `x-polar-signature` | HMAC-SHA256 | `x-polar-timestamp` | `payment` | Typed normalization present | +| Polar | Signature | `webhook-signature` | HMAC-SHA256 (Standard Webhooks/base64) | `webhook-timestamp` | `payment` | Typed normalization present | | Supabase | Token | `x-webhook-token` (+ `x-webhook-id`) | token compare (custom) | N/A | `auth` | Typed normalization present | | GitLab | Token | `X-Gitlab-Token` | token compare (custom) | N/A | (raw/fallback) | Verifier implemented | | Custom/Unknown | Configurable | user-defined | configurable | configurable | fallback | Extension path for new platforms | diff --git a/FRAMEWORK_SUMMARY.md b/FRAMEWORK_SUMMARY.md index f950e65..7a189b2 100644 --- a/FRAMEWORK_SUMMARY.md +++ b/FRAMEWORK_SUMMARY.md @@ -101,9 +101,9 @@ const result = await WebhookVerificationService.verify(request, config); - **Stripe**: `stripe-signature` with comma-separated format - **Clerk**: `svix-signature` with base64 encoding - **Dodo Payments**: `webhook-signature` with raw format -- **Shopify**: `x-shopify-hmac-sha256` +- **Shopify**: `x-shopify-hmac-sha256` (base64 signature) - **Vercel**: `x-vercel-signature` -- **Polar**: `x-polar-signature` +- **Polar**: `webhook-signature` (Standard Webhooks) ### HMAC-SHA1 (Legacy) - Legacy platforms that still use SHA1 diff --git a/README.md b/README.md index 5bcec6e..b83adfb 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ const result = await WebhookVerificationService.verify(request, stripeConfig); - **WooCommerce**: HMAC-SHA256 (base64 signature) - **ReplicateAI**: HMAC-SHA256 (Standard Webhooks style) - **fal.ai**: ED25519 (`x-fal-webhook-signature`) -- **Shopify**: HMAC-SHA256 +- **Shopify**: HMAC-SHA256 (base64 signature) - **Vercel**: HMAC-SHA256 - **Polar**: HMAC-SHA256 - **Supabase**: Token-based authentication diff --git a/USAGE.md b/USAGE.md index 9674cc1..6dbaa9d 100644 --- a/USAGE.md +++ b/USAGE.md @@ -35,7 +35,7 @@ if (result.isValid) { - **Stripe**: `stripe-signature` header - **Shopify**: `x-shopify-hmac-sha256` header - **Vercel**: `x-vercel-signature` header -- **Polar**: `x-polar-signature` header +- **Polar**: `webhook-signature` header (Standard Webhooks) - **Dodo Payments**: `webhook-signature` header - **Paddle**: `paddle-signature` header - **Razorpay**: `x-razorpay-signature` header diff --git a/src/adapters/cloudflare.ts b/src/adapters/cloudflare.ts index fa1f92e..7e3d575 100644 --- a/src/adapters/cloudflare.ts +++ b/src/adapters/cloudflare.ts @@ -32,7 +32,7 @@ export function createWebhookHandler, TResponse = ); if (!result.isValid) { - return Response.json({ error: result.error, platform: result.platform }, { status: 400 }); + return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 }); } const data = await options.handler(result.payload, env, result.metadata || {}); diff --git a/src/adapters/express.ts b/src/adapters/express.ts index a4aea9b..d48f05c 100644 --- a/src/adapters/express.ts +++ b/src/adapters/express.ts @@ -39,7 +39,7 @@ export function createWebhookMiddleware(options: ExpressWebhookMiddlewareOptions ); if (!result.isValid) { - res.status(400).json({ error: result.error, platform: result.platform }); + res.status(400).json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }); return; } diff --git a/src/adapters/nextjs.ts b/src/adapters/nextjs.ts index 141f848..c247b4e 100644 --- a/src/adapters/nextjs.ts +++ b/src/adapters/nextjs.ts @@ -24,7 +24,7 @@ export function createWebhookHandler( ); if (!result.isValid) { - return Response.json({ error: result.error, platform: result.platform }, { status: 400 }); + return Response.json({ error: result.error, errorCode: result.errorCode, platform: result.platform, metadata: result.metadata }, { status: 400 }); } const data = await options.handler(result.payload, result.metadata || {}); diff --git a/src/index.ts b/src/index.ts index 1c1b586..dddbeb4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { SignatureConfig, MultiPlatformSecrets, NormalizeOptions, + WebhookErrorCode, } from './types'; import { createAlgorithmVerifier } from './verifiers/algorithms'; import { createCustomVerifier } from './verifiers/custom-algorithms'; @@ -112,6 +113,12 @@ export class WebhookVerificationService { ); } + const failedAttempts: Array<{ + platform: WebhookPlatform; + error?: string; + errorCode?: WebhookErrorCode; + }> = []; + for (const [platform, secret] of Object.entries(secrets)) { if (!secret) { continue; @@ -128,13 +135,28 @@ export class WebhookVerificationService { if (result.isValid) { return result; } + + failedAttempts.push({ + platform: platform.toLowerCase() as WebhookPlatform, + error: result.error, + errorCode: result.errorCode, + }); } + const details = failedAttempts + .map((attempt) => `${attempt.platform}: ${attempt.error || 'verification failed'}`) + .join('; '); + return { isValid: false, - error: 'Unable to verify webhook with provided platform secrets', - errorCode: 'VERIFICATION_ERROR', + error: details + ? `Unable to verify webhook with provided platform secrets. Attempts -> ${details}` + : 'Unable to verify webhook with provided platform secrets', + errorCode: failedAttempts.find((attempt) => attempt.errorCode)?.errorCode || 'VERIFICATION_ERROR', platform: detectedPlatform, + metadata: { + attempts: failedAttempts, + }, }; } @@ -147,6 +169,7 @@ export class WebhookVerificationService { if (headers.has('workos-signature')) return 'workos'; if (headers.has('webhook-signature')) { const userAgent = headers.get('user-agent')?.toLowerCase() || ''; + if (userAgent.includes('polar')) return 'polar'; if (userAgent.includes('replicate')) return 'replicateai'; return 'dodopayments'; } diff --git a/src/platforms/algorithms.ts b/src/platforms/algorithms.ts index 9f5c131..8eed9fc 100644 --- a/src/platforms/algorithms.ts +++ b/src/platforms/algorithms.ts @@ -83,10 +83,13 @@ export const platformAlgorithmConfigs: Record< algorithm: 'hmac-sha256', headerName: 'x-shopify-hmac-sha256', headerFormat: 'raw', - timestampHeader: 'x-shopify-shop-domain', payloadFormat: 'raw', + customConfig: { + encoding: 'base64', + secretEncoding: 'utf8', + }, }, - description: 'Shopify webhooks use HMAC-SHA256', + description: 'Shopify webhooks use HMAC-SHA256 with base64 encoded signature', }, vercel: { @@ -106,13 +109,20 @@ export const platformAlgorithmConfigs: Record< platform: 'polar', signatureConfig: { algorithm: 'hmac-sha256', - headerName: 'x-polar-signature', + headerName: 'webhook-signature', headerFormat: 'raw', - timestampHeader: 'x-polar-timestamp', + timestampHeader: 'webhook-timestamp', timestampFormat: 'unix', - payloadFormat: 'raw', + payloadFormat: 'custom', + customConfig: { + signatureFormat: 'v1={signature}', + payloadFormat: '{id}.{timestamp}.{body}', + encoding: 'base64', + secretEncoding: 'base64', + idHeader: 'webhook-id', + }, }, - description: 'Polar webhooks use HMAC-SHA256', + description: 'Polar webhooks use HMAC-SHA256 with Standard Webhooks format', }, supabase: { diff --git a/src/test.ts b/src/test.ts index a12f34b..9beb326 100644 --- a/src/test.ts +++ b/src/test.ts @@ -59,6 +59,13 @@ function createPaddleSignature(body: string, secret: string, timestamp: number): return `ts=${timestamp};h1=${hmac.digest('hex')}`; } + +function createShopifySignature(body: string, secret: string): string { + const hmac = createHmac('sha256', secret); + hmac.update(body); + return hmac.digest('base64'); +} + function createWooCommerceSignature(body: string, secret: string): string { const hmac = createHmac('sha256', secret); hmac.update(body); @@ -372,6 +379,29 @@ try { console.log(' ❌ verifyAny test failed:', error); } + // Test 10.5: verifyAny error diagnostics + console.log('\n10.5. Testing verifyAny error diagnostics...'); + try { + const unknownRequest = createMockRequest({ + 'content-type': 'application/json', + }); + + const invalidVerifyAny = await WebhookVerificationService.verifyAny(unknownRequest, { + stripe: testSecret, + shopify: testSecret, + }); + + const hasDetailedErrors = Boolean( + invalidVerifyAny.error + && invalidVerifyAny.error.includes('Attempts ->') + && invalidVerifyAny.metadata?.attempts?.length === 2, + ); + + console.log(' ✅ verifyAny diagnostics:', hasDetailedErrors ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ verifyAny diagnostics test failed:', error); + } + // Test 11: Normalization for Stripe console.log('\n11. Testing payload normalization...'); try { @@ -513,6 +543,24 @@ try { console.log(' ❌ WorkOS test failed:', error); } + // Test 17.5: Shopify + console.log('\n17.5. Testing Shopify webhook...'); + try { + const signature = createShopifySignature(testBody, testSecret); + const request = createMockRequest({ + 'x-shopify-hmac-sha256': signature, + 'content-type': 'application/json', + }); + + const result = await WebhookVerificationService.verifyWithPlatformConfig(request, 'shopify', testSecret); + console.log(' ✅ Shopify:', result.isValid ? 'PASSED' : 'FAILED'); + if (!result.isValid) { + console.log(' ❌ Error:', result.error); + } + } catch (error) { + console.log(' ❌ Shopify test failed:', error); + } + // Test 18: WooCommerce console.log('\n18. Testing WooCommerce webhook...'); try { @@ -528,6 +576,30 @@ try { console.log(' ❌ WooCommerce test failed:', error); } + // Test 18.5: Polar (Standard Webhooks) + console.log('\n18.5. Testing Polar webhook...'); + try { + const secret = `whsec_${Buffer.from(testSecret).toString('base64')}`; + const webhookId = 'polar-webhook-id-1'; + const timestamp = Math.floor(Date.now() / 1000); + const signature = createStandardWebhooksSignature(testBody, secret, webhookId, timestamp); + const request = createMockRequest({ + 'webhook-signature': signature, + 'webhook-id': webhookId, + 'webhook-timestamp': timestamp.toString(), + 'user-agent': 'Polar.sh Webhooks', + 'content-type': 'application/json', + }); + + const result = await WebhookVerificationService.verifyWithPlatformConfig(request, 'polar', secret); + const detectedPlatform = WebhookVerificationService.detectPlatform(request); + + console.log(' ✅ Polar verification:', result.isValid ? 'PASSED' : 'FAILED'); + console.log(' ✅ Polar auto-detect:', detectedPlatform === 'polar' ? 'PASSED' : 'FAILED'); + } catch (error) { + console.log(' ❌ Polar test failed:', error); + } + // Test 19: Replicate console.log('\n19. Testing Replicate webhook...'); try { diff --git a/src/utils.ts b/src/utils.ts index 4675679..0e8ea2e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -160,6 +160,13 @@ export function detectPlatformFromHeaders(headers: Headers): WebhookPlatform | n return 'polar'; } + if (headerMap.has('webhook-signature')) { + const userAgent = (headerMap.get('user-agent') || '').toLowerCase(); + if (userAgent.includes('polar')) { + return 'polar'; + } + } + // Supabase if (headerMap.has('x-webhook-token')) { return 'supabase'; diff --git a/src/verifiers/algorithms.ts b/src/verifiers/algorithms.ts index 237f457..32aa095 100644 --- a/src/verifiers/algorithms.ts +++ b/src/verifiers/algorithms.ts @@ -191,11 +191,14 @@ export abstract class AlgorithmBasedVerifier extends WebhookVerifier { algorithm: string = 'sha256', ): boolean { const secretEncoding = this.config.customConfig?.secretEncoding || 'base64'; - const secretMaterial = secretEncoding === 'base64' - ? new Uint8Array( - Buffer.from(this.secret.includes('_') ? this.secret.split('_')[1] : this.secret, 'base64'), - ) - : this.secret; + + let secretMaterial: string | Uint8Array = this.secret; + if (secretEncoding === 'base64') { + const base64Secret = this.secret.includes('_') + ? this.secret.split('_').slice(1).join('_') + : this.secret; + secretMaterial = new Uint8Array(Buffer.from(base64Secret, 'base64')); + } const hmac = createHmac(algorithm, secretMaterial); hmac.update(payload);