Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fuzzy-rocks-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@saleor/app-sdk": patch
---

Allow to override signature verification method in SaleorWebhook class (likely for testing)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";

import { buildSyncWebhookResponsePayload, FormatWebhookErrorResult } from "@/handlers/shared";
import { SaleorWebhookValidator } from "@/handlers/shared/saleor-webhook-validator";
import { SALEOR_API_URL_HEADER, SALEOR_EVENT_HEADER, SALEOR_SIGNATURE_HEADER } from "@/headers";
import { MockAPL } from "@/test-utils/mock-apl";

import { SaleorSyncWebhook, WebApiSyncWebhookHandler } from "./saleor-sync-webhook";
Expand Down Expand Up @@ -125,4 +126,29 @@ describe("Web API SaleorSyncWebhook", () => {
await expect(response.text()).resolves.toBe("Custom error");
expect(handler).not.toHaveBeenCalled();
});

it("should call externally injected signature verification function", async () => {
const mockVerifySignatureFn = vi.fn().mockImplementationOnce(async () => {});

const saleorSyncWebhook = new SaleorSyncWebhook({
...webhookConfiguration,
verifySignatureFn: mockVerifySignatureFn,
});

const handler = saleorSyncWebhook.createHandler(() => new Response("OK", { status: 200 }));

const request = new Request(`${baseUrl}/webhook`, {
method: "POST",
headers: {
[SALEOR_API_URL_HEADER]: "https://example.com/graphql/",
[SALEOR_SIGNATURE_HEADER]: "random-signature-test",
[SALEOR_EVENT_HEADER]: "checkout_calculate_taxes",
},
body: JSON.stringify({}),
});
const response = await handler(request);

expect(response.status).toBe(200);
expect(mockVerifySignatureFn).toHaveBeenCalledOnce();
});
});
18 changes: 16 additions & 2 deletions src/handlers/shared/generic-saleor-webhook.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { ASTNode } from "graphql";

import { APL } from "@/APL";
import { verifySignatureWithJwks } from "@/auth";
import { createDebug } from "@/debug";
import { gqlAstToString } from "@/gql-ast-to-string";
import {
Expand Down Expand Up @@ -32,11 +33,15 @@ export interface GenericWebhookConfig<
request: RequestType,
): Promise<FormatWebhookErrorResult>;
query: string | ASTNode;
/**
* Allows to overwrite the default signature verification function.
*
* This is useful for testing purposes, when you want to fabricate a payload, or to opt-out from the default behavior from the library
*/
verifySignatureFn?: typeof verifySignatureWithJwks;
}

