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:
parent
6fbf692a44
commit
ddd65dabf1
14 changed files with 128 additions and 26 deletions
|
@ -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;
|
||||
};
|
||||
```
|
||||
|
||||
|
|
|
@ -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", () => {
|
|||
<AppBridgeProvider
|
||||
appBridgeInstance={
|
||||
new AppBridge({
|
||||
targetDomain: "https://test-domain",
|
||||
targetDomain: domain,
|
||||
saleorApiUrl: apiUrl,
|
||||
})
|
||||
}
|
||||
>
|
||||
|
@ -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: {}) => <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", () => {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<AppBridgeState> = {
|
||||
domain: "https://my-saleor-instance.cloud",
|
||||
domain: "my-saleor-instance.cloud",
|
||||
saleorApiUrl: "https://my-saleor-instance.cloud/graphql/",
|
||||
id: "foo-bar",
|
||||
path: "/",
|
||||
theme: "light",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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<AppBridgeState> = {
|
||||
domain: this.combinedOptions.targetDomain,
|
||||
id,
|
||||
path,
|
||||
theme,
|
||||
saleorApiUrl: this.combinedOptions.saleorApiUrl,
|
||||
locale: this.combinedOptions.initialLocale,
|
||||
};
|
||||
|
||||
debug("setInitialState() will setState with %j", state);
|
||||
|
||||
|
|
10
src/app-bridge/app-iframe-params.ts
Normal file
10
src/app-bridge/app-iframe-params.ts
Normal 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",
|
||||
};
|
|
@ -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";
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 <T>({
|
|||
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 <T>({
|
|||
// Payload signature check
|
||||
// TODO: Since it require additional request, can we cache it's response?
|
||||
try {
|
||||
/**
|
||||
* 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");
|
||||
|
|
|
@ -127,9 +127,6 @@ export class SaleorAsyncWebhook<TPayload = unknown> {
|
|||
/**
|
||||
* 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<TPayload>): NextApiHandler {
|
||||
return async (req, res) => {
|
||||
|
|
|
@ -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<string, string | undefined> => ({
|
||||
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 => {
|
||||
|
|
|
@ -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/`;
|
||||
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue