Add functions to process the Saleor webhooks (#100)
* Add functions to process the saleor webhooks Co-authored-by: Lukasz Ostrowski <lukasz.ostrowski@saleor.io>
This commit is contained in:
parent
6e389db9be
commit
e439438d8f
14 changed files with 1234 additions and 908 deletions
22
package.json
22
package.json
|
@ -19,9 +19,9 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"next": "^12",
|
||||||
"react": ">=17",
|
"react": ">=17",
|
||||||
"react-dom": ">=17",
|
"react-dom": ">=17"
|
||||||
"next": "^12"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
|
@ -29,22 +29,23 @@
|
||||||
"graphql": "^16.6.0",
|
"graphql": "^16.6.0",
|
||||||
"jose": "^4.9.2",
|
"jose": "^4.9.2",
|
||||||
"node-fetch": "^2.6.7",
|
"node-fetch": "^2.6.7",
|
||||||
|
"raw-body": "^2.5.1",
|
||||||
"retes": "^0.33.0",
|
"retes": "^0.33.0",
|
||||||
"uuid": "^8.3.2"
|
"uuid": "^8.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/coverage-c8": "^0.23.2",
|
|
||||||
"node-mocks-http": "^1.11.0",
|
|
||||||
"next": "^12.3.0",
|
|
||||||
"@testing-library/dom": "^8.17.1",
|
"@testing-library/dom": "^8.17.1",
|
||||||
"@testing-library/react": "^13.4.0",
|
"@testing-library/react": "^13.4.0",
|
||||||
"@types/debug": "^4.1.7",
|
"@types/debug": "^4.1.7",
|
||||||
"@types/node": "^18.7.15",
|
"@types/node": "^18.7.15",
|
||||||
"@types/node-fetch": "^2.6.2",
|
"@types/node-fetch": "^2.6.2",
|
||||||
|
"@types/react": "18.0.21",
|
||||||
|
"@types/react-dom": "^18.0.5",
|
||||||
"@types/uuid": "^8.3.4",
|
"@types/uuid": "^8.3.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.36.1",
|
"@typescript-eslint/eslint-plugin": "^5.36.1",
|
||||||
"@typescript-eslint/parser": "^5.36.1",
|
"@typescript-eslint/parser": "^5.36.1",
|
||||||
"@vitejs/plugin-react": "^2.1.0",
|
"@vitejs/plugin-react": "^2.1.0",
|
||||||
|
"@vitest/coverage-c8": "^0.23.2",
|
||||||
"clean-publish": "^4.0.1",
|
"clean-publish": "^4.0.1",
|
||||||
"eslint": "8.23.0",
|
"eslint": "8.23.0",
|
||||||
"eslint-config-airbnb": "^19.0.4",
|
"eslint-config-airbnb": "^19.0.4",
|
||||||
|
@ -57,18 +58,17 @@
|
||||||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||||
"husky": "^8.0.1",
|
"husky": "^8.0.1",
|
||||||
"jsdom": "^20.0.0",
|
"jsdom": "^20.0.0",
|
||||||
|
"next": "^12.3.0",
|
||||||
|
"node-mocks-http": "^1.11.0",
|
||||||
"prettier": "2.7.1",
|
"prettier": "2.7.1",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
"release-it": "^15.4.1",
|
"release-it": "^15.4.1",
|
||||||
"tsm": "^2.2.2",
|
"tsm": "^2.2.2",
|
||||||
"tsup": "^6.2.3",
|
"tsup": "^6.2.3",
|
||||||
"typescript": "^4.8.2",
|
"typescript": "^4.8.2",
|
||||||
"vite": "^3.1.0",
|
"vite": "^3.1.0",
|
||||||
"vitest": "^0.23.1",
|
"vitest": "^0.23.1"
|
||||||
"watchlist": "^0.3.1",
|
|
||||||
"react": "^18.2.0",
|
|
||||||
"react-dom": "18.2.0",
|
|
||||||
"@types/react": "18.0.21",
|
|
||||||
"@types/react-dom": "^18.0.5"
|
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{js,ts,tsx}": "eslint --cache --fix",
|
"*.{js,ts,tsx}": "eslint --cache --fix",
|
||||||
|
|
1532
pnpm-lock.yaml
1532
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
7
src/gql-ast-to-string.ts
Normal file
7
src/gql-ast-to-string.ts
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import { ASTNode, print } from "graphql";
|
||||||
|
|
||||||
|
export const gqlAstToString = (ast: ASTNode) =>
|
||||||
|
print(ast) // convert AST to string
|
||||||
|
.replaceAll(/\n*/g, "") // remove new lines
|
||||||
|
.replaceAll(/\s{2,}/g, " ") // remove unnecessary multiple spaces
|
||||||
|
.trim(); // remove whitespace from beginning and end
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./create-app-register-handler";
|
export * from "./create-app-register-handler";
|
||||||
export * from "./create-manifest-handler";
|
export * from "./create-manifest-handler";
|
||||||
|
export * from "./saleor-async-webhook";
|
||||||
|
|
147
src/handlers/next/process-async-saleor-webhook.test.ts
Normal file
147
src/handlers/next/process-async-saleor-webhook.test.ts
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import { NextApiRequest } from "next/types";
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import rawBody from "raw-body";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { APL } from "../../APL";
|
||||||
|
import { processAsyncSaleorWebhook } from "./process-async-saleor-webhook";
|
||||||
|
|
||||||
|
vi.mock("./../../verify-signature", () => ({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
verifySignature: vi.fn((domain, signature) => {
|
||||||
|
if (signature !== "mocked_signature") {
|
||||||
|
throw new Error("Wrong signature");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("raw-body", () => ({
|
||||||
|
default: vi.fn().mockResolvedValue("{}"),
|
||||||
|
}));
|
||||||
|
describe("processAsyncSaleorWebhook", () => {
|
||||||
|
let mockRequest: NextApiRequest;
|
||||||
|
|
||||||
|
const mockAPL: APL = {
|
||||||
|
get: async (domain: string) =>
|
||||||
|
domain === "example.com"
|
||||||
|
? {
|
||||||
|
domain: "example.com",
|
||||||
|
token: "mock-token",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
set: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
getAll: vi.fn(),
|
||||||
|
isReady: vi.fn(),
|
||||||
|
isConfigured: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Create request method which passes all the tests
|
||||||
|
const { req } = createMocks({
|
||||||
|
headers: {
|
||||||
|
host: "some-saleor-host.cloud",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
"saleor-domain": "example.com",
|
||||||
|
"saleor-event": "product_updated",
|
||||||
|
"saleor-signature": "mocked_signature",
|
||||||
|
"content-length": "0", // is ignored by mocked raw-body
|
||||||
|
},
|
||||||
|
method: "POST",
|
||||||
|
// body can be skipped because we mock it with raw-body
|
||||||
|
});
|
||||||
|
mockRequest = req;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Process valid request", async () => {
|
||||||
|
await processAsyncSaleorWebhook({
|
||||||
|
req: mockRequest,
|
||||||
|
apl: mockAPL,
|
||||||
|
allowedEvent: "PRODUCT_UPDATED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Throw error on non-POST request method", async () => {
|
||||||
|
mockRequest.method = "GET";
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
|
||||||
|
).rejects.toThrow("Wrong request method");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Throw error on missing domain header", async () => {
|
||||||
|
delete mockRequest.headers["saleor-domain"];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
|
||||||
|
).rejects.toThrow("Missing saleor-domain header");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Throw error on missing event header", async () => {
|
||||||
|
delete mockRequest.headers["saleor-event"];
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
processAsyncSaleorWebhook({
|
||||||
|
req: mockRequest,
|
||||||
|
apl: mockAPL,
|
||||||
|
allowedEvent: "PRODUCT_UPDATED",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Missing saleor-event header");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Throw error on mismatched event header", async () => {
|
||||||
|
mockRequest.headers["saleor-event"] = "different_event";
|
||||||
|
await expect(
|
||||||
|
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" })
|
||||||
|
).rejects.toThrow("Wrong incoming request event: different_event. Expected: product_updated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Throw error on missing signature header", async () => {
|
||||||
|
delete mockRequest.headers["saleor-signature"];
|
||||||
|
await expect(
|
||||||
|
processAsyncSaleorWebhook({
|
||||||
|
req: mockRequest,
|
||||||
|
apl: mockAPL,
|
||||||
|
allowedEvent: "PRODUCT_UPDATED",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Missing saleor-signature header");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Throw error on missing request body", async () => {
|
||||||
|
vi.mocked(rawBody).mockImplementationOnce(async () => {
|
||||||
|
throw new Error("Missing request body");
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
processAsyncSaleorWebhook({
|
||||||
|
req: mockRequest,
|
||||||
|
apl: mockAPL,
|
||||||
|
allowedEvent: "PRODUCT_UPDATED",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Missing request body");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Throw error on not registered app", async () => {
|
||||||
|
mockRequest.headers["saleor-domain"] = "not-registered.example.com";
|
||||||
|
await expect(
|
||||||
|
processAsyncSaleorWebhook({
|
||||||
|
req: mockRequest,
|
||||||
|
apl: mockAPL,
|
||||||
|
allowedEvent: "PRODUCT_UPDATED",
|
||||||
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Can't find auth data for domain not-registered.example.com. Please register the application"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Throw error on wrong signature", async () => {
|
||||||
|
mockRequest.headers["saleor-signature"] = "wrong_signature";
|
||||||
|
await expect(
|
||||||
|
processAsyncSaleorWebhook({
|
||||||
|
req: mockRequest,
|
||||||
|
apl: mockAPL,
|
||||||
|
allowedEvent: "PRODUCT_UPDATED",
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Request signature check failed");
|
||||||
|
});
|
||||||
|
});
|
148
src/handlers/next/process-async-saleor-webhook.ts
Normal file
148
src/handlers/next/process-async-saleor-webhook.ts
Normal file
|
@ -0,0 +1,148 @@
|
||||||
|
import { NextApiRequest } from "next";
|
||||||
|
import getRawBody from "raw-body";
|
||||||
|
|
||||||
|
import { APL } from "../../APL";
|
||||||
|
import { AuthData } from "../../APL/apl";
|
||||||
|
import { createDebug } from "../../debug";
|
||||||
|
import { getBaseUrl, getSaleorHeaders } from "../../headers";
|
||||||
|
import { verifySignature } from "../../verify-signature";
|
||||||
|
|
||||||
|
const debug = createDebug("processAsyncWebhook");
|
||||||
|
|
||||||
|
export type SaleorWebhookError =
|
||||||
|
| "OTHER"
|
||||||
|
| "MISSING_HOST_HEADER"
|
||||||
|
| "MISSING_DOMAIN_HEADER"
|
||||||
|
| "MISSING_EVENT_HEADER"
|
||||||
|
| "MISSING_PAYLOAD_HEADER"
|
||||||
|
| "MISSING_SIGNATURE_HEADER"
|
||||||
|
| "MISSING_REQUEST_BODY"
|
||||||
|
| "WRONG_EVENT"
|
||||||
|
| "NOT_REGISTERED"
|
||||||
|
| "SIGNATURE_VERIFICATION_FAILED"
|
||||||
|
| "WRONG_METHOD"
|
||||||
|
| "CANT_BE_PARSED";
|
||||||
|
|
||||||
|
export class WebhookError extends Error {
|
||||||
|
errorType: SaleorWebhookError = "OTHER";
|
||||||
|
|
||||||
|
constructor(message: string, errorType: SaleorWebhookError) {
|
||||||
|
super(message);
|
||||||
|
if (errorType) {
|
||||||
|
this.errorType = errorType;
|
||||||
|
}
|
||||||
|
Object.setPrototypeOf(this, WebhookError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebhookContext<T> = {
|
||||||
|
baseUrl: string;
|
||||||
|
event: string;
|
||||||
|
payload: T;
|
||||||
|
authData: AuthData;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ProcessSaleorWebhookArgs {
|
||||||
|
req: NextApiRequest;
|
||||||
|
apl: APL;
|
||||||
|
allowedEvent: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessAsyncSaleorWebhook = <T = unknown>(
|
||||||
|
props: ProcessSaleorWebhookArgs
|
||||||
|
) => Promise<WebhookContext<T>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform security checks on given request and return WebhookContext object.
|
||||||
|
* In case of validation issues, instance of the WebhookError will be thrown.
|
||||||
|
*
|
||||||
|
* @returns WebhookContext
|
||||||
|
*/
|
||||||
|
export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
|
||||||
|
req,
|
||||||
|
apl,
|
||||||
|
allowedEvent,
|
||||||
|
}: ProcessSaleorWebhookArgs): Promise<WebhookContext<T>> => {
|
||||||
|
debug("Request processing started");
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
debug("Wrong HTTP method");
|
||||||
|
throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD");
|
||||||
|
}
|
||||||
|
const { event, domain, signature } = getSaleorHeaders(req.headers);
|
||||||
|
const baseUrl = getBaseUrl(req.headers);
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
debug("Missing host header");
|
||||||
|
throw new WebhookError("Missing host header", "MISSING_HOST_HEADER");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
debug("Missing saleor-domain header");
|
||||||
|
throw new WebhookError("Missing saleor-domain header", "MISSING_DOMAIN_HEADER");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
debug("Missing saleor-event header");
|
||||||
|
throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER");
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = allowedEvent.toLowerCase();
|
||||||
|
if (event !== expected) {
|
||||||
|
debug(`Wrong incoming request event: ${event}. Expected: ${expected}`);
|
||||||
|
throw new WebhookError(
|
||||||
|
`Wrong incoming request event: ${event}. Expected: ${expected}`,
|
||||||
|
"WRONG_EVENT"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!signature) {
|
||||||
|
debug("No signature");
|
||||||
|
throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = (
|
||||||
|
await getRawBody(req, {
|
||||||
|
length: req.headers["content-length"],
|
||||||
|
limit: "1mb",
|
||||||
|
})
|
||||||
|
).toString();
|
||||||
|
if (!rawBody) {
|
||||||
|
debug("Missing request body");
|
||||||
|
throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY");
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedBody: unknown;
|
||||||
|
try {
|
||||||
|
parsedBody = JSON.parse(rawBody);
|
||||||
|
} catch {
|
||||||
|
debug("Request body cannot be parsed");
|
||||||
|
throw new WebhookError("Request body cant be parsed", "CANT_BE_PARSED");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if domain is installed in the app
|
||||||
|
const authData = await apl.get(domain);
|
||||||
|
|
||||||
|
if (!authData) {
|
||||||
|
debug("APL didn't found auth data for domain %s", domain);
|
||||||
|
throw new WebhookError(
|
||||||
|
`Can't find auth data for domain ${domain}. Please register the application`,
|
||||||
|
"NOT_REGISTERED"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payload signature check
|
||||||
|
// TODO: Since it require additional request, can we cache it's response?
|
||||||
|
try {
|
||||||
|
await verifySignature(domain, signature, rawBody);
|
||||||
|
} catch {
|
||||||
|
debug("Request signature check failed");
|
||||||
|
throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl,
|
||||||
|
event,
|
||||||
|
payload: parsedBody as T,
|
||||||
|
authData,
|
||||||
|
};
|
||||||
|
};
|
75
src/handlers/next/saleor-async-webhook.test.ts
Normal file
75
src/handlers/next/saleor-async-webhook.test.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import { createMocks } from "node-mocks-http";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { APL } from "../../APL";
|
||||||
|
import { NextWebhookApiHandler, SaleorAsyncWebhook } from "./saleor-async-webhook";
|
||||||
|
|
||||||
|
const webhookPath = "api/webhooks/product-updated";
|
||||||
|
const baseUrl = "http://example.com";
|
||||||
|
|
||||||
|
describe("SaleorAsyncWebhook", () => {
|
||||||
|
const mockAPL: APL = {
|
||||||
|
get: async (domain: string) =>
|
||||||
|
domain === "example.com"
|
||||||
|
? {
|
||||||
|
domain: "example.com",
|
||||||
|
token: "mock-token",
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
set: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
getAll: vi.fn(),
|
||||||
|
isReady: vi.fn(),
|
||||||
|
isConfigured: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const saleorAsyncWebhook = new SaleorAsyncWebhook({
|
||||||
|
apl: mockAPL,
|
||||||
|
asyncEvent: "PRODUCT_UPDATED",
|
||||||
|
webhookPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("targetUrl should return full path to the webhook route based on given baseUrl", async () => {
|
||||||
|
expect(saleorAsyncWebhook.getTargetUrl(baseUrl)).toBe(`${baseUrl}/${webhookPath}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getWebhookManifest should return a valid manifest", async () => {
|
||||||
|
expect(saleorAsyncWebhook.getWebhookManifest(baseUrl)).toStrictEqual({
|
||||||
|
asyncEvents: ["PRODUCT_UPDATED"],
|
||||||
|
isActive: true,
|
||||||
|
name: "PRODUCT_UPDATED webhook",
|
||||||
|
targetUrl: "http://example.com/api/webhooks/product-updated",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Test createHandler which return success", async () => {
|
||||||
|
// prepare mocked context returned by mocked process function
|
||||||
|
vi.mock("./process-async-saleor-webhook", () => ({
|
||||||
|
processAsyncSaleorWebhook: vi.fn().mockResolvedValue({
|
||||||
|
baseUrl: "example.com",
|
||||||
|
event: "product_updated",
|
||||||
|
payload: { data: "test_payload" },
|
||||||
|
authData: { domain: "example.com", token: "token" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Test handler - will throw error if mocked context is not passed to it
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const testHandler: NextWebhookApiHandler = vi.fn().mockImplementation((req, res, context) => {
|
||||||
|
if (context.payload.data === "test_payload") {
|
||||||
|
res.status(200).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Test payload has not been passed to handler function");
|
||||||
|
});
|
||||||
|
|
||||||
|
// We are mocking validation method, so empty mock requests will pass
|
||||||
|
const { req, res } = createMocks();
|
||||||
|
const wrappedHandler = saleorAsyncWebhook.createHandler(testHandler);
|
||||||
|
await wrappedHandler(req, res);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
|
||||||
|
// Check if test handler was used by the wrapper
|
||||||
|
expect(testHandler).toBeCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
140
src/handlers/next/saleor-async-webhook.ts
Normal file
140
src/handlers/next/saleor-async-webhook.ts
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
import { ASTNode } from "graphql";
|
||||||
|
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
|
import { WebhookManifest } from "../..";
|
||||||
|
import { APL } from "../../APL";
|
||||||
|
import { createDebug } from "../../debug";
|
||||||
|
import { gqlAstToString } from "../../gql-ast-to-string";
|
||||||
|
import { WebhookEvent } from "../../types";
|
||||||
|
import {
|
||||||
|
processAsyncSaleorWebhook,
|
||||||
|
SaleorWebhookError,
|
||||||
|
WebhookContext,
|
||||||
|
WebhookError,
|
||||||
|
} from "./process-async-saleor-webhook";
|
||||||
|
|
||||||
|
const debug = createDebug("SaleorAsyncWebhook");
|
||||||
|
|
||||||
|
export interface WebhookManifestConfiguration {
|
||||||
|
name?: string;
|
||||||
|
webhookPath: string;
|
||||||
|
subscriptionQueryAst?: ASTNode;
|
||||||
|
query?: string;
|
||||||
|
asyncEvent: WebhookEvent;
|
||||||
|
isActive?: boolean;
|
||||||
|
apl: APL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ErrorCodeMap: Record<SaleorWebhookError, number> = {
|
||||||
|
OTHER: 500,
|
||||||
|
MISSING_HOST_HEADER: 400,
|
||||||
|
MISSING_DOMAIN_HEADER: 400,
|
||||||
|
MISSING_EVENT_HEADER: 400,
|
||||||
|
MISSING_PAYLOAD_HEADER: 400,
|
||||||
|
MISSING_SIGNATURE_HEADER: 400,
|
||||||
|
MISSING_REQUEST_BODY: 400,
|
||||||
|
WRONG_EVENT: 400,
|
||||||
|
NOT_REGISTERED: 401,
|
||||||
|
SIGNATURE_VERIFICATION_FAILED: 401,
|
||||||
|
WRONG_METHOD: 405,
|
||||||
|
CANT_BE_PARSED: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NextWebhookApiHandler<TPayload = unknown, TResp = unknown> = (
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<TResp>,
|
||||||
|
ctx: WebhookContext<TPayload>
|
||||||
|
) => unknown | Promise<unknown>;
|
||||||
|
|
||||||
|
export class SaleorAsyncWebhook<TPayload = unknown> {
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
webhookPath: string;
|
||||||
|
|
||||||
|
subscriptionQueryAst?: ASTNode;
|
||||||
|
|
||||||
|
query?: string;
|
||||||
|
|
||||||
|
asyncEvent: WebhookEvent;
|
||||||
|
|
||||||
|
isActive?: boolean;
|
||||||
|
|
||||||
|
apl: APL;
|
||||||
|
|
||||||
|
constructor({
|
||||||
|
name,
|
||||||
|
webhookPath,
|
||||||
|
subscriptionQueryAst,
|
||||||
|
query,
|
||||||
|
asyncEvent,
|
||||||
|
apl,
|
||||||
|
isActive = true,
|
||||||
|
}: WebhookManifestConfiguration) {
|
||||||
|
this.name = name || `${asyncEvent} webhook`;
|
||||||
|
this.subscriptionQueryAst = subscriptionQueryAst;
|
||||||
|
this.query = query;
|
||||||
|
this.webhookPath = webhookPath;
|
||||||
|
this.asyncEvent = asyncEvent;
|
||||||
|
this.isActive = isActive;
|
||||||
|
this.apl = apl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns full URL to the webhook, based on provided baseUrl.
|
||||||
|
*
|
||||||
|
* @param baseUrl Base URL used by your application
|
||||||
|
*/
|
||||||
|
getTargetUrl(baseUrl: string) {
|
||||||
|
return new URL(this.webhookPath, baseUrl).href;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns synchronous event manifest for this webhook.
|
||||||
|
*
|
||||||
|
* @param baseUrl Base URL used by your application
|
||||||
|
* @returns WebhookManifest
|
||||||
|
*/
|
||||||
|
getWebhookManifest(baseUrl: string): WebhookManifest {
|
||||||
|
return {
|
||||||
|
name: this.name,
|
||||||
|
targetUrl: this.getTargetUrl(baseUrl),
|
||||||
|
asyncEvents: [this.asyncEvent],
|
||||||
|
isActive: this.isActive,
|
||||||
|
// Query can be provided as plaintext..
|
||||||
|
...(this.query && { query: this.query }),
|
||||||
|
// ...GQL AST which has to be stringified..
|
||||||
|
...(this.subscriptionQueryAst && { query: gqlAstToString(this.subscriptionQueryAst) }),
|
||||||
|
// or no query at all. In such case default webhook payload will be sent
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps provided function, to ensure incoming request comes from registered Saleor instance.
|
||||||
|
* Also provides additional `context` object containing typed payload and request properties.
|
||||||
|
*
|
||||||
|
* @param handlerFn NextApiHandler function which takes additional `context` argument
|
||||||
|
* @returns NextApiHandler
|
||||||
|
*/
|
||||||
|
createHandler(handlerFn: NextWebhookApiHandler<TPayload>): NextApiHandler {
|
||||||
|
return async (req, res) => {
|
||||||
|
await processAsyncSaleorWebhook<TPayload>({
|
||||||
|
req,
|
||||||
|
apl: this.apl,
|
||||||
|
allowedEvent: this.asyncEvent,
|
||||||
|
})
|
||||||
|
.then(async (context) => {
|
||||||
|
debug("Call handlerFn");
|
||||||
|
return handlerFn(req, res, context);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (e instanceof WebhookError) {
|
||||||
|
debug(e.message);
|
||||||
|
res.status(ErrorCodeMap[e.errorType] || 400).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
debug("Unexpected error during processing the webhook %O", e);
|
||||||
|
res.status(500).end();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,3 +16,8 @@ export const getSaleorHeaders = (headers: {
|
||||||
signature: toStringOrUndefined(headers[SALEOR_SIGNATURE_HEADER]),
|
signature: toStringOrUndefined(headers[SALEOR_SIGNATURE_HEADER]),
|
||||||
event: toStringOrUndefined(headers[SALEOR_EVENT_HEADER]),
|
event: toStringOrUndefined(headers[SALEOR_EVENT_HEADER]),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {
|
||||||
|
const { host, "x-forwarded-proto": protocol = "http" } = headers;
|
||||||
|
return `${protocol}://${host}`;
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
|
import { AppWebhook as WebhookManifest } from "./types";
|
||||||
|
|
||||||
export * from "./const";
|
export * from "./const";
|
||||||
export * from "./headers";
|
export * from "./headers";
|
||||||
export * from "./infer-webhooks";
|
export * from "./infer-webhooks";
|
||||||
export * from "./saleor-app";
|
export * from "./saleor-app";
|
||||||
export * from "./types";
|
export * from "./types";
|
||||||
export * from "./urls";
|
export * from "./urls";
|
||||||
|
export { WebhookManifest };
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { Middleware } from "retes";
|
import { Middleware } from "retes";
|
||||||
|
|
||||||
|
import { getBaseUrl } from "../headers";
|
||||||
import { createMiddlewareDebug } from "./middleware-debug";
|
import { createMiddlewareDebug } from "./middleware-debug";
|
||||||
|
|
||||||
const debug = createMiddlewareDebug("withBaseURL");
|
const debug = createMiddlewareDebug("withBaseURL");
|
||||||
|
@ -7,10 +8,10 @@ const debug = createMiddlewareDebug("withBaseURL");
|
||||||
export const withBaseURL: Middleware = (handler) => async (request) => {
|
export const withBaseURL: Middleware = (handler) => async (request) => {
|
||||||
const { host, "x-forwarded-proto": protocol = "http" } = request.headers;
|
const { host, "x-forwarded-proto": protocol = "http" } = request.headers;
|
||||||
|
|
||||||
debug("Middleware called with host: %s, protocol %s", host, request.headers["x-forwarded-proto"]);
|
debug("Middleware called with host: %s, protocol %s", host, protocol);
|
||||||
|
|
||||||
request.context ??= {};
|
request.context ??= {};
|
||||||
request.context.baseURL = `${protocol}://${host}`;
|
request.context.baseURL = getBaseUrl(request.headers);
|
||||||
|
|
||||||
debug("context.baseURL resolved to be: \"%s\"", request.context.baseURL);
|
debug("context.baseURL resolved to be: \"%s\"", request.context.baseURL);
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import * as jose from "jose";
|
|
||||||
import { Middleware } from "retes";
|
import { Middleware } from "retes";
|
||||||
import { Response } from "retes/response";
|
import { Response } from "retes/response";
|
||||||
|
|
||||||
import { SALEOR_DOMAIN_HEADER, SALEOR_SIGNATURE_HEADER } from "../const";
|
import { SALEOR_DOMAIN_HEADER, SALEOR_SIGNATURE_HEADER } from "../const";
|
||||||
import { getSaleorHeaders } from "../headers";
|
import { getSaleorHeaders } from "../headers";
|
||||||
import { getJwksUrl } from "../urls";
|
import { verifySignature } from "../verify-signature";
|
||||||
import { createMiddlewareDebug } from "./middleware-debug";
|
import { createMiddlewareDebug } from "./middleware-debug";
|
||||||
|
|
||||||
const debug = createMiddlewareDebug("withWebhookSignatureVerified");
|
const debug = createMiddlewareDebug("withWebhookSignatureVerified");
|
||||||
|
@ -62,26 +61,11 @@ export const withWebhookSignatureVerified =
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const [header, , signature] = payloadSignature.split(".");
|
|
||||||
const jws = {
|
|
||||||
protected: header,
|
|
||||||
payload: request.rawBody,
|
|
||||||
signature,
|
|
||||||
};
|
|
||||||
|
|
||||||
const remoteJwks = jose.createRemoteJWKSet(
|
|
||||||
new URL(getJwksUrl(saleorDomain))
|
|
||||||
) as jose.FlattenedVerifyGetKey;
|
|
||||||
|
|
||||||
debug("Created remote JWKS");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await jose.flattenedVerify(jws, remoteJwks);
|
await verifySignature(saleorDomain, payloadSignature, request.rawBody);
|
||||||
|
|
||||||
debug("JWKS verified");
|
debug("JWKS verified");
|
||||||
} catch {
|
} catch {
|
||||||
debug("JWKS verification failed");
|
debug("JWKS verification failed");
|
||||||
|
|
||||||
return Response.BadRequest({
|
return Response.BadRequest({
|
||||||
success: false,
|
success: false,
|
||||||
message: `${ERROR_MESSAGE} Verification using public key has failed.`,
|
message: `${ERROR_MESSAGE} Verification using public key has failed.`,
|
||||||
|
|
|
@ -59,7 +59,9 @@ export interface AppWebhook {
|
||||||
name: string;
|
name: string;
|
||||||
asyncEvents?: WebhookEvent[];
|
asyncEvents?: WebhookEvent[];
|
||||||
syncEvents?: WebhookEvent[];
|
syncEvents?: WebhookEvent[];
|
||||||
query: string;
|
/** If query is not provided, the default webhook payload will be used */
|
||||||
|
query?: string;
|
||||||
|
/** The full URL of the endpoint where request will be sent */
|
||||||
targetUrl: string;
|
targetUrl: string;
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
33
src/verify-signature.ts
Normal file
33
src/verify-signature.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import * as jose from "jose";
|
||||||
|
|
||||||
|
import { createDebug } from "./debug";
|
||||||
|
import { getJwksUrl } from "./urls";
|
||||||
|
|
||||||
|
const debug = createDebug("verify-signature");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify payload signature with public key of given `domain`
|
||||||
|
* https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#payload-signature
|
||||||
|
*/
|
||||||
|
export const verifySignature = async (domain: string, signature: string, rawBody: string) => {
|
||||||
|
const [header, , jwsSignature] = signature.split(".");
|
||||||
|
const jws: jose.FlattenedJWSInput = {
|
||||||
|
protected: header,
|
||||||
|
payload: rawBody,
|
||||||
|
signature: jwsSignature,
|
||||||
|
};
|
||||||
|
|
||||||
|
const remoteJwks = jose.createRemoteJWKSet(
|
||||||
|
new URL(getJwksUrl(domain))
|
||||||
|
) as jose.FlattenedVerifyGetKey;
|
||||||
|
|
||||||
|
debug("Created remote JWKS");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await jose.flattenedVerify(jws, remoteJwks);
|
||||||
|
debug("JWKS verified");
|
||||||
|
} catch {
|
||||||
|
debug("JWKS verification failed");
|
||||||
|
throw new Error("JWKS verification failed");
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in a new issue