Skip to content

Commit d8ad8b1

Browse files
gh-workflow-token-generator[bot]Mohamed Elghobaty
authored andcommitted
verify webhook signature + update webhook schema and types
1 parent f8789e4 commit d8ad8b1

22 files changed

+980
-243
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@team-plain/typescript-sdk': major
3+
---
4+
5+
Upgrade the SDK webhook parsing to support webhook version '2024-09-18'.
6+
7+
### Breaking Changes
8+
If your Plain webhook target is on the legacy/unversioned version, the SDK will no longer be able to parse the webhook payload. You must update your webhook target to '2024-09-18'. Refer to [our docs](https://www.plain.com/docs/api-reference/webhooks/versions) for more information and instructions on how to safely upgrade your webhook target.
9+
10+
### Added
11+
`verifyPlainWebhook` method to verify the webhook signature, with support for replay attack protection, and parse the webhook payload. Refer to the [README](./README.md) for usage instructions.

README.md

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @team-plain/typescript-sdk
22

3-
[Changelog]('./CHANGELOG.md')
3+
[Changelog](./CHANGELOG.md)
44

55
## Plain Client
66

@@ -104,16 +104,38 @@ Fallback error type when something unexpected happens.
104104

105105
## Webhooks
106106

107-
This package also provides functionality to validate our [Webhook payloads](https://www.plain.com/docs/api-reference/webhooks).
107+
Plain signs the [webhooks](https://www.plain.com/docs/api-reference/webhooks) it sends to your endpoint,
108+
allowing you to validate that they were not sent by a third-party. You can read more about it [here](https://www.plain.com/docs/api-reference/request-signing).
109+
The SDK provides a convenient helper function to verify the signature, prevent replay attacks, and parse the payload to a typed object.
108110

109111
```ts
110-
import { parsePlainWebhook } from '@team-plain/typescript-sdk';
111-
112-
const payload = { ... };
113-
114-
if(parsePlainWebhook(payload)) {
115-
// payload is now typed!
116-
doYourThing(payload);
112+
import { verifyPlainWebhook, PlainWebhookSignatureVerificationError, PlainWebhookVersionMismatchError } from '@team-plain/typescript-sdk';
113+
114+
// Please note that you must pass the raw request body, exactly as received from Plain,
115+
// to the verifyPlainWebhook() function; this will not work with a parsed (i.e., JSON) request body.
116+
const payload = '...';
117+
118+
// The value of the `Plain-Request-Signature` header from the webhook request.
119+
const signature = '...';
120+
121+
// Plain Request Signature Secret. You can find this in Plain's settings.
122+
const secret = '...';
123+
124+
try {
125+
const webhook = verifyPlainWebhook(payload, signature, secret);
126+
// webhook is now a typed object.
127+
} catch (error) {
128+
if (error instanceof PlainWebhookSignatureVerificationError) {
129+
// Signature verification failed.
130+
} else if (error instanceof PlainWebhookVersionMismatchError) {
131+
// The SDK is not compatible with the received webhook version.
132+
// Consider updating the SDK and the webhook target to the latest version.
133+
// Consult the changelog or https://plain.com/docs/api-reference/webhooks/versions for more information.
134+
} else {
135+
// Unexpected error. Most likely due to an error in Plain's webhook server or a bug in the SDK.
136+
// Treate this as a 500 response from Plain.
137+
// We also recommend logging the error and sharing it with Plain's support team.
138+
}
117139
}
118140
```
119141

biome.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
}
2424
},
2525
"files": {
26-
"include": ["biome.json", "vitest.*.js", "src/**/*.ts", "src/**/*.gql"],
26+
"include": [
27+
"biome.json",
28+
"vitest.*.js",
29+
"src/**/*.ts",
30+
"src/**/*.gql"
31+
],
2732
"ignore": [
2833
"dist/**",
2934
"node_modules/**",
@@ -59,4 +64,4 @@
5964
"organizeImports": {
6065
"enabled": true
6166
}
62-
}
67+
}

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"@graphql-codegen/typescript-document-nodes": "^3.0.4",
3232
"@graphql-codegen/typescript-operations": "^3.0.4",
3333
"@rollup/plugin-json": "^6.1.0",
34+
"@types/lodash.get": "^4.4.9",
3435
"esbuild": "^0.17.18",
3536
"json-schema-to-typescript": "^13.1.2",
3637
"rollup": "^3.21.5",
@@ -46,6 +47,7 @@
4647
"ajv": "^8.12.0",
4748
"ajv-formats": "^2.1.1",
4849
"graphql": "^16.6.0",
50+
"lodash.get": "^4.4.2",
4951
"zod": "3.22.4"
5052
}
5153
}

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/codegen-webhooks.sh

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11

