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": "",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"next": "^12",
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17",
|
||||
"next": "^12"
|
||||
"react-dom": ">=17"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
|
@ -29,22 +29,23 @@
|
|||
"graphql": "^16.6.0",
|
||||
"jose": "^4.9.2",
|
||||
"node-fetch": "^2.6.7",
|
||||
"raw-body": "^2.5.1",
|
||||
"retes": "^0.33.0",
|
||||
"uuid": "^8.3.2"
|
||||
},
|
||||
"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/react": "^13.4.0",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/node": "^18.7.15",
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/react": "18.0.21",
|
||||
"@types/react-dom": "^18.0.5",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@typescript-eslint/eslint-plugin": "^5.36.1",
|
||||
"@typescript-eslint/parser": "^5.36.1",
|
||||
"@vitejs/plugin-react": "^2.1.0",
|
||||
"@vitest/coverage-c8": "^0.23.2",
|
||||
"clean-publish": "^4.0.1",
|
||||
"eslint": "8.23.0",
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
|
@ -57,18 +58,17 @@
|
|||
"eslint-plugin-simple-import-sort": "^8.0.0",
|
||||
"husky": "^8.0.1",
|
||||
"jsdom": "^20.0.0",
|
||||
"next": "^12.3.0",
|
||||
"node-mocks-http": "^1.11.0",
|
||||
"prettier": "2.7.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"release-it": "^15.4.1",
|
||||
"tsm": "^2.2.2",
|
||||
"tsup": "^6.2.3",
|
||||
"typescript": "^4.8.2",
|
||||
"vite": "^3.1.0",
|
||||
"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"
|
||||
"vitest": "^0.23.1"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{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-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]),
|
||||
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 "./headers";
|
||||
export * from "./infer-webhooks";
|
||||
export * from "./saleor-app";
|
||||
export * from "./types";
|
||||
export * from "./urls";
|
||||
export { WebhookManifest };
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Middleware } from "retes";
|
||||
|
||||
import { getBaseUrl } from "../headers";
|
||||
import { createMiddlewareDebug } from "./middleware-debug";
|
||||
|
||||
const debug = createMiddlewareDebug("withBaseURL");
|
||||
|
@ -7,10 +8,10 @@ const debug = createMiddlewareDebug("withBaseURL");
|
|||
export const withBaseURL: Middleware = (handler) => async (request) => {
|
||||
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.baseURL = `${protocol}://${host}`;
|
||||
request.context.baseURL = getBaseUrl(request.headers);
|
||||
|
||||
debug("context.baseURL resolved to be: \"%s\"", request.context.baseURL);
|
||||
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
import crypto from "crypto";
|
||||
import * as jose from "jose";
|
||||
import { Middleware } from "retes";
|
||||
import { Response } from "retes/response";
|
||||
|
||||
import { SALEOR_DOMAIN_HEADER, SALEOR_SIGNATURE_HEADER } from "../const";
|
||||
import { getSaleorHeaders } from "../headers";
|
||||
import { getJwksUrl } from "../urls";
|
||||
import { verifySignature } from "../verify-signature";
|
||||
import { createMiddlewareDebug } from "./middleware-debug";
|
||||
|
||||
const debug = createMiddlewareDebug("withWebhookSignatureVerified");
|
||||
|
@ -62,26 +61,11 @@ export const withWebhookSignatureVerified =
|
|||
});
|
||||
}
|
||||
} 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 {
|
||||
await jose.flattenedVerify(jws, remoteJwks);
|
||||
|
||||
await verifySignature(saleorDomain, payloadSignature, request.rawBody);
|
||||
debug("JWKS verified");
|
||||
} catch {
|
||||
debug("JWKS verification failed");
|
||||
|
||||
return Response.BadRequest({
|
||||
success: false,
|
||||
message: `${ERROR_MESSAGE} Verification using public key has failed.`,
|
||||
|
|
|
@ -59,7 +59,9 @@ export interface AppWebhook {
|
|||
name: string;
|
||||
asyncEvents?: 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;
|
||||
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