* Verify a Loops webhook signature. * Loops uses a Svix-compatible signing scheme (its own implementation, not Svix-hosted): * HMAC-SHA256 of `${webhookId}.${timestamp}.${body}` signed with the base64-decoded signing * secret (provided as `prefix_base64string`). Delivery metadata arrives in the `
( secret: string, webhookId: string, timestamp: string, signatureHeader: string, rawBody: string )
| 24 | * @see https://loops.so/docs/webhooks |
| 25 | */ |
| 26 | function verifyLoopsSignature( |
| 27 | secret: string, |
| 28 | webhookId: string, |
| 29 | timestamp: string, |
| 30 | signatureHeader: string, |
| 31 | rawBody: string |
| 32 | ): boolean { |
| 33 | try { |
| 34 | const ts = Number.parseInt(timestamp, 10) |
| 35 | const now = Math.floor(Date.now() / 1000) |
| 36 | if (Number.isNaN(ts) || Math.abs(now - ts) > LOOPS_WEBHOOK_TIMESTAMP_SKEW_SECONDS) { |
| 37 | return false |
| 38 | } |
| 39 | |
| 40 | const base64Secret = secret.includes('_') ? secret.slice(secret.indexOf('_') + 1) : secret |
| 41 | const secretBytes = Buffer.from(base64Secret, 'base64') |
| 42 | const toSign = `${webhookId}.${timestamp}.${rawBody}` |
| 43 | const expectedSignature = hmacSha256Base64(toSign, secretBytes) |
| 44 | |
| 45 | const providedSignatures = signatureHeader.split(' ') |
| 46 | for (const versionedSig of providedSignatures) { |
| 47 | const parts = versionedSig.split(',') |
| 48 | const sig = parts.length === 2 ? parts[1] : versionedSig |
| 49 | if (sig && safeCompare(sig, expectedSignature)) { |
| 50 | return true |
| 51 | } |
| 52 | } |
| 53 | return false |
| 54 | } catch (error) { |
| 55 | logger.error('Error verifying Loops signature:', error) |
| 56 | return false |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | export const loopsHandler: WebhookProviderHandler = { |
| 61 | async verifyAuth({ |
no test coverage detected