Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 17 additions & 0 deletions src/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,22 @@ const providerHeaders: Record<Provider, (headers: Headers) => 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=<base64>
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;
Expand Down Expand Up @@ -299,6 +315,7 @@ export function getHeaderNames(provider: Provider): Record<string, string> {
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];
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -48,6 +49,7 @@ export const providers: Record<Provider, ProviderVerifier> = {
twilio,
typeform,
vercel,
vipps,
zendesk,
};

Expand All @@ -73,5 +75,6 @@ export {
twilio,
typeform,
vercel,
vipps,
zendesk,
};
104 changes: 104 additions & 0 deletions src/providers/vipps.ts
Original file line number Diff line number Diff line change
@@ -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=<base64>
* - 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: "<signature>|t=<date>|c=<content-hash>"
* (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;
}

// "<signature>|t=<date>|c=<content-hash>"
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);
},
};
24 changes: 22 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export type Provider =
| 'square'
| 'hubspot'
| 'segment'
| 'homeassistant';
| 'homeassistant'
| 'vipps';

/**
* Base options available to all providers
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading