Skip to content

Commit

Permalink
Add webhook handlers
Browse files Browse the repository at this point in the history
  • Loading branch information
witoszekdev committed Jan 2, 2025
1 parent b0a237a commit 9e5c059
Show file tree
Hide file tree
Showing 12 changed files with 494 additions and 65 deletions.
2 changes: 1 addition & 1 deletion src/handlers/fetch-api/process-protected-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { extractUserFromJwt } from "../../util/extract-user-from-jwt";
import { verifyJWT } from "../../verify-jwt";
import { ProtectedHandlerContext } from "../shared/protected-handler-context";

const debug = createDebug("processProtectedHandler");
const debug = createDebug("WebAPI:processProtectedHandler");

export type SaleorProtectedHandlerError =
| "OTHER"
Expand Down
162 changes: 162 additions & 0 deletions src/handlers/fetch-api/saleor-webhooks/process-saleor-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { APL } from "../../../APL";
import { createDebug } from "../../../debug";
import { fetchRemoteJwks } from "../../../fetch-remote-jwks";
import { getBaseUrlFetchAPI, getSaleorHeadersFetchAPI } from "../../../headers";
import { parseSchemaVersion } from "../../../util";
import { verifySignatureWithJwks } from "../../../verify-signature";
import { WebhookContext, WebhookError } from "../../shared/process-saleor-webhook";

const debug = createDebug("WebAPI:processSaleorWebhook");

interface ProcessSaleorWebhookArgs {
req: Request;
apl: APL;
allowedEvent: string;
}

export const processSaleorWebhook = async <T>({
req,
apl,
allowedEvent,
}: ProcessSaleorWebhookArgs): Promise<WebhookContext<T>> => {
// TODO: Add OTEL

try {
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, signature, saleorApiUrl } = getSaleorHeadersFetchAPI(req.headers);
const baseUrl = getBaseUrlFetchAPI(req.headers);

if (!baseUrl) {
debug("Missing host header");
throw new WebhookError("Missing host header", "MISSING_HOST_HEADER");
}

if (!saleorApiUrl) {
debug("Missing saleor-api-url header");
throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_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");
}

let rawBody: string;

try {
rawBody = await req.text();
} catch (err) {
throw new WebhookError("Error reading request body", "CANT_BE_PARSED");
}

if (!rawBody) {
debug("Missing request body");

throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY");
}

let parsedBody: unknown & { version?: string | null };

try {
parsedBody = JSON.parse(rawBody);
} catch (err) {
throw new WebhookError("Request body can't be parsed", "CANT_BE_PARSED");
}

let parsedSchemaVersion: number | null = null;

try {
parsedSchemaVersion = parseSchemaVersion(parsedBody.version);
} catch {
debug("Schema version cannot be parsed");
}

/**
* Verify if the app is properly installed for given Saleor API URL
*/
const authData = await apl.get(saleorApiUrl);

if (!authData) {
debug("APL didn't found auth data for %s", saleorApiUrl);

throw new WebhookError(
`Can't find auth data for ${saleorApiUrl}. Please register the application`,
"NOT_REGISTERED"
);
}

/**
* Verify payload signature
*
* TODO: Add test for repeat verification scenario
*/
try {
debug("Will verify signature with JWKS saved in AuthData");

if (!authData.jwks) {
throw new Error("JWKS not found in AuthData");
}

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

const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => {
debug(e);

throw new WebhookError("Fetching remote JWKS failed", "SIGNATURE_VERIFICATION_FAILED");
});

debug("Fetched refreshed JWKS");

try {
debug("Second attempt to validate the signature JWKS, using fresh tokens from the API");

await verifySignatureWithJwks(newJwks, signature, rawBody);

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

await apl.set({ ...authData, jwks: newJwks });
} catch {
debug("Second attempt also ended with validation error. Reject the webhook");

throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED");
}
}

return {
baseUrl,
event,
payload: parsedBody as T,
authData,
schemaVersion: parsedSchemaVersion,
};
} catch (err) {
debug("Unexpected error: %O", err);

throw err;
}
};
48 changes: 48 additions & 0 deletions src/handlers/fetch-api/saleor-webhooks/saleor-async-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ASTNode } from "graphql";

import { AsyncWebhookEventType } from "../../../types";
import { SaleorWebApiWebhook, WebApiWebhookHandler, WebhookConfig } from "./saleor-webhook";

export class SaleorAsyncWebhook<TPayload = unknown> extends SaleorWebApiWebhook<TPayload> {
readonly event: AsyncWebhookEventType;

protected readonly eventType = "async" as const;

constructor(
/**
* Omit new required fields and make them optional. Validate in constructor.
* In 0.35.0 remove old fields
*/
configuration: Omit<WebhookConfig<AsyncWebhookEventType>, "event" | "query"> & {
/**
* @deprecated - use `event` instead. Will be removed in 0.35.0
*/
asyncEvent?: AsyncWebhookEventType;
event?: AsyncWebhookEventType;
query?: string | ASTNode;
}
) {
if (!configuration.event && !configuration.asyncEvent) {
throw new Error("event or asyncEvent must be provided. asyncEvent is deprecated");
}

if (!configuration.query && !configuration.subscriptionQueryAst) {
throw new Error(
"query or subscriptionQueryAst must be provided. subscriptionQueryAst is deprecated"
);
}

super({
...configuration,
event: configuration.event! ?? configuration.asyncEvent!,
query: configuration.query! ?? configuration.subscriptionQueryAst!,
});

this.event = configuration.event! ?? configuration.asyncEvent!;
this.query = configuration.query! ?? configuration.subscriptionQueryAst!;
}

createHandler(handlerFn: WebApiWebhookHandler<TPayload>): WebApiWebhookHandler {
return super.createHandler(handlerFn);
}
}
36 changes: 36 additions & 0 deletions src/handlers/fetch-api/saleor-webhooks/saleor-sync-webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { SyncWebhookEventType } from "../../../types";
import { buildSyncWebhookResponsePayload } from "../../shared/sync-webhook-response-builder";
import { SaleorWebApiWebhook, WebApiWebhookHandler, WebhookConfig } from "./saleor-webhook";

type InjectedContext<TEvent extends SyncWebhookEventType> = {
buildResponse: typeof buildSyncWebhookResponsePayload<TEvent>;
};
export class SaleorSyncWebhook<
TPayload = unknown,
TEvent extends SyncWebhookEventType = SyncWebhookEventType
> extends SaleorWebApiWebhook<TPayload, InjectedContext<TEvent>> {
readonly event: TEvent;

protected readonly eventType = "sync" as const;

protected extraContext = {
buildResponse: buildSyncWebhookResponsePayload,
};

constructor(configuration: WebhookConfig<TEvent>) {
super(configuration);

this.event = configuration.event;
}

createHandler(
handlerFn: WebApiWebhookHandler<
TPayload,
{
buildResponse: typeof buildSyncWebhookResponsePayload<TEvent>;
}
>
): WebApiWebhookHandler {
return super.createHandler(handlerFn);
}
}
Loading

0 comments on commit 9e5c059

Please sign in to comment.