Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[vercel only app-avatax] poc app router #1478

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
14 changes: 14 additions & 0 deletions apps/avatax/app-sdk-handlers/fetch-remote-jwks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const getJwksUrlFromSaleorApiUrl = (saleorApiUrl: string): string =>
`${new URL(saleorApiUrl).origin}/.well-known/jwks.json`;

export const fetchRemoteJwks = async (saleorApiUrl: string) => {
try {
const jwksResponse = await fetch(getJwksUrlFromSaleorApiUrl(saleorApiUrl));

const jwksText = await jwksResponse.text();

return jwksText;
} catch (err) {
throw err;
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { SpanKind, SpanStatusCode } from "@opentelemetry/api";

import { NextRequest } from "next/server";
import { APL, AuthData } from "@saleor/app-sdk/APL";
import { getOtelTracer } from "@saleor/apps-otel/src/otel-tracer";
import { parseSchemaVersion } from "@saleor/webhook-utils/src/parse-schema-version";
import { verifySignatureWithJwks } from "@saleor/app-sdk/verify-signature";
import { getBaseUrl, getSaleorHeaders } from "../../utils";
import { fetchRemoteJwks } from "../../fetch-remote-jwks";

export type SaleorWebhookError =
| "OTHER"
| "MISSING_HOST_HEADER"
| "MISSING_DOMAIN_HEADER"
| "MISSING_API_URL_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"
| "CONFIGURATION_ERROR";

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;
/** For Saleor < 3.15 it will be null. */
schemaVersion: number | null;
};

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

type ProcessSaleorWebhook = <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 processSaleorWebhook: ProcessSaleorWebhook = async <T>({
req,
apl,
allowedEvent,
}: ProcessSaleorWebhookArgs): Promise<WebhookContext<T>> => {
const tracer = getOtelTracer();

return tracer.startActiveSpan(
"processSaleorWebhook",
{
kind: SpanKind.INTERNAL,
attributes: {
allowedEvent,
},
},
async (span) => {
try {
if (req.method !== "POST") {
throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD");
}

const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers);
const baseUrl = getBaseUrl(req.headers);

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

if (!saleorApiUrl) {
throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER");
}

if (!event) {
throw new WebhookError("Missing saleor-event header", "MISSING_EVENT_HEADER");
}

const expected = allowedEvent.toLowerCase();

if (event !== expected) {
throw new WebhookError(
`Wrong incoming request event: ${event}. Expected: ${expected}`,
"WRONG_EVENT",
);
}

if (!signature) {
throw new WebhookError("Missing saleor-signature header", "MISSING_SIGNATURE_HEADER");
}

const rawBody = await req.text();

if (!rawBody) {
throw new WebhookError("Missing request body", "MISSING_REQUEST_BODY");
}

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

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

let parsedSchemaVersion: number | null = null;

try {
parsedSchemaVersion = parseSchemaVersion(parsedBody.version);
} catch {}

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

if (!authData) {
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 {
if (!authData.jwks) {
throw new Error("JWKS not found in AuthData");
}

await verifySignatureWithJwks(authData.jwks, signature, rawBody);
} catch {
const newJwks = await fetchRemoteJwks(authData.saleorApiUrl).catch((e) => {
throw new WebhookError("Fetching remote JWKS failed", "SIGNATURE_VERIFICATION_FAILED");
});

try {
await verifySignatureWithJwks(newJwks, signature, rawBody);

await apl.set({ ...authData, jwks: newJwks });
} catch {
throw new WebhookError(
"Request signature check failed",
"SIGNATURE_VERIFICATION_FAILED",
);
}
}

span.setStatus({
code: SpanStatusCode.OK,
});

return {
baseUrl,
event,
payload: parsedBody as T,
authData,
schemaVersion: parsedSchemaVersion,
};
} catch (err) {
const message = (err as Error)?.message ?? "Unknown error";

span.setStatus({
code: SpanStatusCode.ERROR,
message,
});

throw err;
} finally {
span.end();
}
},
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextWebhookApiHandler, SaleorWebhook, WebhookConfig } from "./saleor-webhook";
import { buildSyncWebhookResponsePayload } from "./sync-webhook-response-builder";
import { SyncWebhookEventType } from "@saleor/app-sdk/types";

type InjectedContext<TEvent extends SyncWebhookEventType> = {
buildResponse: typeof buildSyncWebhookResponsePayload<TEvent>;
};

export class SaleorSyncWebhook<
TPayload = unknown,
TEvent extends SyncWebhookEventType = SyncWebhookEventType,
> extends SaleorWebhook<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: NextWebhookApiHandler<
TPayload,
{
buildResponse: typeof buildSyncWebhookResponsePayload<TEvent>;
}
>,
) {
return super.createHandler(handlerFn);
}
}
Loading
Loading