From bf7522aa134fd3ecca06c89967a437e451d4b22f Mon Sep 17 00:00:00 2001 From: "Knut Martin Torn knutmt@gmail.com" Date: Thu, 3 Sep 2026 13:53:02 +0200 Subject: [PATCH] feat: add Vipps MobilePay provider Vipps uses the Azure API Management scheme rather than a plain body HMAC, so the generic handlers could not express it: the signed string is METHOD\nPATH_AND_QUERY\nDATE;HOST;CONTENT_HASH which covers the method, path, host and a SHA-256 of the body, not the body itself. The existing {timestamp}/{payload} format has nowhere to put any of that. Two details worth calling out: Verification is two steps. Because the signature covers a *hash* of the body, checking the signature alone would accept a swapped payload that still carries a valid signature. x-ms-content-sha256 is checked against the received bytes first, and there is a test for exactly that swap. The signature is passed pipe-delimited rather than comma-delimited as the other multi-part providers do, because x-ms-date is RFC1123 and contains a comma of its own ("Thu, 30 Mar 2023 08:38:32 GMT"). Host and path come from the required url option rather than the inbound Host header, following square and hubspot, so a proxy rewriting Host cannot break verification. 17 tests, covering the tampered-body swap, a mismatched content hash, stale and unparseable dates, wrong host, wrong path, query strings, custom methods and secret rotation. Full suite 111 passing, build clean. --- README.md | 45 +++++++++++++- src/headers.ts | 17 ++++++ src/index.ts | 2 +- src/providers/index.ts | 3 + src/providers/vipps.ts | 104 +++++++++++++++++++++++++++++++ src/types.ts | 24 +++++++- test/verify.test.ts | 135 ++++++++++++++++++++++++++++++++++++++++- 7 files changed, 325 insertions(+), 5 deletions(-) create mode 100644 src/providers/vipps.ts diff --git a/README.md b/README.md index 316c955..8b97d91 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Works with Codehooks.io](https://img.shields.io/badge/works%20with-codehooks.io-blue)](https://codehooks.io) [![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](https://www.npmjs.com/package/webhook-verify) -**One API for all your webhooks.** Verify signatures from Stripe, GitHub, Shopify, Slack, and 17 other providers with a single, consistent interface. +**One API for all your webhooks.** Verify signatures from Stripe, GitHub, Shopify, Slack, and 18 other providers with a single, consistent interface. ```typescript // Same pattern for every provider @@ -70,6 +70,7 @@ if (!isValid) { | Square | `x-square-hmacsha256-signature` | HMAC-SHA256 | | HubSpot | `X-HubSpot-Signature-V3` | HMAC-SHA256 + timestamp | | Segment | `X-Signature` | HMAC-SHA1 | +| Vipps | `Authorization` (Azure APIM) | HMAC-SHA256 + timestamp | ## API @@ -426,6 +427,35 @@ rest_command: payload: '{"entity_id": "{{ entity_id }}", "state": "{{ state }}"}' ``` +### Vipps MobilePay + +Vipps uses the Azure API Management scheme: the `Authorization` header carries +the signature, and the signed string covers the HTTP method, path, date, host +and a SHA-256 hash of the body. Pass the URL you registered with Vipps — host +and path are taken from it, so a proxy rewriting `Host` cannot break +verification. + +```typescript +app.post('/api/vipps/webhooks', async (req, res) => { + const isValid = verify('vipps', req.rawBody, req.headers, process.env.VIPPS_WEBHOOK_SECRET, { + url: 'https://api.example.com/api/vipps/webhooks', + }); + + if (!isValid) { + return res.status(401).json({ error: 'Invalid signature' }); + } + + const event = JSON.parse(req.rawBody); + // event.name e.g. 'recurring.agreement-stopped.v1' + res.status(200).end(); +}); +``` + +The body hash in `x-ms-content-sha256` is checked against the bytes you received +before the signature is verified. The signature covers a hash of the body rather +than the body itself, so without that check a swapped payload carrying a valid +signature would pass. + ### Crystallize ```typescript @@ -552,6 +582,19 @@ verify('hubspot', payload, signature, secret, { }); ``` +### Vipps URL + +Vipps requires the registered webhook URL, and optionally the method and a +timestamp tolerance: + +```typescript +verify('vipps', payload, signature, secret, { + url: 'https://api.example.com/api/vipps/webhooks', + method: 'POST', // optional, defaults to 'POST' + tolerance: 300, // optional, seconds, defaults to 300 +}); +``` + ## Generic Algorithm Handlers For providers not explicitly supported, or for custom verification logic, use the generic handlers: diff --git a/src/headers.ts b/src/headers.ts index 2fba7db..65a6210 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -212,6 +212,22 @@ const providerHeaders: Record SignatureData | nu }; }, + vipps: (headers) => { + const authorization = getHeader(headers, 'authorization'); + const date = getHeader(headers, 'x-ms-date'); + const contentHash = getHeader(headers, 'x-ms-content-sha256'); + if (!authorization || !date || !contentHash) return null; + // Authorization: HMAC-SHA256 SignedHeaders=...&Signature= + const signature = /Signature=([^&\s]+)/.exec(authorization)?.[1]; + if (!signature) return null; + // Pipe-delimited: the RFC1123 date contains a comma + return { + signature: `${signature}|t=${date}|c=${contentHash}`, + rawSignature: authorization, + timestamp: date, + }; + }, + segment: (headers) => { const signature = getHeader(headers, 'x-signature'); if (!signature) return null; @@ -299,6 +315,7 @@ export function getHeaderNames(provider: Provider): Record { square: { signature: 'x-square-hmacsha256-signature' }, hubspot: { signature: 'x-hubspot-signature-v3', timestamp: 'x-hubspot-request-timestamp' }, segment: { signature: 'x-signature' }, + vipps: { signature: 'authorization', timestamp: 'x-ms-date', contentHash: 'x-ms-content-sha256' }, }; return headerMap[provider]; diff --git a/src/index.ts b/src/index.ts index a672466..f7ea35d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -95,7 +95,7 @@ export function isProviderSupported(provider: string): provider is Provider { } // Re-export types -export type { Provider, VerifyOptions, BaseOptions, TimestampOptions, TwilioOptions, CrystallizeOptions, SquareOptions, HubSpotOptions } from './types.js'; +export type { Provider, VerifyOptions, BaseOptions, TimestampOptions, TwilioOptions, CrystallizeOptions, SquareOptions, HubSpotOptions, VippsOptions } from './types.js'; // Re-export individual providers for direct access export * from './providers/index.js'; diff --git a/src/providers/index.ts b/src/providers/index.ts index 5e02d3c..22372e3 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -18,6 +18,7 @@ import { stripe } from './stripe.js'; import { svix } from './svix.js'; import { twilio } from './twilio.js'; import { typeform } from './typeform.js'; +import { vipps } from './vipps.js'; import { vercel } from './vercel.js'; import { zendesk } from './zendesk.js'; @@ -48,6 +49,7 @@ export const providers: Record = { twilio, typeform, vercel, + vipps, zendesk, }; @@ -73,5 +75,6 @@ export { twilio, typeform, vercel, + vipps, zendesk, }; diff --git a/src/providers/vipps.ts b/src/providers/vipps.ts new file mode 100644 index 0000000..ba79809 --- /dev/null +++ b/src/providers/vipps.ts @@ -0,0 +1,104 @@ +import { createHash, createHmac } from 'crypto'; +import { secureCompare } from '../utils/crypto.js'; +import type { ProviderVerifier, VippsOptions } from '../types.js'; + +/** + * Vipps MobilePay webhook verification + * + * Vipps uses the Azure API Management signature scheme. The request carries: + * - Authorization: HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature= + * - x-ms-date: RFC1123 timestamp, e.g. "Thu, 30 Mar 2023 08:38:32 GMT" + * - x-ms-content-sha256: base64 SHA-256 of the raw body + * + * The signed string is, with \n and not \r\n: + * + * METHOD\nPATH_AND_QUERY\nDATE;HOST;CONTENT_HASH + * + * signed with HMAC-SHA256 using the webhook secret as-is (it is not + * base64-decoded first) and base64-encoded. + * + * Verification is two steps, not one. The signature covers a *hash* of the body + * rather than the body itself, so checking the signature alone would accept a + * swapped payload carrying a still-valid signature. The content hash is checked + * against the received bytes first. + * + * Requires the `url` option: host and path-and-query are taken from the URL you + * registered with Vipps rather than from the inbound Host header, so a proxy + * rewriting Host cannot break verification. + * + * For this library, pass the signature as: "|t=|c=" + * (pipe-delimited, because the RFC1123 date itself contains a comma). Passing + * the headers object to verify() does this for you. + * + * @see https://developer.vippsmobilepay.com/docs/APIs/webhooks-api/request-authentication/ + */ +export const vipps: ProviderVerifier = { + verify(payload, signature, secret, options) { + if (!payload || !signature || !secret) { + return false; + } + + const opts = options as VippsOptions | undefined; + const url = opts?.url; + if (!url) { + return false; + } + + // "|t=|c=" + const parts = signature.split('|'); + let sig = parts[0]; + let date: string | undefined; + let contentHash: string | undefined; + + for (const part of parts.slice(1)) { + if (part.startsWith('t=')) { + date = part.slice(2); + } else if (part.startsWith('c=')) { + contentHash = part.slice(2); + } + } + + if (!sig || !date || !contentHash) { + return false; + } + + // Tolerate the full Authorization header value being passed as the signature + const signatureParam = /Signature=([^&\s]+)/.exec(sig); + if (signatureParam?.[1]) { + sig = signatureParam[1]; + } + + // Step 1: the claimed content hash must match the bytes we actually received + const computedHash = createHash('sha256').update(payload).digest('base64'); + if (!secureCompare(computedHash, contentHash)) { + return false; + } + + // Reject stale deliveries. x-ms-date is RFC1123, not a Unix timestamp. + const tolerance = opts?.tolerance ?? 300; + const sentAt = Date.parse(date); + if (Number.isNaN(sentAt)) { + return false; + } + if (Math.abs(Date.now() - sentAt) > tolerance * 1000) { + return false; + } + + let host: string; + let pathAndQuery: string; + try { + const parsed = new URL(url); + host = parsed.host; + pathAndQuery = parsed.pathname + parsed.search; + } catch { + return false; + } + + // Step 2: METHOD\nPATH_AND_QUERY\nDATE;HOST;CONTENT_HASH + const method = opts?.method ?? 'POST'; + const signedString = `${method}\n${pathAndQuery}\n${date};${host};${contentHash}`; + const computed = createHmac('sha256', secret).update(signedString).digest('base64'); + + return secureCompare(computed, sig); + }, +}; diff --git a/src/types.ts b/src/types.ts index 6b632bd..387a25b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,7 +23,8 @@ export type Provider = | 'square' | 'hubspot' | 'segment' - | 'homeassistant'; + | 'homeassistant' + | 'vipps'; /** * Base options available to all providers @@ -99,10 +100,29 @@ export interface HubSpotOptions extends BaseOptions { tolerance?: number; } +/** + * Vipps MobilePay-specific options requiring the registered webhook URL + */ +export interface VippsOptions extends BaseOptions { + /** + * The full URL of the webhook endpoint as registered with Vipps + * (required: host and path-and-query are taken from it) + */ + url: string; + /** + * The HTTP method (default: 'POST') + */ + method?: string; + /** + * Maximum age of the webhook in seconds (default: 300 = 5 minutes) + */ + tolerance?: number; +} + /** * Provider-specific verification options */ -export type VerifyOptions = BaseOptions | TimestampOptions | TwilioOptions | CrystallizeOptions | SquareOptions | HubSpotOptions; +export type VerifyOptions = BaseOptions | TimestampOptions | TwilioOptions | CrystallizeOptions | SquareOptions | HubSpotOptions | VippsOptions; /** * Internal interface for provider verification functions diff --git a/test/verify.test.ts b/test/verify.test.ts index 28024a0..61d1744 100644 --- a/test/verify.test.ts +++ b/test/verify.test.ts @@ -1,6 +1,6 @@ import { describe, it } from 'node:test'; import assert from 'node:assert'; -import { createHmac } from 'node:crypto'; +import { createHash, createHmac } from 'node:crypto'; import { verify, getSupportedProviders, @@ -583,6 +583,139 @@ describe('webhook-verify', () => { }); }); + describe('Vipps MobilePay', () => { + const secret = 'test-webhook-secret'; + const url = 'https://api.example.com/api/vipps/webhooks'; + const payload = '{"name":"recurring.agreement-stopped.v1"}'; + + function contentHash(body: string): string { + return createHash('sha256').update(body).digest('base64'); + } + + function sign( + body: string, + key: string, + opts: { date?: string; method?: string; host?: string; path?: string; hash?: string } = {} + ): { signature: string; date: string; hash: string } { + const date = opts.date ?? new Date().toUTCString(); + const hash = opts.hash ?? contentHash(body); + const host = opts.host ?? 'api.example.com'; + const path = opts.path ?? '/api/vipps/webhooks'; + const method = opts.method ?? 'POST'; + const signedString = `${method}\n${path}\n${date};${host};${hash}`; + const sig = createHmac('sha256', key).update(signedString).digest('base64'); + return { signature: `${sig}|t=${date}|c=${hash}`, date, hash }; + } + + it('should verify a valid signature', () => { + const { signature } = sign(payload, secret); + assert.strictEqual(verify('vipps', payload, signature, secret, { url }), true); + }); + + it('should verify from a headers object', () => { + const date = new Date().toUTCString(); + const hash = contentHash(payload); + const signedString = `POST\n/api/vipps/webhooks\n${date};api.example.com;${hash}`; + const sig = createHmac('sha256', secret).update(signedString).digest('base64'); + const headers = { + authorization: `HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=${sig}`, + 'x-ms-date': date, + 'x-ms-content-sha256': hash, + }; + assert.strictEqual(verify('vipps', payload, headers, secret, { url }), true); + }); + + it('should reject a tampered body that carries a valid signature', () => { + // The signature covers a hash of the body, not the body. Without the + // content-hash check this swap would verify. + const { signature } = sign(payload, secret); + const tampered = '{"name":"recurring.charge.captured"}'; + assert.strictEqual(verify('vipps', tampered, signature, secret, { url }), false); + }); + + it('should reject a content hash that does not match the body', () => { + const { signature } = sign(payload, secret, { hash: contentHash('something else') }); + assert.strictEqual(verify('vipps', payload, signature, secret, { url }), false); + }); + + it('should reject without url option', () => { + const { signature } = sign(payload, secret); + assert.strictEqual(verify('vipps', payload, signature, secret), false); + }); + + it('should reject an invalid signature', () => { + const date = new Date().toUTCString(); + const sig = `invalid|t=${date}|c=${contentHash(payload)}`; + assert.strictEqual(verify('vipps', payload, sig, secret, { url }), false); + }); + + it('should reject a stale x-ms-date', () => { + const old = new Date(Date.now() - 600_000).toUTCString(); + const { signature } = sign(payload, secret, { date: old }); + assert.strictEqual(verify('vipps', payload, signature, secret, { url }), false); + }); + + it('should accept a stale date within a raised tolerance', () => { + const old = new Date(Date.now() - 600_000).toUTCString(); + const { signature } = sign(payload, secret, { date: old }); + assert.strictEqual(verify('vipps', payload, signature, secret, { url, tolerance: 900 }), true); + }); + + it('should reject an unparseable date', () => { + const { signature } = sign(payload, secret, { date: 'not-a-date' }); + assert.strictEqual(verify('vipps', payload, signature, secret, { url }), false); + }); + + it('should reject when signed for a different path', () => { + const { signature } = sign(payload, secret, { path: '/api/other/webhooks' }); + assert.strictEqual(verify('vipps', payload, signature, secret, { url }), false); + }); + + it('should reject when signed for a different host', () => { + const { signature } = sign(payload, secret, { host: 'evil.example.com' }); + assert.strictEqual(verify('vipps', payload, signature, secret, { url }), false); + }); + + it('should verify with a custom method', () => { + const { signature } = sign(payload, secret, { method: 'PUT' }); + assert.strictEqual(verify('vipps', payload, signature, secret, { url, method: 'PUT' }), true); + }); + + it('should include the query string in the signed path', () => { + const withQuery = 'https://api.example.com/api/vipps/webhooks?x=1'; + const { signature } = sign(payload, secret, { path: '/api/vipps/webhooks?x=1' }); + assert.strictEqual(verify('vipps', payload, signature, secret, { url: withQuery }), true); + }); + + it('should tolerate the full Authorization value as the signature', () => { + const date = new Date().toUTCString(); + const hash = contentHash(payload); + const signedString = `POST\n/api/vipps/webhooks\n${date};api.example.com;${hash}`; + const sig = createHmac('sha256', secret).update(signedString).digest('base64'); + const full = `HMAC-SHA256 SignedHeaders=x-ms-date;host;x-ms-content-sha256&Signature=${sig}|t=${date}|c=${hash}`; + assert.strictEqual(verify('vipps', payload, full, secret, { url }), true); + }); + + it('should support secret rotation via additionalSecrets', () => { + const { signature } = sign(payload, 'old-secret'); + assert.strictEqual( + verify('vipps', payload, signature, 'new-secret', { url, additionalSecrets: ['old-secret'] }), + true + ); + }); + + it('should throw when signature headers are missing', () => { + assert.throws(() => verify('vipps', payload, { 'x-ms-date': 'x' }, secret, { url })); + }); + + it('should return Vipps header names', () => { + const names = getHeaderNames('vipps'); + assert.strictEqual(names.signature, 'authorization'); + assert.strictEqual(names.timestamp, 'x-ms-date'); + assert.strictEqual(names.contentHash, 'x-ms-content-sha256'); + }); + }); + describe('Segment', () => { const secret = 'test-shared-secret'; const payload = '{"type":"track","event":"Order Completed"}';