Add saleorApiUrl state param to AppBridge (#114)

* Add saleorApiUrl state param to AppBridge

* Apply payload verification check based on full api endpoint url

* Remove deprecation info

* Check if either domain or saleorApiUrl is provided
This commit is contained in:
Lukasz Ostrowski 2022-11-23 10:08:04 +01:00 committed by GitHub
parent 6fbf692a44
commit ddd65dabf1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 128 additions and 26 deletions

View file

@ -17,6 +17,8 @@ Options object is following:
``` ```
type AppBridgeOptions = { type AppBridgeOptions = {
targetDomain?: string; targetDomain?: string;
saleorApiUrl?: string;
initialLocale?: LocaleCode;
}; };
``` ```
@ -36,6 +38,11 @@ type AppBridgeState = {
domain: string; domain: string;
path: string; path: string;
theme: ThemeType; theme: ThemeType;
locale: LocaleCode; // See src/locales.ts
/**
* Full URL including protocol and path where graphql is available
*/
saleorApiUrl: string;
}; };
``` ```

View file

@ -4,10 +4,12 @@ import { describe, expect, it, vi } from "vitest";
import { AppBridge } from "./app-bridge"; import { AppBridge } from "./app-bridge";
import { AppBridgeProvider, useAppBridge } from "./app-bridge-provider"; import { AppBridgeProvider, useAppBridge } from "./app-bridge-provider";
import { AppIframeParams } from "./app-iframe-params";
import { DashboardEventFactory } from "./events"; import { DashboardEventFactory } from "./events";
const origin = "http://example.com"; const origin = "http://example.com";
const domain = "saleor.domain.host"; const domain = "saleor.domain.host";
const apiUrl = "https://saleor.domain.host/graphql/";
Object.defineProperty(window.document, "referrer", { Object.defineProperty(window.document, "referrer", {
value: origin, value: origin,
@ -16,7 +18,7 @@ Object.defineProperty(window.document, "referrer", {
Object.defineProperty(window, "location", { Object.defineProperty(window, "location", {
value: { value: {
href: `${origin}?domain=${domain}&id=appid`, href: `${origin}?${AppIframeParams.DOMAIN}=${domain}&${AppIframeParams.APP_ID}=appid&${AppIframeParams.SALEOR_API_URL}=${apiUrl}`,
}, },
writable: true, writable: true,
}); });
@ -37,7 +39,8 @@ describe("AppBridgeProvider", () => {
<AppBridgeProvider <AppBridgeProvider
appBridgeInstance={ appBridgeInstance={
new AppBridge({ new AppBridge({
targetDomain: "https://test-domain", targetDomain: domain,
saleorApiUrl: apiUrl,
}) })
} }
> >
@ -86,19 +89,20 @@ describe("useAppBridge hook", () => {
it("Returned instance provided in Provider", () => { it("Returned instance provided in Provider", () => {
const appBridge = new AppBridge({ const appBridge = new AppBridge({
targetDomain: "test-domain", targetDomain: domain,
}); });
const { result } = renderHook(() => useAppBridge(), { const { result } = renderHook(() => useAppBridge(), {
wrapper: (props: {}) => <AppBridgeProvider {...props} appBridgeInstance={appBridge} />, wrapper: (props: {}) => <AppBridgeProvider {...props} appBridgeInstance={appBridge} />,
}); });
expect(result.current.appBridge?.getState().domain).toBe("test-domain"); expect(result.current.appBridge?.getState().domain).toBe(domain);
}); });
it("Stores active state in React State", () => { it("Stores active state in React State", () => {
const appBridge = new AppBridge({ const appBridge = new AppBridge({
targetDomain: origin, targetDomain: domain,
saleorApiUrl: apiUrl,
}); });
const renderCallback = vi.fn(); const renderCallback = vi.fn();
@ -128,12 +132,13 @@ describe("useAppBridge hook", () => {
return waitFor(() => { return waitFor(() => {
expect(renderCallback).toHaveBeenCalledTimes(2); expect(renderCallback).toHaveBeenCalledTimes(2);
expect(renderCallback).toHaveBeenCalledWith({ expect(renderCallback).toHaveBeenCalledWith({
domain: "http://example.com", domain,
id: "appid", id: "appid",
path: "", path: "",
ready: false, ready: false,
theme: "light", theme: "light",
locale: "en", locale: "en",
saleorApiUrl: apiUrl,
}); });
}); });
}); });

View file

@ -13,6 +13,7 @@ describe("app-bridge-state.ts", () => {
path: "/", path: "/",
theme: "light", theme: "light",
locale: "en", locale: "en",
saleorApiUrl: "",
}); });
}); });
@ -20,7 +21,8 @@ describe("app-bridge-state.ts", () => {
const instance = new AppBridgeStateContainer(); const instance = new AppBridgeStateContainer();
const newState: Partial<AppBridgeState> = { const newState: Partial<AppBridgeState> = {
domain: "https://my-saleor-instance.cloud", domain: "my-saleor-instance.cloud",
saleorApiUrl: "https://my-saleor-instance.cloud/graphql/",
id: "foo-bar", id: "foo-bar",
path: "/", path: "/",
theme: "light", theme: "light",

View file

@ -9,6 +9,7 @@ export type AppBridgeState = {
path: string; path: string;
theme: ThemeType; theme: ThemeType;
locale: LocaleCode; locale: LocaleCode;
saleorApiUrl: string;
}; };
type Options = { type Options = {
@ -19,6 +20,7 @@ export class AppBridgeStateContainer {
private state: AppBridgeState = { private state: AppBridgeState = {
id: "", id: "",
domain: "", domain: "",
saleorApiUrl: "",
ready: false, ready: false,
path: "/", path: "/",
theme: "light", theme: "light",

View file

@ -3,6 +3,7 @@ import debugPkg from "debug";
import { LocaleCode } from "../locales"; import { LocaleCode } from "../locales";
import { Actions } from "./actions"; import { Actions } from "./actions";
import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state"; import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state";
import { AppIframeParams } from "./app-iframe-params";
import { SSR } from "./constants"; import { SSR } from "./constants";
import { Events, EventType, PayloadOfEvent, ThemeType } from "./events"; import { Events, EventType, PayloadOfEvent, ThemeType } from "./events";
@ -68,6 +69,7 @@ const createEmptySubscribeMap = (): SubscribeMap => ({
export type AppBridgeOptions = { export type AppBridgeOptions = {
targetDomain?: string; targetDomain?: string;
saleorApiUrl?: string;
initialLocale?: LocaleCode; initialLocale?: LocaleCode;
}; };
@ -75,16 +77,22 @@ export type AppBridgeOptions = {
* TODO: Consider validating locale if wrong code provided * TODO: Consider validating locale if wrong code provided
*/ */
const getLocaleFromUrl = () => 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 * 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 => ({ const getDefaultOptions = (): AppBridgeOptions => ({
targetDomain: getDomainFromUrl(), targetDomain: getDomainFromUrl(),
initialLocale: getLocaleFromUrl(), saleorApiUrl: getSaleorApiUrlFromUrl(),
initialLocale: getLocaleFromUrl() ?? "en",
}); });
export class AppBridge { export class AppBridge {
@ -121,9 +129,17 @@ export class AppBridge {
console.warn("document.referrer is empty"); console.warn("document.referrer is empty");
} }
if (!this.combinedOptions.saleorApiUrl) {
debug("?saleorApiUrl was not found in iframe url");
}
if (!this.combinedOptions.targetDomain) { if (!this.combinedOptions.targetDomain) {
debug("?domain was not found in iframe url");
}
if (!(this.combinedOptions.saleorApiUrl && this.combinedOptions.targetDomain)) {
console.error( 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"); debug("setInitialState() called");
const url = new URL(window.location.href); 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 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<AppBridgeState> = {
domain: this.combinedOptions.targetDomain,
id,
path,
theme,
saleorApiUrl: this.combinedOptions.saleorApiUrl,
locale: this.combinedOptions.initialLocale,
};
debug("setInitialState() will setState with %j", state); debug("setInitialState() will setState with %j", state);

View file

@ -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",
};

View file

@ -4,6 +4,7 @@ export { AppBridge };
export * from "./actions"; export * from "./actions";
export * from "./app-bridge-provider"; export * from "./app-bridge-provider";
export * from "./app-iframe-params";
export * from "./events"; export * from "./events";
export * from "./types"; export * from "./types";
export * from "./use-dashboard-token"; export * from "./use-dashboard-token";

View file

@ -2,5 +2,6 @@ export const SALEOR_DOMAIN_HEADER = "saleor-domain";
export const SALEOR_EVENT_HEADER = "saleor-event"; export const SALEOR_EVENT_HEADER = "saleor-event";
export const SALEOR_SIGNATURE_HEADER = "saleor-signature"; export const SALEOR_SIGNATURE_HEADER = "saleor-signature";
export const SALEOR_AUTHORIZATION_BEARER_HEADER = "authorization-bearer"; export const SALEOR_AUTHORIZATION_BEARER_HEADER = "authorization-bearer";
export const SALEOR_API_URL_HEADER = "saleor-api-url";
export * from "./locales"; export * from "./locales";

View file

@ -13,6 +13,11 @@ vi.mock("./../../verify-signature", () => ({
throw new Error("Wrong signature"); throw new Error("Wrong signature");
} }
}), }),
verifySignatureFromApiUrl: vi.fn((domain, signature) => {
if (signature !== "mocked_signature") {
throw new Error("Wrong signature");
}
}),
})); }));
vi.mock("raw-body", () => ({ vi.mock("raw-body", () => ({
@ -43,6 +48,7 @@ describe("processAsyncSaleorWebhook", () => {
host: "some-saleor-host.cloud", host: "some-saleor-host.cloud",
"x-forwarded-proto": "https", "x-forwarded-proto": "https",
"saleor-domain": "example.com", "saleor-domain": "example.com",
"saleor-api-url": "https://example.com/graphql/",
"saleor-event": "product_updated", "saleor-event": "product_updated",
"saleor-signature": "mocked_signature", "saleor-signature": "mocked_signature",
"content-length": "0", // is ignored by mocked raw-body "content-length": "0", // is ignored by mocked raw-body

View file

@ -5,7 +5,7 @@ import { APL } from "../../APL";
import { AuthData } from "../../APL/apl"; import { AuthData } from "../../APL/apl";
import { createDebug } from "../../debug"; import { createDebug } from "../../debug";
import { getBaseUrl, getSaleorHeaders } from "../../headers"; import { getBaseUrl, getSaleorHeaders } from "../../headers";
import { verifySignature } from "../../verify-signature"; import { verifySignature, verifySignatureFromApiUrl } from "../../verify-signature";
const debug = createDebug("processAsyncWebhook"); const debug = createDebug("processAsyncWebhook");
@ -69,7 +69,7 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
debug("Wrong HTTP method"); debug("Wrong HTTP method");
throw new WebhookError("Wrong request method, only POST allowed", "WRONG_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); const baseUrl = getBaseUrl(req.headers);
if (!baseUrl) { if (!baseUrl) {
@ -134,7 +134,14 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
// Payload signature check // Payload signature check
// TODO: Since it require additional request, can we cache it's response? // TODO: Since it require additional request, can we cache it's response?
try { 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 { } catch {
debug("Request signature check failed"); debug("Request signature check failed");
throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED"); throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED");

View file

@ -127,9 +127,6 @@ export class SaleorAsyncWebhook<TPayload = unknown> {
/** /**
* Wraps provided function, to ensure incoming request comes from registered Saleor instance. * Wraps provided function, to ensure incoming request comes from registered Saleor instance.
* Also provides additional `context` object containing typed payload and request properties. * 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 { createHandler(handlerFn: NextWebhookApiHandler<TPayload>): NextApiHandler {
return async (req, res) => { return async (req, res) => {

View file

@ -1,4 +1,5 @@
import { import {
SALEOR_API_URL_HEADER,
SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_AUTHORIZATION_BEARER_HEADER,
SALEOR_DOMAIN_HEADER, SALEOR_DOMAIN_HEADER,
SALEOR_EVENT_HEADER, SALEOR_EVENT_HEADER,
@ -8,13 +9,12 @@ import {
const toStringOrUndefined = (value: string | string[] | undefined) => const toStringOrUndefined = (value: string | string[] | undefined) =>
value ? value.toString() : undefined; value ? value.toString() : undefined;
export const getSaleorHeaders = (headers: { export const getSaleorHeaders = (headers: { [name: string]: string | string[] | undefined }) => ({
[name: string]: string | string[] | undefined;
}): Record<string, string | undefined> => ({
domain: toStringOrUndefined(headers[SALEOR_DOMAIN_HEADER]), domain: toStringOrUndefined(headers[SALEOR_DOMAIN_HEADER]),
authorizationBearer: toStringOrUndefined(headers[SALEOR_AUTHORIZATION_BEARER_HEADER]), authorizationBearer: toStringOrUndefined(headers[SALEOR_AUTHORIZATION_BEARER_HEADER]),
signature: toStringOrUndefined(headers[SALEOR_SIGNATURE_HEADER]), signature: toStringOrUndefined(headers[SALEOR_SIGNATURE_HEADER]),
event: toStringOrUndefined(headers[SALEOR_EVENT_HEADER]), event: toStringOrUndefined(headers[SALEOR_EVENT_HEADER]),
saleorApiUrl: toStringOrUndefined(headers[SALEOR_API_URL_HEADER]),
}); });
export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => { export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => {

View file

@ -4,9 +4,18 @@
const resolveUrlProtocol = (saleorDomain: string): string => const resolveUrlProtocol = (saleorDomain: string): string =>
saleorDomain === "localhost:8000" ? "http" : "https"; saleorDomain === "localhost:8000" ? "http" : "https";
/**
* @deprecated use getJwksUrlFromSaleorApiUrl
*/
export const getJwksUrl = (saleorDomain: string): string => export const getJwksUrl = (saleorDomain: string): string =>
`${resolveUrlProtocol(saleorDomain)}://${saleorDomain}/.well-known/jwks.json`; `${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 => export const getGraphQLUrl = (saleorDomain: string): string =>
`${resolveUrlProtocol(saleorDomain)}://${saleorDomain}/graphql/`; `${resolveUrlProtocol(saleorDomain)}://${saleorDomain}/graphql/`;

View file

@ -1,13 +1,13 @@
import * as jose from "jose"; import * as jose from "jose";
import { createDebug } from "./debug"; import { createDebug } from "./debug";
import { getJwksUrl } from "./urls"; import { getJwksUrl, getJwksUrlFromSaleorApiUrl } from "./urls";
const debug = createDebug("verify-signature"); const debug = createDebug("verify-signature");
/** /**
* Verify payload signature with public key of given `domain` * @deprecated
* https://docs.saleor.io/docs/3.x/developer/extending/apps/asynchronous-webhooks#payload-signature * use verifySignatureFromApiUrl
*/ */
export const verifySignature = async (domain: string, signature: string, rawBody: string) => { export const verifySignature = async (domain: string, signature: string, rawBody: string) => {
const [header, , jwsSignature] = signature.split("."); const [header, , jwsSignature] = signature.split(".");
@ -31,3 +31,34 @@ export const verifySignature = async (domain: string, signature: string, rawBody
throw new Error("JWKS verification failed"); 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");
}
};