export abstract class GenericSaleorWebhook<TRequestType, TPayload = unknown> {
private webhookValidator = new SaleorWebhookValidator();

protected abstract eventType: "async" | "sync";

name: string;
Expand All @@ -55,6 +60,10 @@ export abstract class GenericSaleorWebhook<TRequestType, TPayload = unknown> {

formatErrorResponse: GenericWebhookConfig<TRequestType>["formatErrorResponse"];

verifySignatureFn: typeof verifySignatureWithJwks;

private webhookValidator: SaleorWebhookValidator;

protected constructor(configuration: GenericWebhookConfig<TRequestType>) {
const { name, webhookPath, event, query, apl, isActive = true } = configuration;

Expand All @@ -66,6 +75,11 @@ export abstract class GenericSaleorWebhook<TRequestType, TPayload = unknown> {
this.apl = apl;
this.onError = configuration.onError;
this.formatErrorResponse = configuration.formatErrorResponse;
this.verifySignatureFn = configuration.verifySignatureFn ?? verifySignatureWithJwks;

this.webhookValidator = new SaleorWebhookValidator({
verifySignatureFn: this.verifySignatureFn,
});
}

/** Gets webhook absolute URL based on baseUrl of app
Expand Down
67 changes: 57 additions & 10 deletions src/handlers/shared/saleor-webhook-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,9 @@ import { MockAPL } from "@/test-utils/mock-apl";
import { SaleorRequestProcessor } from "./saleor-request-processor";
import { SaleorWebhookValidator } from "./saleor-webhook-validator";

vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockImplementation(
async (domain, signature) => {
if (signature !== "mocked_signature") {
throw new Error("Wrong signature");
}
},
);

describe("SaleorWebhookValidator", () => {
const mockAPL = new MockAPL();
const validator = new SaleorWebhookValidator();

let adapter: MockAdapter;
let requestProcessor: SaleorRequestProcessor<unknown>;

Expand All @@ -38,6 +30,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on non-POST request method", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(adapter, "method", "get").mockReturnValue("GET");

const result = await validator.validateRequest({
Expand All @@ -57,6 +53,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on missing base URL", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(adapter, "getBaseUrl").mockReturnValue("");
const result = await validator.validateRequest({
allowedEvent: "PRODUCT_UPDATED",
Expand All @@ -75,6 +75,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on missing api url header", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({
...validHeaders,
// @ts-expect-error testing missing saleorApiUrl
Expand All @@ -98,6 +102,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on missing event header", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({
// @ts-expect-error testing missing event
event: null,
Expand All @@ -122,6 +130,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on mismatched event header", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({
...validHeaders,
event: "different_event",
Expand All @@ -144,6 +156,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on missing signature header", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({
...validHeaders,
// @ts-expect-error testing missing signature
Expand All @@ -167,6 +183,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on missing request body", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(adapter, "getRawBody").mockResolvedValue("");
vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders);

Expand All @@ -187,6 +207,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on unparsable request body", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(adapter, "getRawBody").mockResolvedValue("{ "); // broken JSON
vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders);

Expand All @@ -207,6 +231,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Throws error on unregistered app", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

const unregisteredApiUrl = "https://not-registered.example.com/graphql/";

vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue({
Expand All @@ -231,6 +259,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Fallbacks to null if version is missing in payload", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(adapter, "getRawBody").mockResolvedValue(JSON.stringify({}));
vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders);

Expand All @@ -250,6 +282,10 @@ describe("SaleorWebhookValidator", () => {
});

it("Returns success on valid request with signature passing validation against jwks in auth data", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);

const validator = new SaleorWebhookValidator();

vi.spyOn(requestProcessor, "getSaleorHeaders").mockReturnValue(validHeaders);

const result = await validator.validateRequest({
Expand Down Expand Up @@ -284,10 +320,12 @@ describe("SaleorWebhookValidator", () => {
});

it("Triggers JWKS refresh when initial auth data contains empty JWKS", async () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValue(undefined);
vi.spyOn(mockAPL, "get").mockResolvedValue(authDataNoJwks);
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks").mockResolvedValueOnce(undefined);
vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("new-jwks");

const validator = new SaleorWebhookValidator();

const result = await validator.validateRequest({
allowedEvent: "PRODUCT_UPDATED",
apl: mockAPL,
Expand Down Expand Up @@ -321,15 +359,20 @@ describe("SaleorWebhookValidator", () => {
vi.spyOn(verifySignatureModule, "verifySignatureWithJwks")
.mockRejectedValueOnce(new Error("Signature verification failed")) // First: reject validation due to stale jwks
.mockResolvedValueOnce(undefined); // Second: resolve validation because jwks is now correct

vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("new-jwks");

const validator = new SaleorWebhookValidator();

const result = await validator.validateRequest({
allowedEvent: "PRODUCT_UPDATED",
apl: mockAPL,
adapter,
requestProcessor,
});

console.error(result);

expect(result).toMatchObject({
result: "ok",
context: expect.objectContaining({
Expand Down Expand Up @@ -360,6 +403,8 @@ describe("SaleorWebhookValidator", () => {
new Error("JWKS fetch failed"),
);

const validator = new SaleorWebhookValidator();

const result = await validator.validateRequest({
allowedEvent: "PRODUCT_UPDATED",
apl: mockAPL,
Expand All @@ -383,6 +428,8 @@ describe("SaleorWebhookValidator", () => {
.mockRejectedValueOnce(new Error("Fresh JWKS mismatch")); // Second attempt fails
vi.spyOn(fetchRemoteJwksModule, "fetchRemoteJwks").mockResolvedValue("{}");

const validator = new SaleorWebhookValidator();

const result = await validator.validateRequest({
allowedEvent: "PRODUCT_UPDATED",
apl: mockAPL,
Expand Down
14 changes: 11 additions & 3 deletions src/handlers/shared/saleor-webhook-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { SpanKind, SpanStatusCode } from "@opentelemetry/api";

import { APL } from "@/APL";
import { fetchRemoteJwks } from "@/auth/fetch-remote-jwks";
import { verifySignatureWithJwks } from "@/auth/verify-signature";
import { createDebug } from "@/debug";
import { getOtelTracer } from "@/open-telemetry";
import { SaleorSchemaVersion } from "@/types";
import { parseSchemaVersion } from "@/util/schema-version";

import { verifySignatureWithJwks } from "../../auth/verify-signature";
import { PlatformAdapterInterface } from "./generic-adapter-use-case-types";
import { SaleorRequestProcessor } from "./saleor-request-processor";
import { WebhookContext, WebhookError } from "./saleor-webhook";
Expand All @@ -17,6 +17,14 @@ type WebhookValidationResult<TPayload> =
| { result: "failure"; error: WebhookError | Error };

export class SaleorWebhookValidator {
private verifySignatureWithJwks = verifySignatureWithJwks.bind(this);

constructor(params?: { verifySignatureFn: typeof verifySignatureWithJwks }) {
if (params?.verifySignatureFn) {
this.verifySignatureWithJwks = params.verifySignatureFn;
}
}

private debug = createDebug("processProtectedHandler");

private tracer = getOtelTracer();
Expand Down Expand Up @@ -157,7 +165,7 @@ export class SaleorWebhookValidator {
throw new Error("JWKS not found in AuthData");
}

await verifySignatureWithJwks(authData.jwks, signature, rawBody);
await this.verifySignatureWithJwks(authData.jwks, signature, rawBody);
} catch {
this.debug("Request signature check failed. Refresh the JWKS cache and check again");

Expand All @@ -177,7 +185,7 @@ export class SaleorWebhookValidator {
"Second attempt to validate the signature JWKS, using fresh tokens from the API",
);

await verifySignatureWithJwks(newJwks, signature, rawBody);
await this.verifySignatureWithJwks(newJwks, signature, rawBody);

this.debug("Verification successful - update JWKS in the AuthData");

Expand Down
Loading