22
#!/bin/bash
3-
43
# Download the JSON schema
5-
curl https://core-api.uk.plain.com/webhooks/schema.json -o ./src/webhooks/webhook-schema.json
4+
curl https://core-api.uk.plain.com/webhooks/schema/latest.json -o ./src/webhooks/webhook-schema.json
65

7-
./node_modules/.bin/json2ts --input ./src/webhooks/webhook-schema.json --output ./src/webhooks/webhook-schema.ts
6+
./node_modules/.bin/json2ts --input ./src/webhooks/webhook-schema.json --output ./src/webhooks/webhook-schema.ts

src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,15 @@
22

33
export { PlainClient } from './client';
44

5-
export { parsePlainWebhook } from './webhooks';
5+
export {
6+
parsePlainWebhook,
7+
verifyPlainWebhook,
8+
PlainWebhookError,
9+
PlainWebhookPayloadError,
10+
PlainWebhookSignatureVerificationError,
11+
PlainWebhookVersionMismatchError,
12+
} from './webhooks';
13+
614
export type {
715
WebhooksSchemaDefinition,
816
CustomerChangedPayload,

src/result.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,7 @@ type Err<U> = {
1111
};
1212

1313
export type Result<T, U> = NonNullable<Data<T> | Err<U>>;
14+
15+
export const isErr = <T, U>(result: Result<T, U>): result is Err<U> => {
16+
return !!result.error;
17+
};

src/tests/parse-webhook.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { describe, expect, test } from 'vitest';
1+
import { describe, expect, it, test } from 'vitest';
22

3+
import { PlainWebhookPayloadError, PlainWebhookVersionMismatchError } from '../webhooks/errors';
34
import { parsePlainWebhook } from '../webhooks/parse';
45
import customerCreatedPayload from './webhook-payloads/customer-created';
56
import emailReceivedPayload from './webhook-payloads/email-received';
@@ -24,4 +25,45 @@ describe('Parse webhook', () => {
2425
test('should fail to validate an invalid payload', () => {
2526
expect(parsePlainWebhook(invalidWebhook).error).toBeTruthy();
2627
});
28+
29+
it('accepts a stringified payload', () => {
30+
const result = parsePlainWebhook(JSON.stringify(threadCreatedPayload));
31+
expect(result.data).toBeTruthy();
32+
});
33+
34+
it('returns a human-readable error message when the payload is not a valid webhook payload', () => {
35+
const invalidPayload = {
36+
...threadCreatedPayload,
37+
payload: {
38+
...threadCreatedPayload.payload,
39+
thread: {
40+
...threadCreatedPayload.payload.thread,
41+
title: undefined,
42+
},
43+
},
44+
};
45+
46+
const result = parsePlainWebhook(invalidPayload);
47+
48+
expect(result.error).instanceOf(PlainWebhookPayloadError);
49+
expect(result.error?.message).toBe("data/payload/thread must have required property 'title'");
50+
});
51+
52+
it('returns a version mismatch error', () => {
53+
const invalidWebhook = {
54+
...threadCreatedPayload,
55+
56+
webhookMetadata: {
57+
...threadCreatedPayload.webhookMetadata,
58+
webhookTargetVersion: 'NEW_VERSION',
59+
},
60+
};
61+
62+
const result = parsePlainWebhook(invalidWebhook);
63+
64+
expect(result.error).instanceOf(PlainWebhookVersionMismatchError);
65+
expect(result.error?.message).toBe(
66+
'The webhook payload (version=NEW_VERSION) is incompatible with the current version of the SDK. Please upgrade both the SDK and the webhook target to the latest version. Refer to https://www.plain.com/docs/api-reference/webhooks/versions for more information. Original error: data/webhookMetadata/webhookTargetVersion must be equal to constant'
67+
);
68+
});
2769
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2+
import {
3+
PlainWebhookPayloadError,
4+
PlainWebhookSignatureVerificationError,
5+
} from '../webhooks/errors';
6+
import { verifyPlainWebhook } from '../webhooks/verify';
7+
import threadCreatedPayload from './webhook-payloads/thread-created';
8+
9+
describe('verifyPlainWebhook', () => {
10+
beforeEach(() => {
11+
vi.useFakeTimers();
12+
});
13+
14+
afterEach(() => {
15+
vi.useRealTimers();
16+
});
17+
18+
it('returns an error when the payload is empty', () => {
19+
const result = verifyPlainWebhook('', 'signature', 'secret');
20+
21+
expect(result.error).instanceOf(PlainWebhookSignatureVerificationError);
22+
expect(result.error?.message).toBe('No webhook payload was provided.');
23+
});
24+
25+
it('returns an error when the signature is empty', () => {
26+
const result = verifyPlainWebhook('payload', '', 'secret');
27+
28+
expect(result.error).instanceOf(PlainWebhookSignatureVerificationError);
29+
expect(result.error?.message).toBe(
30+
'No signature header value was provided. Please pass the value of the "Plain-Request-Signature" header.'
31+
);
32+
});
33+
34+
it('returns an error when the secret is empty', () => {
35+
const result = verifyPlainWebhook('payload', 'signature', '');
36+
37+
expect(result.error).instanceOf(PlainWebhookSignatureVerificationError);
38+
expect(result.error?.message).toBe(
39+
'No webhook secret was provided. You can find your webhook secret in your workspace settings.'
40+
);
41+
});
42+
43+
it('returns an error when the signature does not match', () => {
44+
const result = verifyPlainWebhook('payload', 'signature', 'secret');
45+
46+
expect(result.error).instanceOf(PlainWebhookSignatureVerificationError);
47+
expect(result.error?.message).toBe('The signature provided is invalid.');
48+
});
49+
50+
it('returns an error when the signature matches but the timestamp is too far in the past', () => {
51+
const result = verifyPlainWebhook(
52+
JSON.stringify(threadCreatedPayload),
53+
'22f44d327c69161903b4656717862e5a535e93248e70f0d42c4d0a52962ce0e9',
54+
'secret'
55+
);
56+
57+
expect(result.error).instanceOf(PlainWebhookSignatureVerificationError);
58+
expect(result.error?.message).toBe(
59+
'The timestamp provided in the webhook payload is too far in the past. The maximum allowed difference is 300 seconds.'
60+
);
61+
});
62+
63+
it("doesn't return an error when the signature matches and the timestamp is within the tolerance", () => {
64+
// +5 minutes - 1 second
65+
vi.setSystemTime(new Date(Date.UTC(2023, 9, 19, 14, 17, 26)));
66+
67+
const result = verifyPlainWebhook(
68+
JSON.stringify(threadCreatedPayload),
69+
'22f44d327c69161903b4656717862e5a535e93248e70f0d42c4d0a52962ce0e9',
70+
'secret'
71+
);
72+
73+
expect(result.error).toBeUndefined();
74+
expect(result.data?.type).toBe('thread.thread_created');
75+
});
76+
77+
it('returns an error when the payload is not a valid JSON object', () => {
78+
const result = verifyPlainWebhook(
79+
'hello-world',
80+
'1bff4699de4fb5202a4b1e6cefd7b5fdfb02d19a67a1eb371dd417a45b0a47df',
81+
'secret'
82+
);
83+
84+
expect(result.error).instanceOf(PlainWebhookPayloadError);
85+
expect(result.error?.message).toBe('The webhook payload is not a valid JSON object.');
86+
});
87+
88+
it('returns an error when the payload is not a valid webhook payload', () => {
89+
const invalidPayload = {
90+
...threadCreatedPayload,
91+
payload: {
92+
...threadCreatedPayload.payload,
93+
thread: {
94+
...threadCreatedPayload.payload.thread,
95+
title: undefined,
96+
},
97+
},
98+
};
99+
100+
const result = verifyPlainWebhook(
101+
JSON.stringify(invalidPayload),
102+
'd7476d183d9e9a52dd7796c769641b89fe61443f62ca8d68c720815a9cf43ca6',
103+
'secret'
104+
);
105+
106+
expect(result.error).instanceOf(PlainWebhookPayloadError);
107+
expect(result.error?.message).toBe("data/payload/thread must have required property 'title'");
108+
});
109+
});

0 commit comments

Comments
 (0)