Skip to content

Commit 5aa6e03

Browse files
feat(webhooks): verifyAndParse* API for compressed payloads (CHA-3071) (#294)
Co-authored-by: Zita Szupera <szuperaz@gmail.com>
1 parent e03176f commit 5aa6e03

4 files changed

Lines changed: 542 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import crypto from 'crypto';
2+
import zlib from 'zlib';
3+
4+
import { beforeEach, describe, expect, it } from 'vitest';
5+
6+
import { StreamClient } from '../src/StreamClient';
7+
import {
8+
decodeSnsPayload,
9+
decodeSqsPayload,
10+
gunzipPayload,
11+
InvalidWebhookError,
12+
InvalidWebhookErrorMessages,
13+
parseEvent,
14+
parseSns,
15+
parseSqs,
16+
verifyAndParseWebhook,
17+
verifySignature,
18+
} from '../src/utils/webhook';
19+
20+
const JSON_BODY =
21+
'{"type":"user.updated","created_at":"2026-04-08T14:23:35.879421674Z","user":{"id":"u-1"}}';
22+
const API_KEY = 'tkey';
23+
const API_SECRET = 'tsec2';
24+
25+
const sign = (body: Buffer | string) =>
26+
crypto
27+
.createHmac('sha256', Buffer.from(API_SECRET, 'utf8'))
28+
.update(body)
29+
.digest('hex');
30+
31+
const gzip = (body: Buffer | string) =>
32+
zlib.gzipSync(Buffer.isBuffer(body) ? body : Buffer.from(body));
33+
34+
const base64 = (body: Buffer | string) =>
35+
(Buffer.isBuffer(body) ? body : Buffer.from(body)).toString('base64');
36+
37+
const snsEnvelope = (innerMessage: string) =>
38+
JSON.stringify({
39+
Type: 'Notification',
40+
MessageId: '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324',
41+
TopicArn: 'arn:aws:sns:us-east-1:123456789012:stream-webhooks',
42+
Message: innerMessage,
43+
Timestamp: '2026-05-11T10:00:00.000Z',
44+
SignatureVersion: '1',
45+
MessageAttributes: {
46+
'X-Signature': { Type: 'String', Value: '<signature placeholder>' },
47+
},
48+
});
49+
50+
describe('Webhook verification + parsing', () => {
51+
let client: StreamClient;
52+
53+
beforeEach(() => {
54+
client = new StreamClient(API_KEY, API_SECRET);
55+
});
56+
57+
describe('verifyWebhook (legacy boolean helper, unchanged)', () => {
58+
it('validates a plain JSON body with its HMAC signature', () => {
59+
expect(client.verifyWebhook(JSON_BODY, sign(JSON_BODY))).toBe(true);
60+
});
61+
62+
it('rejects when signature is wrong', () => {
63+
expect(client.verifyWebhook(JSON_BODY, 'deadbeef')).toBe(false);
64+
});
65+
});
66+
67+
describe('verifySignature', () => {
68+
it('returns true for matching HMAC', () => {
69+
expect(verifySignature(JSON_BODY, sign(JSON_BODY), API_SECRET)).toBe(
70+
true,
71+
);
72+
});
73+
74+
it('returns false for mismatched signature', () => {
75+
expect(verifySignature(JSON_BODY, '0'.repeat(64), API_SECRET)).toBe(
76+
false,
77+
);
78+
});
79+
80+
it('returns false for wrong secret', () => {
81+
const sig = crypto
82+
.createHmac('sha256', 'other')
83+
.update(JSON_BODY)
84+
.digest('hex');
85+
expect(verifySignature(JSON_BODY, sig, API_SECRET)).toBe(false);
86+
});
87+
88+
it('rejects signatures computed over compressed bytes', () => {
89+
const compressed = gzip(JSON_BODY);
90+
expect(verifySignature(JSON_BODY, sign(compressed), API_SECRET)).toBe(
91+
false,
92+
);
93+
});
94+
});
95+
96+
describe('gunzipPayload', () => {
97+
it('passes through plain bytes unchanged', () => {
98+
expect(gunzipPayload(JSON_BODY).toString('utf8')).toBe(JSON_BODY);
99+
});
100+
101+
it('passes through Buffer input unchanged', () => {
102+
expect(gunzipPayload(Buffer.from(JSON_BODY)).toString('utf8')).toBe(
103+
JSON_BODY,
104+
);
105+
});
106+
107+
it('inflates gzip-magic bytes', () => {
108+
expect(gunzipPayload(gzip(JSON_BODY)).toString('utf8')).toBe(JSON_BODY);
109+
});
110+
111+
it('returns Buffer in all cases', () => {
112+
expect(Buffer.isBuffer(gunzipPayload(JSON_BODY))).toBe(true);
113+
expect(Buffer.isBuffer(gunzipPayload(gzip(JSON_BODY)))).toBe(true);
114+
});
115+
116+
it('handles empty input', () => {
117+
expect(gunzipPayload(Buffer.alloc(0)).length).toBe(0);
118+
});
119+
120+
it('throws InvalidWebhookError on truncated gzip with magic', () => {
121+
const bad = Buffer.concat([
122+
Buffer.from([0x1f, 0x8b]),
123+
Buffer.from([0, 0, 0]),
124+
]);
125+
expect(() => gunzipPayload(bad)).toThrow(InvalidWebhookError);
126+
expect(() => gunzipPayload(bad)).toThrow(
127+
InvalidWebhookErrorMessages.gzipFailed,
128+
);
129+
});
130+
});
131+
132+
describe('decodeSqsPayload', () => {
133+
it('decodes base64 only (no compression)', () => {
134+
expect(decodeSqsPayload(base64(JSON_BODY)).toString('utf8')).toBe(
135+
JSON_BODY,
136+
);
137+
});
138+
139+
it('decodes base64 + gzip', () => {
140+
expect(decodeSqsPayload(base64(gzip(JSON_BODY))).toString('utf8')).toBe(
141+
JSON_BODY,
142+
);
143+
});
144+
145+
it('throws InvalidWebhookError on malformed base64', () => {
146+
expect(() => decodeSqsPayload('!!!not-base64!!!')).toThrow(
147+
InvalidWebhookError,
148+
);
149+
expect(() => decodeSqsPayload('!!!not-base64!!!')).toThrow(
150+
InvalidWebhookErrorMessages.invalidBase64,
151+
);
152+
});
153+
});
154+
155+
describe('decodeSnsPayload', () => {
156+
it('treats a pre-extracted Message identically to decodeSqsPayload', () => {
157+
const wrapped = base64(gzip(JSON_BODY));
158+
expect(decodeSnsPayload(wrapped).equals(decodeSqsPayload(wrapped))).toBe(
159+
true,
160+
);
161+
});
162+
163+
it('round-trips base64 + gzip (pre-extracted Message)', () => {
164+
expect(decodeSnsPayload(base64(gzip(JSON_BODY))).toString('utf8')).toBe(
165+
JSON_BODY,
166+
);
167+
});
168+
169+
it('unwraps a full SNS HTTP notification envelope', () => {
170+
const wrapped = base64(gzip(JSON_BODY));
171+
const envelope = snsEnvelope(wrapped);
172+
expect(decodeSnsPayload(envelope).toString('utf8')).toBe(JSON_BODY);
173+
});
174+
175+
it('handles whitespace before the envelope JSON', () => {
176+
const wrapped = base64(gzip(JSON_BODY));
177+
const envelope = `\n ${snsEnvelope(wrapped)}`;
178+
expect(decodeSnsPayload(envelope).toString('utf8')).toBe(JSON_BODY);
179+
});
180+
});
181+
182+
describe('parseEvent', () => {
183+
it('parses Buffer payload into a typed event', () => {
184+
const ev = parseEvent(Buffer.from(JSON_BODY));
185+
expect(ev.type).toBe('user.updated');
186+
});
187+
188+
it('parses string payload', () => {
189+
const ev = parseEvent(JSON_BODY);
190+
expect(ev.type).toBe('user.updated');
191+
});
192+
193+
it('throws InvalidWebhookError on malformed JSON', () => {
194+
expect(() => parseEvent('not json')).toThrow(InvalidWebhookError);
195+
expect(() => parseEvent('not json')).toThrow(
196+
InvalidWebhookErrorMessages.invalidJson,
197+
);
198+
});
199+
});
200+
201+
describe('verifyAndParseWebhook', () => {
202+
it('parses a plain HTTP webhook with a valid signature', () => {
203+
const ev = client.verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY));
204+
expect(ev.type).toBe('user.updated');
205+
});
206+
207+
it('parses a gzip-compressed HTTP webhook', () => {
208+
const ev = client.verifyAndParseWebhook(gzip(JSON_BODY), sign(JSON_BODY));
209+
expect(ev.type).toBe('user.updated');
210+
});
211+
212+
it('throws InvalidWebhookError on signature mismatch', () => {
213+
expect(() => client.verifyAndParseWebhook(JSON_BODY, 'deadbeef')).toThrow(
214+
InvalidWebhookError,
215+
);
216+
expect(() => client.verifyAndParseWebhook(JSON_BODY, 'deadbeef')).toThrow(
217+
InvalidWebhookErrorMessages.signatureMismatch,
218+
);
219+
});
220+
221+
it('rejects a gzip body when the signature was computed over compressed bytes', () => {
222+
const compressed = gzip(JSON_BODY);
223+
expect(() =>
224+
client.verifyAndParseWebhook(compressed, sign(compressed)),
225+
).toThrow(InvalidWebhookError);
226+
});
227+
228+
it('also works as a package-level function', () => {
229+
const ev = verifyAndParseWebhook(JSON_BODY, sign(JSON_BODY), API_SECRET);
230+
expect(ev.type).toBe('user.updated');
231+
});
232+
});
233+
234+
describe('parseSqs', () => {
235+
it('parses a base64-only SQS body', () => {
236+
const ev = client.parseSqs(base64(JSON_BODY));
237+
expect(ev.type).toBe('user.updated');
238+
});
239+
240+
it('parses a base64 + gzip SQS body', () => {
241+
const wrapped = base64(gzip(JSON_BODY));
242+
const ev = client.parseSqs(wrapped);
243+
expect(ev.type).toBe('user.updated');
244+
});
245+
246+
it('also works as a package-level function', () => {
247+
const wrapped = base64(gzip(JSON_BODY));
248+
const ev = parseSqs(wrapped);
249+
expect(ev.type).toBe('user.updated');
250+
});
251+
252+
it('surfaces malformed base64 as InvalidWebhookError', () => {
253+
expect(() => client.parseSqs('!!!not-base64!!!')).toThrow(
254+
InvalidWebhookError,
255+
);
256+
});
257+
});
258+
259+
describe('parseSns', () => {
260+
it('parses a pre-extracted base64 + gzip SNS message', () => {
261+
const wrapped = base64(gzip(JSON_BODY));
262+
const ev = client.parseSns(wrapped);
263+
expect(ev.type).toBe('user.updated');
264+
});
265+
266+
it('produces the same event as parseSqs (pre-extracted Message)', () => {
267+
const wrapped = base64(gzip(JSON_BODY));
268+
expect(client.parseSns(wrapped)).toEqual(client.parseSqs(wrapped));
269+
});
270+
271+
it('parses a full SNS HTTP notification envelope', () => {
272+
const wrapped = base64(gzip(JSON_BODY));
273+
const envelope = snsEnvelope(wrapped);
274+
const ev = client.parseSns(envelope);
275+
expect(ev.type).toBe('user.updated');
276+
});
277+
278+
it('also works as a package-level function', () => {
279+
const wrapped = base64(gzip(JSON_BODY));
280+
const ev = parseSns(wrapped);
281+
expect(ev.type).toBe('user.updated');
282+
});
283+
});
284+
});

index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@ export * from './src/StreamVideoClient';
66
export * from './src/gen/models';
77
export * from './src/StreamFeedsClient';
88
export * from './src/StreamFeed';
9+
export {
10+
InvalidWebhookError,
11+
InvalidWebhookErrorMessages,
12+
} from './src/utils/webhook';

src/StreamClient.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import { JWTServerToken, JWTUserToken } from './utils/create-token';
2+
import {
3+
InvalidWebhookError,
4+
parseSns as parseSnsHelper,
5+
parseSqs as parseSqsHelper,
6+
verifyAndParseWebhook as verifyAndParseWebhookHelper,
7+
} from './utils/webhook';
28
import { CommonApi } from './gen/common/CommonApi';
39
import { StreamVideoClient } from './StreamVideoClient';
410
import crypto from 'crypto';
@@ -253,4 +259,46 @@ export class StreamClient extends CommonApi {
253259
return false;
254260
}
255261
};
262+
263+
/**
264+
* Verify and parse an HTTP webhook event.
265+
*
266+
* Decompresses `rawBody` when gzipped (detected from the body bytes),
267+
* verifies the `X-Signature` header against the app's API secret, and
268+
* returns the parsed typed event. Works whether or not Stream is
269+
* currently compressing payloads for this app, and stays correct behind
270+
* middleware that auto-decompresses the request.
271+
*
272+
* @param rawBody Raw HTTP request body bytes Stream signed
273+
* @param signature Value of the `X-Signature` header
274+
* @throws {InvalidWebhookError} When the signature does not match or
275+
* the gzip / JSON envelope is malformed.
276+
*/
277+
verifyAndParseWebhook = (rawBody: string | Buffer, signature: string) => {
278+
if (!this.secret) {
279+
throw new InvalidWebhookError(
280+
'cannot verify webhook signature without an API secret on the client',
281+
);
282+
}
283+
return verifyAndParseWebhookHelper(rawBody, signature, this.secret);
284+
};
285+
286+
/**
287+
* Parse an SQS firehose event: decodes the message `Body` (base64 +
288+
* optional gzip) and returns the parsed typed event. No HMAC
289+
* verification (Stream does not sign SQS bodies).
290+
*
291+
* @param messageBody SQS message `Body` string
292+
* @throws {InvalidWebhookError} When the base64 / gzip envelope is malformed.
293+
*/
294+
parseSqs = (messageBody: string) => parseSqsHelper(messageBody);
295+
296+
/**
297+
* Parse an SNS-delivered event (unwraps envelope JSON when needed, then
298+
* same decode path as SQS). No HMAC verification.
299+
*
300+
* @param notificationBody Raw SNS POST body or pre-extracted `Message` string
301+
* @throws {InvalidWebhookError} When the envelope cannot be decoded.
302+
*/
303+
parseSns = (notificationBody: string) => parseSnsHelper(notificationBody);
256304
}

0 commit comments

Comments
 (0)