diff --git a/docs/app-bridge.md b/docs/app-bridge.md index 4d26778..588dd78 100644 --- a/docs/app-bridge.md +++ b/docs/app-bridge.md @@ -17,6 +17,8 @@ Options object is following: ``` type AppBridgeOptions = { targetDomain?: string; + saleorApiUrl?: string; + initialLocale?: LocaleCode; }; ``` @@ -36,6 +38,11 @@ type AppBridgeState = { domain: string; path: string; theme: ThemeType; + locale: LocaleCode; // See src/locales.ts + /** + * Full URL including protocol and path where graphql is available + */ + saleorApiUrl: string; }; ``` diff --git a/src/app-bridge/app-bridge-provider.test.tsx b/src/app-bridge/app-bridge-provider.test.tsx index fec6093..30c2837 100644 --- a/src/app-bridge/app-bridge-provider.test.tsx +++ b/src/app-bridge/app-bridge-provider.test.tsx @@ -4,10 +4,12 @@ import { describe, expect, it, vi } from "vitest"; import { AppBridge } from "./app-bridge"; import { AppBridgeProvider, useAppBridge } from "./app-bridge-provider"; +import { AppIframeParams } from "./app-iframe-params"; import { DashboardEventFactory } from "./events"; const origin = "http://example.com"; const domain = "saleor.domain.host"; +const apiUrl = "https://saleor.domain.host/graphql/"; Object.defineProperty(window.document, "referrer", { value: origin, @@ -16,7 +18,7 @@ Object.defineProperty(window.document, "referrer", { Object.defineProperty(window, "location", { value: { - href: `${origin}?domain=${domain}&id=appid`, + href: `${origin}?${AppIframeParams.DOMAIN}=${domain}&${AppIframeParams.APP_ID}=appid&${AppIframeParams.SALEOR_API_URL}=${apiUrl}`, }, writable: true, }); @@ -37,7 +39,8 @@ describe("AppBridgeProvider", () => { @@ -86,19 +89,20 @@ describe("useAppBridge hook", () => { it("Returned instance provided in Provider", () => { const appBridge = new AppBridge({ - targetDomain: "test-domain", + targetDomain: domain, }); const { result } = renderHook(() => useAppBridge(), { wrapper: (props: {}) => , }); - expect(result.current.appBridge?.getState().domain).toBe("test-domain"); + expect(result.current.appBridge?.getState().domain).toBe(domain); }); it("Stores active state in React State", () => { const appBridge = new AppBridge({ - targetDomain: origin, + targetDomain: domain, + saleorApiUrl: apiUrl, }); const renderCallback = vi.fn(); @@ -128,12 +132,13 @@ describe("useAppBridge hook", () => { return waitFor(() => { expect(renderCallback).toHaveBeenCalledTimes(2); expect(renderCallback).toHaveBeenCalledWith({ - domain: "http://example.com", + domain, id: "appid", path: "", ready: false, theme: "light", locale: "en", + saleorApiUrl: apiUrl, }); }); }); diff --git a/src/app-bridge/app-bridge-state.test.ts b/src/app-bridge/app-bridge-state.test.ts index 7a5fb8b..06e6c5a 100644 --- a/src/app-bridge/app-bridge-state.test.ts +++ b/src/app-bridge/app-bridge-state.test.ts @@ -13,6 +13,7 @@ describe("app-bridge-state.ts", () => { path: "/", theme: "light", locale: "en", + saleorApiUrl: "", }); }); @@ -20,7 +21,8 @@ describe("app-bridge-state.ts", () => { const instance = new AppBridgeStateContainer(); const newState: Partial = { - domain: "https://my-saleor-instance.cloud", + domain: "my-saleor-instance.cloud", + saleorApiUrl: "https://my-saleor-instance.cloud/graphql/", id: "foo-bar", path: "/", theme: "light", diff --git a/src/app-bridge/app-bridge-state.ts b/src/app-bridge/app-bridge-state.ts index 25de828..01e4822 100644 --- a/src/app-bridge/app-bridge-state.ts +++ b/src/app-bridge/app-bridge-state.ts @@ -9,6 +9,7 @@ export type AppBridgeState = { path: string; theme: ThemeType; locale: LocaleCode; + saleorApiUrl: string; }; type Options = { @@ -19,6 +20,7 @@ export class AppBridgeStateContainer { private state: AppBridgeState = { id: "", domain: "", + saleorApiUrl: "", ready: false, path: "/", theme: "light", diff --git a/src/app-bridge/app-bridge.ts b/src/app-bridge/app-bridge.ts index 88c18d7..125201e 100644 --- a/src/app-bridge/app-bridge.ts +++ b/src/app-bridge/app-bridge.ts @@ -3,6 +3,7 @@ import debugPkg from "debug"; import { LocaleCode } from "../locales"; import { Actions } from "./actions"; import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state"; +import { AppIframeParams } from "./app-iframe-params"; import { SSR } from "./constants"; import { Events, EventType, PayloadOfEvent, ThemeType } from "./events"; @@ -68,6 +69,7 @@ const createEmptySubscribeMap = (): SubscribeMap => ({ export type AppBridgeOptions = { targetDomain?: string; + saleorApiUrl?: string; initialLocale?: LocaleCode; }; @@ -75,16 +77,22 @@ export type AppBridgeOptions = { * TODO: Consider validating locale if wrong code provided */ const getLocaleFromUrl = () => - (new URL(window.location.href).searchParams.get("locale") as LocaleCode) || undefined; + (new URL(window.location.href).searchParams.get(AppIframeParams.LOCALE) as LocaleCode) || + undefined; /** * TODO: Probably remove empty string fallback */ -const getDomainFromUrl = () => new URL(window.location.href).searchParams.get("domain") || ""; +const getDomainFromUrl = () => + new URL(window.location.href).searchParams.get(AppIframeParams.DOMAIN) || ""; + +const getSaleorApiUrlFromUrl = () => + new URL(window.location.href).searchParams.get(AppIframeParams.SALEOR_API_URL) || ""; const getDefaultOptions = (): AppBridgeOptions => ({ targetDomain: getDomainFromUrl(), - initialLocale: getLocaleFromUrl(), + saleorApiUrl: getSaleorApiUrlFromUrl(), + initialLocale: getLocaleFromUrl() ?? "en", }); export class AppBridge { @@ -121,9 +129,17 @@ export class AppBridge { console.warn("document.referrer is empty"); } + if (!this.combinedOptions.saleorApiUrl) { + debug("?saleorApiUrl was not found in iframe url"); + } + if (!this.combinedOptions.targetDomain) { + debug("?domain was not found in iframe url"); + } + + if (!(this.combinedOptions.saleorApiUrl && this.combinedOptions.targetDomain)) { console.error( - "No domain set, ensure ?domain param in iframe exist or provide in AppBridge constructor" + "domain and saleorApiUrl params were not found in iframe url. Ensure at least one of them is present" ); } @@ -243,11 +259,19 @@ export class AppBridge { debug("setInitialState() called"); const url = new URL(window.location.href); - const id = url.searchParams.get("id") || ""; + const id = url.searchParams.get(AppIframeParams.APP_ID) || ""; const path = window.location.pathname || ""; - const theme: ThemeType = url.searchParams.get("theme") === "light" ? "light" : "dark"; + const theme: ThemeType = + url.searchParams.get(AppIframeParams.THEME) === "light" ? "light" : "dark"; - const state = { domain: this.combinedOptions.targetDomain, id, path, theme }; + const state: Partial = { + domain: this.combinedOptions.targetDomain, + id, + path, + theme, + saleorApiUrl: this.combinedOptions.saleorApiUrl, + locale: this.combinedOptions.initialLocale, + }; debug("setInitialState() will setState with %j", state); diff --git a/src/app-bridge/app-iframe-params.ts b/src/app-bridge/app-iframe-params.ts new file mode 100644 index 0000000..dbc4922 --- /dev/null +++ b/src/app-bridge/app-iframe-params.ts @@ -0,0 +1,10 @@ +/** + * Contains keys of SearchParams added to iframe src + */ +export const AppIframeParams = { + APP_ID: "id", + THEME: "theme", + DOMAIN: "domain", + SALEOR_API_URL: "saleorApiUrl", + LOCALE: "locale", +}; diff --git a/src/app-bridge/index.ts b/src/app-bridge/index.ts index 3b15aec..33b6925 100644 --- a/src/app-bridge/index.ts +++ b/src/app-bridge/index.ts @@ -4,6 +4,7 @@ export { AppBridge }; export * from "./actions"; export * from "./app-bridge-provider"; +export * from "./app-iframe-params"; export * from "./events"; export * from "./types"; export * from "./use-dashboard-token"; diff --git a/src/const.ts b/src/const.ts index a908391..9911491 100644 --- a/src/const.ts +++ b/src/const.ts @@ -2,5 +2,6 @@ export const SALEOR_DOMAIN_HEADER = "saleor-domain"; export const SALEOR_EVENT_HEADER = "saleor-event"; export const SALEOR_SIGNATURE_HEADER = "saleor-signature"; export const SALEOR_AUTHORIZATION_BEARER_HEADER = "authorization-bearer"; +export const SALEOR_API_URL_HEADER = "saleor-api-url"; export * from "./locales"; diff --git a/src/handlers/next/process-async-saleor-webhook.test.ts b/src/handlers/next/process-async-saleor-webhook.test.ts index 30e9628..4fa4a6c 100644 --- a/src/handlers/next/process-async-saleor-webhook.test.ts +++ b/src/handlers/next/process-async-saleor-webhook.test.ts @@ -13,6 +13,11 @@ vi.mock("./../../verify-signature", () => ({ throw new Error("Wrong signature"); } }), + verifySignatureFromApiUrl: vi.fn((domain, signature) => { + if (signature !== "mocked_signature") { + throw new Error("Wrong signature"); + } + }), })); vi.mock("raw-body", () => ({ @@ -43,6 +48,7 @@ describe("processAsyncSaleorWebhook", () => { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", "saleor-domain": "example.com", + "saleor-api-url": "https://example.com/graphql/", "saleor-event": "product_updated", "saleor-signature": "mocked_signature", "content-length": "0", // is ignored by mocked raw-body diff --git a/src/handlers/next/process-async-saleor-webhook.ts b/src/handlers/next/process-async-saleor-webhook.ts index b793d06..b88f33f 100644 --- a/src/handlers/next/process-async-saleor-webhook.ts +++ b/src/handlers/next/process-async-saleor-webhook.ts @@ -5,7 +5,7 @@ import { APL } from "../../APL"; import { AuthData } from "../../APL/apl"; import { createDebug } from "../../debug"; import { getBaseUrl, getSaleorHeaders } from "../../headers"; -import { verifySignature } from "../../verify-signature"; +import { verifySignature, verifySignatureFromApiUrl } from "../../verify-signature"; const debug = createDebug("processAsyncWebhook"); @@ -69,7 +69,7 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async ({ debug("Wrong HTTP method"); throw new WebhookError("Wrong request method, only POST allowed", "WRONG_METHOD"); } - const { event, domain, signature } = getSaleorHeaders(req.headers); + const { event, domain, signature, saleorApiUrl } = getSaleorHeaders(req.headers); const baseUrl = getBaseUrl(req.headers); if (!baseUrl) { @@ -134,7 +134,14 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async ({ // Payload signature check // TODO: Since it require additional request, can we cache it's response? try { - await verifySignature(domain, signature, rawBody); + /** + * saleorApiUrl is a new header, is it if available. Verification by domain will be removed in future versions + */ + if (saleorApiUrl) { + await verifySignatureFromApiUrl(saleorApiUrl, signature, rawBody); + } else { + await verifySignature(domain, signature, rawBody); + } } catch { debug("Request signature check failed"); throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED"); diff --git a/src/handlers/next/saleor-async-webhook.ts b/src/handlers/next/saleor-async-webhook.ts index cfffe23..fba8b73 100644 --- a/src/handlers/next/saleor-async-webhook.ts +++ b/src/handlers/next/saleor-async-webhook.ts @@ -127,9 +127,6 @@ export class SaleorAsyncWebhook { /** * 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): NextApiHandler { return async (req, res) => { diff --git a/src/headers.ts b/src/headers.ts index a321690..c0a48ed 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -1,4 +1,5 @@ import { + SALEOR_API_URL_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_DOMAIN_HEADER, SALEOR_EVENT_HEADER, @@ -8,13 +9,12 @@ import { const toStringOrUndefined = (value: string | string[] | undefined) => value ? value.toString() : undefined; -export const getSaleorHeaders = (headers: { - [name: string]: string | string[] | undefined; -}): Record => ({ +export const getSaleorHeaders = (headers: { [name: string]: string | string[] | undefined }) => ({ domain: toStringOrUndefined(headers[SALEOR_DOMAIN_HEADER]), authorizationBearer: toStringOrUndefined(headers[SALEOR_AUTHORIZATION_BEARER_HEADER]), signature: toStringOrUndefined(headers[SALEOR_SIGNATURE_HEADER]), event: toStringOrUndefined(headers[SALEOR_EVENT_HEADER]), + saleorApiUrl: toStringOrUndefined(headers[SALEOR_API_URL_HEADER]), }); export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => { diff --git a/src/urls.ts b/src/urls.ts index 3386940..9b78af9 100644 --- a/src/urls.ts +++ b/src/urls.ts @@ -4,9 +4,18 @@ const resolveUrlProtocol = (saleorDomain: string): string => saleorDomain === "localhost:8000" ? "http" : "https"; +/** + * @deprecated use getJwksUrlFromSaleorApiUrl + */ export const getJwksUrl = (saleorDomain: string): string => `${resolveUrlProtocol(saleorDomain)}://${saleorDomain}/.well-known/jwks.json`; +export const getJwksUrlFromSaleorApiUrl = (saleorApiUrl: string): string => + `${new URL(saleorApiUrl).origin}/.well-known/jwks.json`; + +/** + * @deprecated Use saleor-api-url header + */ export const getGraphQLUrl = (saleorDomain: string): string => `${resolveUrlProtocol(saleorDomain)}://${saleorDomain}/graphql/`; diff --git a/src/verify-signature.ts b/src/verify-signature.ts index 5f8b0b5..5bba505 100644 --- a/src/verify-signature.ts +++ b/src/verify-signature.ts @@ -1,13 +1,13 @@ import * as jose from "jose"; import { createDebug } from "./debug"; -import { getJwksUrl } from "./urls"; +import { getJwksUrl, getJwksUrlFromSaleorApiUrl } 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 + * @deprecated + * use verifySignatureFromApiUrl */ export const verifySignature = async (domain: string, signature: string, rawBody: string) => { const [header, , jwsSignature] = signature.split("."); @@ -31,3 +31,34 @@ export const verifySignature = async (domain: string, signature: string, rawBody throw new Error("JWKS verification failed"); } }; + +/** + * 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 verifySignatureFromApiUrl = async ( + apiUrl: 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(getJwksUrlFromSaleorApiUrl(apiUrl)) + ) 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"); + } +};