Skip to content

Commit 17a80c2

Browse files
authored
Merge pull request #60 from imagekit-developer/dev
Webhook signature
2 parents d1cf79d + cd7e96d commit 17a80c2

File tree

8 files changed

+425
-3
lines changed

8 files changed

+425
-3
lines changed

README.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,76 @@ When you exceed the rate limits for an endpoint, you will receive a `429` status
11251125
| `X-RateLimit-Reset` | The amount of time in milliseconds before you can make another request to this endpoint. Pause/sleep your workflow for this duration. |
11261126
| `X-RateLimit-Interval` | The duration of interval in milliseconds for which this rate limit was exceeded. |
11271127

1128+
## Verify webhook events
1129+
1130+
ImageKit sends `x-ik-signature` in the webhook request header, which can be used to verify the authenticity of the webhook request.
1131+
1132+
Verifying webhook signature is easy with imagekit SDK. All you need is the value of the `x-ik-signature` header, request body, and [webhook secret](https://imagekit.io/dashboard/developer/webhooks) from the ImageKit dashboard.
1133+
1134+
Here is an example using the express.js server.
1135+
1136+
```js
1137+
const express = require('express');
1138+
const Imagekit = require('imagekit');
1139+
1140+
// Webhook configs
1141+
const WEBHOOK_SECRET = 'whsec_...'; // Copy from Imagekit dashboard
1142+
const WEBHOOK_EXPIRY_DURATION = 300 * 1000; // 300 seconds for example
1143+
1144+
const imagekit = new Imagekit({
1145+
publicKey: 'public_...',
1146+
urlEndpoint: 'https://ik.imagekit.io/imagekit_id',
1147+
privateKey: 'private_...',
1148+
})
1149+
1150+
const app = express();
1151+
1152+
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
1153+
const signature = req.headers["x-ik-signature"];
1154+
const requestBody = req.body;
1155+
let webhookResult;
1156+
try {
1157+
webhookResult = imagekit.verifyWebhookEvent(requestBody, signature, WEBHOOK_SECRET);
1158+
} catch (e) {
1159+
// `verifyWebhookEvent` method will throw an error if signature is invalid
1160+
console.log(e);
1161+
// Return a response to acknowledge receipt of the event so that ImageKit doesn't retry sending this webhook.
1162+
res.send()
1163+
}
1164+
1165+
const { timestamp, event } = webhookResult;
1166+
1167+
// Check if webhook has expired
1168+
if (timestamp + WEBHOOK_EXPIRY_DURATION < Date.now()) {
1169+
// Return a response to acknowledge receipt of the event so that ImageKit doesn't retry sending this webhook.
1170+
res.send()
1171+
}
1172+
1173+
// Handle webhook
1174+
switch (event.type) {
1175+
case 'video.transformation.accepted':
1176+
// It is triggered when a new video transformation request is accepted for processing. You can use this for debugging purposes.
1177+
break;
1178+
case 'video.transformation.ready':
1179+
// It is triggered when a video encoding is finished, and the transformed resource is ready to be served. You should listen to this webhook and update any flag in your database or CMS against that particular asset so your application can start showing it to users.
1180+
break;
1181+
case 'video.transformation.error':
1182+
// It is triggered if an error occurs during encoding. Listen to this webhook to log the reason. You should check your origin and URL-endpoint settings if the reason is related to download failure. If the reason seems like an error on the ImageKit side, then raise a support ticket at [email protected].
1183+
break;
1184+
default:
1185+
// ... handle other event types
1186+
console.log(`Unhandled event type ${event.type}`);
1187+
}
1188+
1189+
// Return a response to acknowledge receipt of the event
1190+
res.send();
1191+
})
1192+
1193+
app.listen(3000, () => {
1194+
console.log(`Example app listening on port 3000`)
1195+
})
1196+
```
1197+
11281198
## Support
11291199

11301200
For any feedback or to report any issues or general implementation support, please reach out to [[email protected]](mailto:[email protected])

index.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import { IKCallback } from "./libs/interfaces/IKCallback";
4040
import manage from "./libs/manage";
4141
import signature from "./libs/signature";
4242
import upload from "./libs/upload";
43+
import { verify as verifyWebhookEvent } from "./utils/webhook-signature";
4344
import customMetadataField from "./libs/manage/custom-metadata-field";
4445
/*
4546
Implementations
@@ -75,7 +76,6 @@ const promisify = function <T = void>(thisContext: ImageKit, fn: Function) {
7576
}
7677
};
7778
};
78-
7979
class ImageKit {
8080
options: ImageKitOptions = {
8181
uploadEndpoint: "https://upload.imagekit.io/api/v1/files/upload",
@@ -667,6 +667,14 @@ class ImageKit {
667667
pHashDistance(firstPHash: string, secondPHash: string): number | Error {
668668
return pHashUtils.pHashDistance(firstPHash, secondPHash);
669669
}
670+
671+
/**
672+
* @param payload - Raw webhook request body (Encoded as UTF8 string or Buffer)
673+
* @param signature - Webhook signature as UTF8 encoded strings (Stored in `x-ik-signature` header of the request)
674+
* @param secret - Webhook secret as UTF8 encoded string [Copy from ImageKit dashboard](https://imagekit.io/dashboard/developer/webhooks)
675+
* @returns \{ `timestamp`: Verified UNIX epoch timestamp if signature, `event`: Parsed webhook event payload \}
676+
*/
677+
verifyWebhookEvent = verifyWebhookEvent;
670678
}
671679

672-
export = ImageKit;
680+
export = ImageKit;

libs/constants/errorMessages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,9 @@ export default {
4242
"INVALID_FILE_PATH": { message: "Invalid value for filePath", help: "Pass the full path of the file. For example - /path/to/file.jpg" },
4343
"INVALID_NEW_FILE_NAME": { message: "Invalid value for newFileName. It should be a string.", help: "" },
4444
"INVALID_PURGE_CACHE": { message: "Invalid value for purgeCache. It should be boolean.", help: "" },
45+
// Webhook signature
46+
"VERIFY_WEBHOOK_EVENT_SIGNATURE_INCORRECT": { message: "Incorrect signature", help: "Please pass x-ik-signature header as utf8 string" },
47+
"VERIFY_WEBHOOK_EVENT_SIGNATURE_MISSING": { message: "Signature missing", help: "Please pass x-ik-signature header as utf8 string" },
48+
"VERIFY_WEBHOOK_EVENT_TIMESTAMP_MISSING": { message: "Timestamp missing", help: "Please pass x-ik-signature header as utf8 string" },
49+
"VERIFY_WEBHOOK_EVENT_TIMESTAMP_INVALID": { message: "Timestamp invalid", help: "Please pass x-ik-signature header as utf8 string" },
4550
};

libs/interfaces/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import { MoveFolderOptions, MoveFolderResponse, MoveFolderError } from "./MoveFo
1717
import { DeleteFileVersionOptions, RestoreFileVersionOptions } from "./FileVersion"
1818
import { CreateCustomMetadataFieldOptions, CustomMetadataField, UpdateCustomMetadataFieldOptions, GetCustomMetadataFieldsOptions } from "./CustomMetatadaField"
1919
import { RenameFileOptions, RenameFileResponse } from "./Rename"
20+
import {
21+
WebhookEvent,
22+
WebhookEventVideoTransformationAccepted,
23+
WebhookEventVideoTransformationReady,
24+
WebhookEventVideoTransformationError,
25+
} from "./webhookEvent";
2026

2127
type FinalUrlOptions = ImageKitOptions & UrlOptions; // actual options used to construct url
2228

@@ -56,5 +62,9 @@ export type {
5662
UpdateCustomMetadataFieldOptions,
5763
RenameFileOptions,
5864
RenameFileResponse,
65+
WebhookEvent,
66+
WebhookEventVideoTransformationAccepted,
67+
WebhookEventVideoTransformationReady,
68+
WebhookEventVideoTransformationError,
5969
};
6070
export type { IKCallback } from "./IKCallback";

libs/interfaces/webhookEvent.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
type Asset = {
2+
url: string;
3+
};
4+
5+
type TransformationOptions = {
6+
video_codec: string;
7+
audio_codec: string;
8+
auto_rotate: boolean;
9+
quality: number;
10+
format: string;
11+
};
12+
13+
interface WebhookEventBase {
14+
type: string;
15+
id: string;
16+
created_at: string; // Date
17+
}
18+
19+
/** WebhookEvent for "video.transformation.*" type */
20+
interface WebhookEventVideoTransformationBase extends WebhookEventBase {
21+
request: {
22+
x_request_id: string;
23+
url: string;
24+
user_agent: string;
25+
};
26+
}
27+
28+
export interface WebhookEventVideoTransformationAccepted extends WebhookEventVideoTransformationBase {
29+
type: "video.transformation.accepted";
30+
data: {
31+
asset: Asset;
32+
transformation: {
33+
type: string;
34+
options: TransformationOptions;
35+
};
36+
};
37+
}
38+
39+
export interface WebhookEventVideoTransformationReady extends WebhookEventVideoTransformationBase {
40+
type: "video.transformation.ready";
41+
timings: {
42+
donwload_duration: number;
43+
encoding_duration: number;
44+
};
45+
data: {
46+
asset: Asset;
47+
transformation: {
48+
type: string;
49+
options: TransformationOptions;
50+
output: {
51+
url: string;
52+
video_metadata: {
53+
duration: number;
54+
width: number;
55+
height: number;
56+
bitrate: number;
57+
};
58+
};
59+
};
60+
};
61+
}
62+
63+
export interface WebhookEventVideoTransformationError extends WebhookEventVideoTransformationBase {
64+
type: "video.transformation.error";
65+
data: {
66+
asset: Asset;
67+
transformation: {
68+
type: string;
69+
options: TransformationOptions;
70+
error: {
71+
reason: string;
72+
};
73+
};
74+
};
75+
}
76+
77+
export type WebhookEvent =
78+
| WebhookEventVideoTransformationAccepted
79+
| WebhookEventVideoTransformationReady
80+
| WebhookEventVideoTransformationError
81+
| Object;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "imagekit",
3-
"version": "4.0.1",
3+
"version": "4.1.0",
44
"description": "Offical NodeJS SDK for ImageKit.io integration",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",

tests/webhook-signature.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import ImageKit from "../index";
2+
import { expect } from "chai";
3+
4+
// Sample webhook data
5+
const WEBHOOK_REQUEST_SAMPLE_SECRET = "whsec_xeO2UNkfKMQnfJf7Q/Qx+fYptL1wabXd";
6+
const WEBHOOK_REQUEST_SAMPLE_TIMESTAMP = new Date(1655788406333);
7+
const WEBHOOK_REQUEST_SAMPLE_SIGNATURE_HEADER =
8+
"t=1655788406333,v1=d30758f47fcb31e1fa0109d3b3e2a6c623e699aaf1461cba6bd462ef58ea4b31";
9+
const WEBHOOK_REQUEST_SAMPLE_RAW_BODY =
10+
'{"type":"video.transformation.accepted","id":"58e6d24d-6098-4319-be8d-40c3cb0a402d","created_at":"2022-06-20T11:59:58.461Z","request":{"x_request_id":"fa98fa2e-d6cd-45b4-acf5-bc1d2bbb8ba9","url":"http://ik.imagekit.io/demo/sample-video.mp4?tr=f-webm,q-10","user_agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:101.0) Gecko/20100101 Firefox/101.0"},"data":{"asset":{"url":"http://ik.imagekit.io/demo/sample-video.mp4"},"transformation":{"type":"video-transformation","options":{"video_codec":"vp9","audio_codec":"opus","auto_rotate":true,"quality":10,"format":"webm"}}}}';
11+
const WEBHOOK_REQUEST_SAMPLE = Object.seal({
12+
secret: WEBHOOK_REQUEST_SAMPLE_SECRET,
13+
timestamp: WEBHOOK_REQUEST_SAMPLE_TIMESTAMP,
14+
signatureHeader: WEBHOOK_REQUEST_SAMPLE_SIGNATURE_HEADER,
15+
rawBody: WEBHOOK_REQUEST_SAMPLE_RAW_BODY,
16+
body: JSON.parse(WEBHOOK_REQUEST_SAMPLE_RAW_BODY),
17+
});
18+
19+
describe("WebhookSignature", function () {
20+
const verify = (new ImageKit(require("./data").initializationParams)).verifyWebhookEvent;
21+
22+
context("Test Webhook.verify() - Positive cases", () => {
23+
it("Verify with body as string", () => {
24+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
25+
const { timestamp, event } = verify(
26+
webhookRequest.rawBody,
27+
webhookRequest.signatureHeader,
28+
webhookRequest.secret
29+
);
30+
expect(timestamp).to.equal(webhookRequest.timestamp.getTime());
31+
expect(event).to.deep.equal(webhookRequest.body);
32+
});
33+
it("Verify with body as Buffer", () => {
34+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
35+
const { timestamp, event } = verify(
36+
Buffer.from(webhookRequest.rawBody),
37+
webhookRequest.signatureHeader,
38+
webhookRequest.secret
39+
);
40+
expect(timestamp).to.equal(webhookRequest.timestamp.getTime());
41+
expect(event).to.deep.equal(webhookRequest.body);
42+
});
43+
it("Verify with body as Uint8Array", () => {
44+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
45+
const rawBody = Uint8Array.from(Buffer.from(webhookRequest.rawBody));
46+
const { timestamp, event } = verify(
47+
rawBody,
48+
webhookRequest.signatureHeader,
49+
webhookRequest.secret
50+
);
51+
expect(timestamp).to.equal(webhookRequest.timestamp.getTime());
52+
expect(event).to.deep.equal(webhookRequest.body);
53+
});
54+
});
55+
56+
context("Test WebhookSignature.verify() - Negative cases", () => {
57+
it("Timestamp missing", () => {
58+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
59+
const invalidSignature =
60+
"v1=b6bc2aa82491c32f1cbef0eb52b7ffffff467ea65a03b5d4ccdcfb9e0941c946";
61+
try {
62+
verify(webhookRequest.rawBody, invalidSignature, webhookRequest.secret);
63+
expect.fail("Expected exception");
64+
} catch (e) {
65+
expect(e.message).to.equal("Timestamp missing");
66+
}
67+
});
68+
it("Timestamp invalid", () => {
69+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
70+
const invalidSignature =
71+
"t=notANumber,v1=b6bc2aa82491c32f1cbef0eb52b7ffffff467ea65a03b5d4ccdcfb9e0941c946";
72+
try {
73+
verify(webhookRequest.rawBody, invalidSignature, webhookRequest.secret);
74+
expect.fail("Expected exception");
75+
} catch (e) {
76+
expect(e.message).to.equal("Timestamp invalid");
77+
}
78+
});
79+
it("Signature missing", () => {
80+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
81+
const invalidSignature = "t=1656326161409";
82+
try {
83+
verify(webhookRequest.rawBody, invalidSignature, webhookRequest.secret);
84+
expect.fail("Expected exception");
85+
} catch (e) {
86+
expect(e.message).to.equal("Signature missing");
87+
}
88+
});
89+
it("Incorrect signature - v1 manipulated", () => {
90+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
91+
const invalidSignature = `t=${webhookRequest.timestamp.getTime()},v1=d66b01d8f1e158d1af7646184716037510ac8ce0a1e70b726a1b698f954785b2`;
92+
try {
93+
verify(webhookRequest.rawBody, invalidSignature, webhookRequest.secret);
94+
expect.fail("Expected exception");
95+
} catch (e) {
96+
expect(e.message).to.equal("Incorrect signature");
97+
}
98+
});
99+
it("Incorrect signature - incorrect request body", () => {
100+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
101+
const incorrectBody = { hello: "world" };
102+
const incorrectRawBody = JSON.stringify(incorrectBody);
103+
try {
104+
verify(
105+
incorrectRawBody,
106+
webhookRequest.signatureHeader,
107+
webhookRequest.secret
108+
);
109+
expect.fail("Expected exception");
110+
} catch (e) {
111+
expect(e.message).to.equal("Incorrect signature");
112+
}
113+
});
114+
it("Incorrect signature - timestamp manipulated", () => {
115+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
116+
const incorrectSignature = webhookRequest.signatureHeader.replace(
117+
`t=${webhookRequest.timestamp.getTime()}`,
118+
`t=${webhookRequest.timestamp.getTime() + 1}`
119+
); // Correct timestamp replaced with incorrect timestamp
120+
try {
121+
verify(
122+
webhookRequest.rawBody,
123+
incorrectSignature,
124+
webhookRequest.secret
125+
);
126+
expect.fail("Expected exception");
127+
} catch (e) {
128+
expect(e.message).to.equal("Incorrect signature");
129+
}
130+
});
131+
it("Incorrect signature - different secret", () => {
132+
const webhookRequest = WEBHOOK_REQUEST_SAMPLE;
133+
try {
134+
verify(
135+
webhookRequest.rawBody,
136+
webhookRequest.signatureHeader,
137+
"A different secret"
138+
);
139+
expect.fail("Expected exception");
140+
} catch (e) {
141+
expect(e.message).to.equal("Incorrect signature");
142+
}
143+
});
144+
});
145+
});

0 commit comments

Comments
 (0)