From 62bdb80385d3299c0592e8d15e74c70db3c16718 Mon Sep 17 00:00:00 2001 From: Krzysztof Wolski Date: Wed, 11 Jan 2023 16:55:10 +0100 Subject: [PATCH] Implement APL 2.0 (#143) * Implement APL 2.0 * Rename RestAPL to SaleorCloud APL * Add mapping helper * Modify Saleor APL documentation * Update rest of the docs * Update the node version in actions * Update fetch mock * Remove node-fetch pkg * Remove console log * Update test after the rebase --- .github/workflows/main.yml | 12 +-- docs/apl.md | 72 ++++++++--------- docs/app-bridge.md | 6 +- docs/protected-handlers.md | 12 +-- package.json | 1 - pnpm-lock.yaml | 6 +- src/APL/apl.ts | 7 +- src/APL/auth-data-from-object.ts | 23 ++++++ src/APL/auth-data-from-string.ts | 19 +++++ src/APL/file-apl.test.ts | 16 ++-- src/APL/file-apl.ts | 14 ++-- src/APL/has-auth-data.ts | 10 +++ src/APL/index.ts | 2 +- src/APL/{rest-apl.ts => saleor-cloud-apl.ts} | 77 +++++++++++------- src/APL/upstash-apl.test.ts | 32 ++++---- src/APL/upstash-apl.ts | 30 +++---- src/APL/vercel-apl.test.ts | 79 ++++++++++--------- src/APL/vercel-apl.ts | 29 +++---- src/fetch-remote-jwks.ts | 6 ++ src/get-app-id.ts | 15 +++- .../next/create-app-register-handler.test.ts | 12 ++- .../next/create-app-register-handler.ts | 25 +++++- src/handlers/next/create-protected-handler.ts | 1 + .../next/process-async-saleor-webhook.test.ts | 23 ++++-- .../next/process-async-saleor-webhook.ts | 41 +++++----- .../next/process-protected-handler.test.ts | 29 ++++--- .../next/process-protected-handler.ts | 29 +++---- src/handlers/next/saleor-async-webhook.ts | 3 +- src/verify-jwt.test.ts | 8 +- src/verify-jwt.ts | 8 +- src/verify-signature.ts | 29 +++++++ 31 files changed, 409 insertions(+), 267 deletions(-) create mode 100644 src/APL/auth-data-from-object.ts create mode 100644 src/APL/auth-data-from-string.ts create mode 100644 src/APL/has-auth-data.ts rename src/APL/{rest-apl.ts => saleor-cloud-apl.ts} (57%) create mode 100644 src/fetch-remote-jwks.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 59bcf12..7aa4086 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -5,12 +5,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v2.2.2 + - uses: pnpm/action-setup@v2.2.4 with: version: 7.5.0 # Use 7.5.0 like other Saleor packages, to avoid pnpm version conflicts. - uses: actions/setup-node@v3 with: - node-version: 16.x + node-version: 18 cache: pnpm - name: Install dependencies run: pnpm install @@ -21,12 +21,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v2.2.2 + - uses: pnpm/action-setup@v2.2.4 with: version: 7.5.0 - uses: actions/setup-node@v3 with: - node-version: 16.x + node-version: 18 cache: "pnpm" - name: Install dependencies run: pnpm install @@ -50,12 +50,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v2.2.2 + - uses: pnpm/action-setup@v2.2.4 with: version: 7.5.0 - uses: actions/setup-node@v3 with: - node-version: 16.x + node-version: 18 cache: pnpm - uses: actions/cache@v3 with: diff --git a/docs/apl.md b/docs/apl.md index 22dace1..9380f84 100644 --- a/docs/apl.md +++ b/docs/apl.md @@ -4,11 +4,11 @@ APL is an interface for managing auth data of registered Apps. Implementing it d ## Available methods -- `get: (domain: string) => Promise` - If the entry for given domain exists, returns AuthData (`{ domain: string, token: string }`) object. +- `get: (apiUrl: string) => Promise` - If the entry for given apiUrl exists, returns AuthData object. - `set: (authData: AuthData) => Promise` - Save auth data. -- `delete: (domain: string) => Promise` - Remove auth data fot the given domain. +- `delete: (apiUrl: string) => Promise` - Remove auth data fot the given API URL. - `getAll: () => Promise` - Returns all auth data available. @@ -16,6 +16,26 @@ APL is an interface for managing auth data of registered Apps. Implementing it d - `isConfigured: () => Promise` - Check if persistence layer behind APL is configured. For example: env variable required by database connection +## AuthData + +Interface containing data used for communication with the Saleor API: + +```ts +export interface AuthData { + domain: string; + token: string; + apiUrl: string; + appId: string; + jwks: string; +} +``` + +- `domain` - Domain of the API +- `token` - Authorization token +- `apiUrl` - Full URL to the Saleor GraphQL API +- `appID` - ID of the app assigned during the installation process +- `jwks` - JSON Web Key Set available at `https:///.well-known/jwks.json`, cached in the APL for the faster webhook validation + ## AplReadyResult & ApConfiguredResult Responses from `isReady()` and `isConfigured()` should match following: @@ -52,18 +72,18 @@ const client = createClient(); await client.connect(); const redisAPL: APL = { - get: async (domain: string) => { - const token = await client.get(domain); - if (token) { - return { token, domain }; + get: async (apiUrl: string) => { + const response = await client.get(apiUrl); + if (response) { + return JSON.parse(response); } return; }, set: async (authData: AuthData) => { - await client.set(authData.domain, authData.token); + await client.set(authData.apiUrl, JSON.stringify(authData)); }, - delete: async (domain: string) => { - await client.del(domain); + delete: async (apiUrl: string) => { + await client.del(apiUrl); }, getAll: async () => { throw new Exception("Not implemented."); @@ -76,8 +96,8 @@ You'll be able to use it directly: ```ts import { redisAPL } from "./apl"; -const createTestData = async () => { - await redisAPL.set({ domain: "example.com ", token: "test-token" }); +const getSavedAuthData = async () => { + await redisAPL.get("https://demo.saleor.io/graphql/"); }; ``` @@ -115,7 +135,7 @@ export const apl = process.env.VERCEL === "1" ? new VercelAPL() : new FileAPL(); Now you can use it for in your view: ```ts -import { SALEOR_DOMAIN_HEADER } from "@saleor/app-sdk/const"; +import { SALEOR_API_URL_HEADER } from "@saleor/app-sdk/const"; import { withRegisteredSaleorDomainHeader } from "@saleor/app-sdk/middleware"; import type { Handler } from "retes"; import { toNextHandler } from "retes/adapter"; @@ -125,10 +145,10 @@ import { Response } from "retes/response"; import { apl } from "@lib/saleorApp"; const handler: Handler = async (request) => { - const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER]; + const saleorApiUrl = request.headers[SALEOR_API_URL_HEADER]; // Get auth data - const authData = apl.get(saleorDomain); + const authData = apl.get(saleorApiUrl); // view logic... @@ -184,27 +204,3 @@ const apl = new UpstashAPL({ ``` Or using environment variables: `UPSTASH_TOKEN`, `UPSTASH_URL`. - -### RestAPL - -RestAPL is a simple CRUD client that performs basic operations on resource provided in constructor - -Example usage: - -```typescript -import { RestAPL } from "@saleor/app-sdk/APL"; - -const apl = new RestAPL({ - resourceUrl: "https://crudcrud.com/api/2c35cdbf2a2c48669b2feda4ab260e1c", // Required, should point to CRUD resource - headers: { - Authorization: "Bearer XYZ", // Optional. Headers will be merged into request, allowing to pass auth. - }, -}); -``` - -Target resource must accept "standard" CRUD operations: - -- `GET /` - get all -- `GET /:domain` - get AuthData for given domain -- `POST /` - set AuthData (`{domain: string, token: string}`) from BODY -- `DELETE /:domain` - deletes AuthData for given domain diff --git a/docs/app-bridge.md b/docs/app-bridge.md index cdcb12a..de44469 100644 --- a/docs/app-bridge.md +++ b/docs/app-bridge.md @@ -26,7 +26,7 @@ type AppBridgeOptions = { ## Access app state: ```js -const { token, domain, ready, id } = appBridge.getState(); +const { token, saleorApiUrl, ready, id } = appBridge.getState(); ``` Available state represents `AppBridgeState`: @@ -41,8 +41,8 @@ type AppBridgeState = { theme: ThemeType; locale: LocaleCode; // See src/locales.ts /** - * Full URL including protocol and path where graphql is available - */ + * Full URL including protocol and path where GraphQL API is available + **/ saleorApiUrl: string; }; ``` diff --git a/docs/protected-handlers.md b/docs/protected-handlers.md index 305aebc..36c191b 100644 --- a/docs/protected-handlers.md +++ b/docs/protected-handlers.md @@ -15,10 +15,10 @@ export type ProtectedHandlerContext = { `createProtectedHandler` will check if: -- the request has `saleor-domain` header -- the domain has been registered, with help of the APL +- the request has `saleor-api-url` header of the Saleor instance +- the API URL has been registered, with help of the APL - the request has `authorization-bearer` -- the auth token is a valid JWT token created by the Saleor running on the given domain +- the auth token is a valid JWT token created by the Saleor running on the given URL For example purposes our endpoint will only log welcome message: @@ -43,17 +43,17 @@ export const handler = async ( export default createProtectedHandler(handler, saleorApp.apl); ``` -To make your requests successfully communicate with the backend, `saleor-domain` and `authorization-bearer` headers are required: +To make your requests successfully communicate with the backend, `saleor-api-url` and `authorization-bearer` headers are required: ```typescript fetch("/api/protected", { headers: { /** - * Both domain and token are available in the appBridgeState. Based on those + * Both API URL and token are available in the appBridgeState. Based on those * headers the backend will check if the request has enough permissions to * perform the action. */ - "saleor-domain": domain, + "saleor-api-url": apiUrl, "authorization-bearer": token, }, }); diff --git a/package.json b/package.json index 3259ae9..6b71711 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "fast-glob": "^3.2.11", "graphql": "^16.6.0", "jose": "^4.9.2", - "node-fetch": "^2.6.7", "raw-body": "^2.5.1", "retes": "^0.33.0", "uuid": "^8.3.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b5bc9c..94fca97 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,6 @@ specifiers: jsdom: ^20.0.3 lint-staged: ^13.0.3 next: ^12.3.0 - node-fetch: ^2.6.7 node-mocks-http: ^1.11.0 prettier: 2.7.1 raw-body: ^2.5.1 @@ -52,7 +51,6 @@ dependencies: fast-glob: 3.2.11 graphql: 16.6.0 jose: 4.9.2 - node-fetch: 2.6.7 raw-body: 2.5.1 retes: 0.33.0 uuid: 8.3.2 @@ -4468,6 +4466,7 @@ packages: optional: true dependencies: whatwg-url: 5.0.0 + dev: true /node-fetch/3.2.10: resolution: {integrity: sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==, registry: https://registry.npmjs.com/, tarball: https://registry.npmjs.com/node-fetch/-/node-fetch-3.2.10.tgz} @@ -5697,6 +5696,7 @@ packages: /tr46/0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + dev: true /tr46/1.0.1: resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} @@ -6118,6 +6118,7 @@ packages: /webidl-conversions/3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + dev: true /webidl-conversions/4.0.2: resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} @@ -6153,6 +6154,7 @@ packages: dependencies: tr46: 0.0.3 webidl-conversions: 3.0.1 + dev: true /whatwg-url/7.1.0: resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} diff --git a/src/APL/apl.ts b/src/APL/apl.ts index da8b4e2..0cbd182 100644 --- a/src/APL/apl.ts +++ b/src/APL/apl.ts @@ -1,6 +1,9 @@ export interface AuthData { domain: string; token: string; + apiUrl: string; + appId: string; + jwks: string; } export type AplReadyResult = @@ -22,9 +25,9 @@ export type AplConfiguredResult = }; export interface APL { - get: (domain: string) => Promise; + get: (apiUrl: string) => Promise; set: (authData: AuthData) => Promise; - delete: (domain: string) => Promise; + delete: (apiUrl: string) => Promise; getAll: () => Promise; /** * Inform that configuration is finished and correct diff --git a/src/APL/auth-data-from-object.ts b/src/APL/auth-data-from-object.ts new file mode 100644 index 0000000..a22d0f0 --- /dev/null +++ b/src/APL/auth-data-from-object.ts @@ -0,0 +1,23 @@ +import { AuthData } from "./apl"; +import { createAPLDebug } from "./apl-debug"; +import { hasAuthData } from "./has-auth-data"; + +const debug = createAPLDebug("authDataFromObject"); + +/** + * Returns AuthData if the object follows it's structure + */ +export const authDataFromObject = (parsed: unknown): AuthData | undefined => { + if (!hasAuthData(parsed)) { + debug("Given object did not contained AuthData"); + return undefined; + } + const { apiUrl, appId, domain, token, jwks } = parsed as AuthData; + return { + apiUrl, + appId, + domain, + token, + jwks, + }; +}; diff --git a/src/APL/auth-data-from-string.ts b/src/APL/auth-data-from-string.ts new file mode 100644 index 0000000..489da29 --- /dev/null +++ b/src/APL/auth-data-from-string.ts @@ -0,0 +1,19 @@ +import { AuthData } from "./apl"; +import { createAPLDebug } from "./apl-debug"; +import { authDataFromObject } from "./auth-data-from-object"; + +const debug = createAPLDebug("authDataFromString"); + +/** + * Parse given string and returns valid AuthData object if valid + */ +export const authDataFromString = (stringifiedData: string): AuthData | undefined => { + let parsed; + try { + parsed = JSON.parse(stringifiedData); + } catch { + debug("Could not parse given data"); + return undefined; + } + return authDataFromObject(parsed); +}; diff --git a/src/APL/file-apl.test.ts b/src/APL/file-apl.test.ts index 9a02659..4b5b5ca 100644 --- a/src/APL/file-apl.test.ts +++ b/src/APL/file-apl.test.ts @@ -1,11 +1,15 @@ import { promises as fsPromises } from "fs"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { AuthData } from "./apl"; import { FileAPL } from "./file-apl"; -const stubAuthData = { +const stubAuthData: AuthData = { domain: "example.com", token: "example-token", + apiUrl: "https://example.com/graphql/", + appId: "42", + jwks: "{}", }; describe("APL", () => { @@ -19,18 +23,18 @@ describe("APL", () => { vi.spyOn(fsPromises, "readFile").mockResolvedValue("Not a valid JSON"); const apl = new FileAPL(); - await expect(apl.get(stubAuthData.domain)).resolves.toBe(undefined); + await expect(apl.get(stubAuthData.apiUrl)).resolves.toBe(undefined); }); - it("Returns auth data for existing domain", async () => { + it("Returns auth data for existing api url", async () => { vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); const apl = new FileAPL(); - expect(await apl.get(stubAuthData.domain)).toStrictEqual(stubAuthData); + expect(await apl.get(stubAuthData.apiUrl)).toStrictEqual(stubAuthData); }); - it("Returns undefined for unknown domain", async () => { + it("Returns undefined for unknown api url", async () => { vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData)); const apl = new FileAPL(); @@ -71,7 +75,7 @@ describe("APL", () => { const apl = new FileAPL(); - await apl.delete(stubAuthData.domain); + await apl.delete(stubAuthData.apiUrl); expect(spyWriteFile).toBeCalledWith(".saleor-app-auth.json", "{}"); }); diff --git a/src/APL/file-apl.ts b/src/APL/file-apl.ts index 75b452a..26ee67d 100644 --- a/src/APL/file-apl.ts +++ b/src/APL/file-apl.ts @@ -48,11 +48,11 @@ export class FileAPL implements APL { return undefined; } - const { token, domain } = parsedData; + const { token, domain, apiUrl, appId, jwks } = parsedData; - if (token && domain) { + if (token && domain && apiUrl && appId && jwks) { debug("Token and domain found, returning values: %s, %s", domain, `${token[0]}***`); - return { token, domain }; + return { token, domain, apiUrl, appId, jwks }; } return undefined; @@ -77,9 +77,9 @@ export class FileAPL implements APL { } } - async get(domain: string) { + async get(apiUrl: string) { const authData = await this.loadDataFromFile(); - if (domain === authData?.domain) { + if (apiUrl === authData?.apiUrl) { return authData; } return undefined; @@ -89,10 +89,10 @@ export class FileAPL implements APL { await this.saveDataToFile(authData); } - async delete(domain: string) { + async delete(apiUrl: string) { const authData = await this.loadDataFromFile(); - if (domain === authData?.domain) { + if (apiUrl === authData?.apiUrl) { await this.saveDataToFile(); } } diff --git a/src/APL/has-auth-data.ts b/src/APL/has-auth-data.ts new file mode 100644 index 0000000..c58c2f1 --- /dev/null +++ b/src/APL/has-auth-data.ts @@ -0,0 +1,10 @@ +import { hasProp } from "../has-prop"; + +/** + * Checks if given object has fields used by the AuthData + */ +export const hasAuthData = (data: unknown) => + hasProp(data, "domain") && + hasProp(data, "token") && + hasProp(data, "appId") && + hasProp(data, "apiUrl"); diff --git a/src/APL/index.ts b/src/APL/index.ts index 07480ea..4ce2467 100644 --- a/src/APL/index.ts +++ b/src/APL/index.ts @@ -1,5 +1,5 @@ export * from "./apl"; export * from "./file-apl"; -export * from "./rest-apl"; +export * from "./saleor-cloud-apl"; export * from "./upstash-apl"; export * from "./vercel-apl"; diff --git a/src/APL/rest-apl.ts b/src/APL/saleor-cloud-apl.ts similarity index 57% rename from src/APL/rest-apl.ts rename to src/APL/saleor-cloud-apl.ts index 99a280b..1168c24 100644 --- a/src/APL/rest-apl.ts +++ b/src/APL/saleor-cloud-apl.ts @@ -1,43 +1,60 @@ -import { hasProp } from "../has-prop"; import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl"; import { createAPLDebug } from "./apl-debug"; +import { authDataFromObject } from "./auth-data-from-object"; -const debug = createAPLDebug("RestAPL"); +const debug = createAPLDebug("SaleorCloudAPL"); -export type RestAPLConfig = { +export type SaleorCloudAPLConfig = { resourceUrl: string; - headers?: Record; + token: string; }; const validateResponseStatus = (response: Response) => { - if (response.status < 200 || response.status >= 400) { + if (!response.ok) { debug("Response failed with status %s", response.status); throw new Error(`Fetch returned with non 200 status code ${response.status}`); } }; +const mapAuthDataToAPIBody = (authData: AuthData) => ({ + saleor_app_id: authData.appId, + api_url: authData.apiUrl, + jwks: authData.jwks, + domain: authData.domain, + token: authData.token, +}); + /** - * TODO Add test + * + * Saleor Cloud APL - handle auth data management via REST API. + * + * Required configuration options: + * - `resourceUrl` URL to the REST API + * - `token` Authorization token assigned to your deployment + * */ -export class RestAPL implements APL { +export class SaleorCloudAPL implements APL { private readonly resourceUrl: string; - private headers?: Record; + private headers: Record; - constructor(config: RestAPLConfig) { + constructor(config: SaleorCloudAPLConfig) { this.resourceUrl = config.resourceUrl; - this.headers = config.headers; + this.headers = { + Authorization: `Bearer ${config.token}`, + }; } - private getUrlForDomain(domain: string) { - return `${this.resourceUrl}/${domain}`; + private getUrlForDomain(apiUrl: string) { + // API URL has to be base64 encoded + return `${this.resourceUrl}/${btoa(apiUrl)}`; } - async get(domain: string): Promise { - debug("Will fetch data from RestAPL for domain %s", domain); + async get(apiUrl: string): Promise { + debug("Will fetch data from SaleorCloudAPL for apiUrl %s", apiUrl); - const response = await fetch(this.getUrlForDomain(domain), { + const response = await fetch(this.getUrlForDomain(apiUrl), { method: "GET", headers: { "Content-Type": "application/json", ...this.headers }, }).catch((error) => { @@ -51,22 +68,22 @@ export class RestAPL implements APL { debug("Failed to parse response: %s", e?.message ?? "Unknown error"); })) as unknown; - if (hasProp(parsedResponse, "domain") && hasProp(parsedResponse, "token")) { - return { domain: parsedResponse.domain as string, token: parsedResponse.token as string }; + const authData = authDataFromObject(parsedResponse); + if (!authData) { + debug("No auth data for given apiUrl"); + return undefined; } - debug("Response had no domain and token."); - - return undefined; + return authData; } async set(authData: AuthData) { - debug("Saving data to RestAPL for domain: %s", authData.domain); + debug("Saving data to SaleorCloudAPL for domain: %s", authData.domain); const response = await fetch(this.resourceUrl, { method: "POST", headers: { "Content-Type": "application/json", ...this.headers }, - body: JSON.stringify(authData), + body: JSON.stringify(mapAuthDataToAPIBody(authData)), }).catch((e) => { debug("Failed to reach API call: %s", e?.message ?? "Unknown error"); @@ -80,26 +97,25 @@ export class RestAPL implements APL { return undefined; } - async delete(domain: string) { - debug("Deleting data from Rest for domain: %s", domain); + async delete(apiUrl: string) { + debug("Deleting data from SaleorCloud for apiUrl: %s", apiUrl); try { - const response = await fetch(this.getUrlForDomain(domain), { + const response = await fetch(this.getUrlForDomain(apiUrl), { method: "DELETE", headers: { "Content-Type": "application/json", ...this.headers }, - body: JSON.stringify({ domain }), }); debug(`Delete responded with ${response.status} code`); } catch (error) { debug("Error during deleting the data: %s", error); - throw new Error(`Error during saving the data: ${error}`); + throw new Error(`Error during deleting the data: ${error}`); } } async getAll() { - debug("Get all data from Rest"); + debug("Get all data from SaleorCloud"); try { const response = await fetch(this.resourceUrl, { @@ -126,15 +142,16 @@ export class RestAPL implements APL { } : { ready: false, - error: new Error("App is not configured"), + error: new Error("SaleorCloudAPL is not configured"), }; } async isConfigured(): Promise { if (!this.resourceUrl) { + debug("Resource URL has not been specified."); return { configured: false, - error: new Error("RestAPL required resourceUrl param"), + error: new Error("SaleorCloudAPL required resourceUrl param"), }; } diff --git a/src/APL/upstash-apl.test.ts b/src/APL/upstash-apl.test.ts index c48969d..e70e3b0 100644 --- a/src/APL/upstash-apl.test.ts +++ b/src/APL/upstash-apl.test.ts @@ -1,14 +1,11 @@ -import fetch from "node-fetch"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AuthData } from "./apl"; import { UpstashAPL, UpstashAPLConfig, UpstashAPLVariables } from "./upstash-apl"; -vi.mock("node-fetch", () => ({ - default: vi.fn().mockImplementation(() => ""), -})); +const fetchMock = vi.fn(); -const mockFetch = vi.mocked(fetch); +vi.stubGlobal("fetch", fetchMock); const aplConfig: UpstashAPLConfig = { restToken: "token", @@ -18,6 +15,9 @@ const aplConfig: UpstashAPLConfig = { const stubAuthData: AuthData = { domain: "example.com", token: "example-token", + apiUrl: "https://example.com/graphql/", + appId: "42", + jwks: "{}", }; describe("APL", () => { @@ -43,7 +43,7 @@ describe("APL", () => { describe("set", () => { it("Successful save of the auth data", async () => { // @ts-ignore Ignore type of mocked response - mockFetch.mockResolvedValue({ + fetchMock.mockResolvedValue({ status: 200, json: async () => ({ result: "ok" }), }); @@ -51,13 +51,13 @@ describe("APL", () => { restURL: "https://example.com", restToken: "token", }); - await apl.set({ domain: "example.com", token: "token" }); - expect(mockFetch).toBeCalledWith( + await apl.set(stubAuthData); + expect(fetchMock).toBeCalledWith( "https://example.com", { // eslint-disable-next-line quotes - body: '["SET", "example.com", "token"]', + body: `["SET", "${stubAuthData.apiUrl}", "${JSON.stringify(stubAuthData)}"]`, headers: { "Content-Type": "application/json", Authorization: "Bearer token", @@ -69,36 +69,36 @@ describe("APL", () => { it("Raise error when register service returns non 200 response", async () => { // @ts-ignore Ignore type of mocked response - mockFetch.mockResolvedValue({ status: 500 }); + fetchMock.mockResolvedValue({ status: 500 }); const apl = new UpstashAPL({ restURL: "https://example.com", restToken: "token", }); - await expect(apl.set({ domain: "example.com", token: "token" })).rejects.toThrow( + await expect(apl.set(stubAuthData)).rejects.toThrow( "Upstash APL responded with the code 500" ); }); }); describe("get", () => { - describe("Read existing auth data from env", () => { + describe("Read existing auth data from upstash", () => { it("Read existing auth data", async () => { // @ts-ignore Ignore type of mocked response - mockFetch.mockResolvedValue({ + fetchMock.mockResolvedValue({ status: 200, json: async () => ({ - result: stubAuthData.token, + result: JSON.stringify(stubAuthData), }), }); const apl = new UpstashAPL(aplConfig); - expect(await apl.get(stubAuthData.domain)).toStrictEqual(stubAuthData); + expect(await apl.get(stubAuthData.apiUrl)).toStrictEqual(stubAuthData); }); it("Return undefined when unknown domain requested", async () => { // @ts-ignore Ignore type of mocked response - mockFetch.mockResolvedValue({ + fetchMock.mockResolvedValue({ status: 200, json: async () => ({ result: null, diff --git a/src/APL/upstash-apl.ts b/src/APL/upstash-apl.ts index f1044ab..14d0240 100644 --- a/src/APL/upstash-apl.ts +++ b/src/APL/upstash-apl.ts @@ -1,7 +1,5 @@ /* eslint-disable class-methods-use-this */ // eslint-disable-next-line max-classes-per-file -import fetch, { Response } from "node-fetch"; - import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl"; import { createAPLDebug } from "./apl-debug"; @@ -87,37 +85,39 @@ export class UpstashAPL implements APL { return parsedResponse.result; } - private async saveDataToUpstash(authData?: AuthData) { + private async saveDataToUpstash(authData: AuthData) { debug("saveDataToUpstash() called with: %j", { - domain: authData?.domain, - token: authData?.token.substring(0, 4), + apiUrl: authData.apiUrl, + token: authData.token.substring(0, 4), }); - await this.upstashRequest(`["SET", "${authData?.domain}", "${authData?.token}"]`); + const data = JSON.stringify(authData); + await this.upstashRequest(`["SET", "${authData.apiUrl}", "${data}"]`); } - private async deleteDataFromUpstash(domain: string) { - await this.upstashRequest(`["DEL", "${domain}"]`); + private async deleteDataFromUpstash(apiUrl: string) { + await this.upstashRequest(`["DEL", "${apiUrl}"]`); } - private async fetchDataFromUpstash(domain: string) { - const result = await this.upstashRequest(`["GET", "${domain}"]`); + private async fetchDataFromUpstash(apiUrl: string) { + const result = await this.upstashRequest(`["GET", "${apiUrl}"]`); if (result) { - return { domain, token: result }; + const authData = JSON.parse(result); + return authData; } return undefined; } - async get(domain: string) { - return this.fetchDataFromUpstash(domain); + async get(apiUrl: string) { + return this.fetchDataFromUpstash(apiUrl); } async set(authData: AuthData) { await this.saveDataToUpstash(authData); } - async delete(domain: string) { - await this.deleteDataFromUpstash(domain); + async delete(apiUrl: string) { + await this.deleteDataFromUpstash(apiUrl); } async getAll() { diff --git a/src/APL/vercel-apl.test.ts b/src/APL/vercel-apl.test.ts index b73f31a..e7487dc 100644 --- a/src/APL/vercel-apl.test.ts +++ b/src/APL/vercel-apl.test.ts @@ -1,22 +1,24 @@ -import fetch from "node-fetch"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { AuthData } from "./apl"; import { VercelAPL, VercelAPLVariables } from "./vercel-apl"; -vi.mock("node-fetch", () => ({ - default: vi.fn().mockImplementation(() => ""), -})); +const fetchMock = vi.fn(); -const mockFetch = vi.mocked(fetch); +vi.stubGlobal("fetch", fetchMock); +// now you can access it as `IntersectionObserver` or `window.IntersectionObserver` const aplConfig = { deploymentToken: "token", registerAppURL: "http://example.com", }; -const stubAuthData = { +const stubAuthData: AuthData = { domain: "example.com", token: "example-token", + apiUrl: "https://example.com/graphql/", + appId: "42", + jwks: "{}", }; describe("APL", () => { @@ -53,21 +55,23 @@ describe("APL", () => { describe("set", () => { it("Successful save of the auth data", async () => { // @ts-ignore Ignore type of mocked response - mockFetch.mockResolvedValue({ status: 200 }); + fetchMock.mockResolvedValue({ status: 200 }); const apl = new VercelAPL({ registerAppURL: "https://registerService.example.com", deploymentToken: "token", }); - await apl.set({ domain: "example.com", token: "token" }); - expect(mockFetch).toBeCalledWith( + await apl.set(stubAuthData); + expect(fetchMock).toBeCalledWith( "https://registerService.example.com", { body: JSON.stringify({ token: "token", envs: [ - { key: "SALEOR_AUTH_TOKEN", value: "token" }, - { key: "SALEOR_DOMAIN", value: "example.com" }, + { + key: VercelAPLVariables.AUTH_DATA_VARIABLE_NAME, + value: JSON.stringify(stubAuthData), + }, ], }), headers: { @@ -79,25 +83,26 @@ describe("APL", () => { }); it("Successful save of the auth data during reinstallation for the same domain", async () => { - process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME] = "old_token"; - process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = "example.com"; + process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData); // @ts-ignore Ignore type of mocked response - mockFetch.mockResolvedValue({ status: 200 }); + fetchMock.mockResolvedValue({ status: 200 }); const apl = new VercelAPL({ registerAppURL: "https://registerService.example.com", deploymentToken: "token", }); - await apl.set({ domain: "example.com", token: "token" }); - expect(mockFetch).toBeCalledWith( + await apl.set({ ...stubAuthData, token: "new_token" }); + expect(fetchMock).toBeCalledWith( "https://registerService.example.com", { body: JSON.stringify({ token: "token", envs: [ - { key: "SALEOR_AUTH_TOKEN", value: "token" }, - { key: "SALEOR_DOMAIN", value: "example.com" }, + { + key: VercelAPLVariables.AUTH_DATA_VARIABLE_NAME, + value: JSON.stringify({ ...stubAuthData, token: "new_token" }), + }, ], }), headers: { @@ -109,29 +114,30 @@ describe("APL", () => { }); it("Reject save of the auth data during reinstallation for a different domain", async () => { - process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME] = "old_token"; - process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = "not.example.com"; + process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData); // @ts-ignore Ignore type of mocked response - mockFetch.mockResolvedValue({ status: 200 }); + fetchMock.mockResolvedValue({ status: 200 }); const apl = new VercelAPL({ registerAppURL: "https://registerService.example.com", deploymentToken: "token", }); - await expect(apl.set({ domain: "example.com", token: "token" })).rejects.toThrow( + await expect( + apl.set({ ...stubAuthData, domain: "different.domain.example.com" }) + ).rejects.toThrow( "Vercel APL was not able to save auth data, application already registered" ); }); it("Raise error when register service returns non 200 response", async () => { // @ts-ignore Ignore type of mocked response - mockFetch.mockResolvedValue({ status: 500 }); + fetchMock.mockResolvedValue({ status: 500 }); const apl = new VercelAPL({ registerAppURL: "https://registerService.example.com/internalError", deploymentToken: "token", }); - await expect(apl.set({ domain: "example.com", token: "token" })).rejects.toThrow( + await expect(apl.set(stubAuthData)).rejects.toThrow( "Vercel APL was not able to save auth data, register service responded with the code 500" ); }); @@ -140,30 +146,27 @@ describe("APL", () => { describe("get", () => { describe("Read existing auth data from env", () => { it("Read existing auth data", async () => { - process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME] = stubAuthData.token; - process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = stubAuthData.domain; + process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData); const apl = new VercelAPL(aplConfig); - expect(await apl.get(stubAuthData.domain)).toStrictEqual(stubAuthData); + expect(await apl.get(stubAuthData.apiUrl)).toStrictEqual(stubAuthData); }); - it("Return undefined when unknown domain requested", async () => { - process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME] = stubAuthData.token; - process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = stubAuthData.domain; + it("Return undefined when unknown api url requested", async () => { + process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData); const apl = new VercelAPL(aplConfig); - expect(await apl.get("unknown-domain.example.com")).toBe(undefined); + expect(await apl.get("https://unknown-domain.example.com/graphql/")).toBe(undefined); }); it("Return undefined when no data is defined", async () => { - delete process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME]; - delete process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME]; + delete process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME]; const apl = new VercelAPL(aplConfig); - expect(await apl.get("example.com")).toBe(undefined); + expect(await apl.get("https://example.com/graphql/")).toBe(undefined); }); }); }); @@ -171,8 +174,7 @@ describe("APL", () => { describe("getAll", () => { describe("Read existing auth data from env", () => { it("Read existing auth data", async () => { - process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME] = stubAuthData.token; - process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = stubAuthData.domain; + process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData); const apl = new VercelAPL(aplConfig); @@ -180,8 +182,7 @@ describe("APL", () => { }); it("Return empty list when no auth data are existing", async () => { - delete process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME]; - delete process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME]; + delete process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME]; const apl = new VercelAPL(aplConfig); @@ -198,7 +199,7 @@ describe("APL", () => { if (!result.ready) { expect(result.error.message).toEqual( - "Env variables: \"SALEOR_AUTH_TOKEN\", \"SALEOR_DOMAIN\", \"SALEOR_REGISTER_APP_URL\", \"SALEOR_DEPLOYMENT_TOKEN\" not found or is empty. Ensure env variables exist" + "Env variables: \"SALEOR_AUTH_DATA\", \"SALEOR_REGISTER_APP_URL\", \"SALEOR_DEPLOYMENT_TOKEN\" not found or is empty. Ensure env variables exist" ); } else { throw new Error("This should not happen"); diff --git a/src/APL/vercel-apl.ts b/src/APL/vercel-apl.ts index 6c18c99..43f549b 100644 --- a/src/APL/vercel-apl.ts +++ b/src/APL/vercel-apl.ts @@ -1,15 +1,12 @@ /* eslint-disable class-methods-use-this */ // eslint-disable-next-line max-classes-per-file -import fetch, { Response } from "node-fetch"; - import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl"; import { createAPLDebug } from "./apl-debug"; const debug = createAPLDebug("VercelAPL"); export const VercelAPLVariables = { - TOKEN_VARIABLE_NAME: "SALEOR_AUTH_TOKEN", - DOMAIN_VARIABLE_NAME: "SALEOR_DOMAIN", + AUTH_DATA_VARIABLE_NAME: "SALEOR_AUTH_DATA", SALEOR_REGISTER_APP_URL: "SALEOR_REGISTER_APP_URL", SALEOR_DEPLOYMENT_TOKEN: "SALEOR_DEPLOYMENT_TOKEN", }; @@ -27,15 +24,11 @@ export class VercelAplNotReadyError extends Error { export class VercelAplNotConfiguredError extends Error {} const getEnvAuth = (): AuthData | undefined => { - const token = process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME]; - const domain = process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME]; - if (!token || !domain) { + const authDataSerialized = process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME]; + if (!authDataSerialized) { return undefined; } - return { - token, - domain, - }; + return JSON.parse(authDataSerialized) as AuthData; }; export type VercelAPLConfig = { @@ -95,8 +88,10 @@ export class VercelAPL implements APL { body: JSON.stringify({ token: this.deploymentToken, envs: [ - { key: VercelAPLVariables.TOKEN_VARIABLE_NAME, value: authData?.token || "" }, - { key: VercelAPLVariables.DOMAIN_VARIABLE_NAME, value: authData?.domain || "" }, + { + key: VercelAPLVariables.AUTH_DATA_VARIABLE_NAME, + value: authData ? JSON.stringify(authData) : "", + }, ], }), }); @@ -113,10 +108,10 @@ export class VercelAPL implements APL { debug("Register service responded successfully"); } - async get(domain: string) { + async get(apiUrl: string) { const authData = getEnvAuth(); - if (authData && domain === authData?.domain) { + if (authData && apiUrl === authData.apiUrl) { return authData; } return undefined; @@ -131,8 +126,8 @@ export class VercelAPL implements APL { await this.saveDataToVercel(authData); } - async delete(domain: string) { - if (domain === getEnvAuth()?.domain) { + async delete(apiUrl: string) { + if (apiUrl === getEnvAuth()?.apiUrl) { // Override existing data with the empty values await this.saveDataToVercel(); } diff --git a/src/fetch-remote-jwks.ts b/src/fetch-remote-jwks.ts new file mode 100644 index 0000000..deeabd5 --- /dev/null +++ b/src/fetch-remote-jwks.ts @@ -0,0 +1,6 @@ +import { getJwksUrlFromSaleorApiUrl } from "./urls"; + +export const fetchRemoteJwks = async (saleorApiUrl: string) => { + const jwksResponse = await fetch(getJwksUrlFromSaleorApiUrl(saleorApiUrl)); + return jwksResponse.text(); +}; diff --git a/src/get-app-id.ts b/src/get-app-id.ts index fffc17d..549f4e0 100644 --- a/src/get-app-id.ts +++ b/src/get-app-id.ts @@ -1,4 +1,3 @@ -import { AuthData } from "./APL"; import { createDebug } from "./debug"; const debug = createDebug("getAppId"); @@ -11,13 +10,21 @@ type GetIdResponseType = { }; }; -export const getAppId = async (authData: AuthData): Promise => { +export interface GetAppIdProperties { + apiUrl: string; + token: string; +} + +export const getAppId = async ({ + apiUrl, + token, +}: GetAppIdProperties): Promise => { try { - const response = await fetch(`https://${authData.domain}/graphql/`, { + const response = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${authData.token}`, + Authorization: `Bearer ${token}`, }, body: JSON.stringify({ query: ` diff --git a/src/handlers/next/create-app-register-handler.test.ts b/src/handlers/next/create-app-register-handler.test.ts index 804f39c..761def2 100644 --- a/src/handlers/next/create-app-register-handler.test.ts +++ b/src/handlers/next/create-app-register-handler.test.ts @@ -10,6 +10,10 @@ describe("create-app-register-handler", () => { getAppId: vi.fn().mockResolvedValue("42"), })); + vi.mock("../../fetch-remote-jwks", () => ({ + fetchRemoteJwks: vi.fn().mockResolvedValue("{}"), + })); + const mockApl: APL = { get: vi.fn(), set: vi.fn(), @@ -34,7 +38,8 @@ describe("create-app-register-handler", () => { headers: { host: "some-saleor-host.cloud", "x-forwarded-proto": "https", - "saleor-domain": "https://mock-saleor-domain.saleor.cloud", + "saleor-api-url": "https://mock-saleor-domain.saleor.cloud/graphql/", + "saleor-domain": "https://mock-saleor-domain.saleor.cloud/", }, method: "POST", }); @@ -49,8 +54,11 @@ describe("create-app-register-handler", () => { * It fails -> params.auth_token isn't present */ expect(mockApl.set).toHaveBeenCalledWith({ - domain: "https://mock-saleor-domain.saleor.cloud", + apiUrl: "https://mock-saleor-domain.saleor.cloud/graphql/", + domain: "https://mock-saleor-domain.saleor.cloud/", token: "mock-auth-token", + appId: "42", + jwks: "{}", }); }); }); diff --git a/src/handlers/next/create-app-register-handler.ts b/src/handlers/next/create-app-register-handler.ts index 22d0f86..5f35a2d 100644 --- a/src/handlers/next/create-app-register-handler.ts +++ b/src/handlers/next/create-app-register-handler.ts @@ -3,8 +3,9 @@ import { toNextHandler } from "retes/adapter"; import { withMethod } from "retes/middleware"; import { Response } from "retes/response"; -import { SALEOR_DOMAIN_HEADER } from "../../const"; +import { SALEOR_API_URL_HEADER, SALEOR_DOMAIN_HEADER } from "../../const"; import { createDebug } from "../../debug"; +import { fetchRemoteJwks } from "../../fetch-remote-jwks"; import { getAppId } from "../../get-app-id"; import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware"; import { HasAPL } from "../../saleor-app"; @@ -23,6 +24,7 @@ export const createAppRegisterHandler = ({ apl }: CreateAppRegisterHandlerOption debug("Request received"); const authToken = request.params.auth_token; const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER] as string; + const saleorApiUrl = request.headers[SALEOR_API_URL_HEADER] as string; const { configured: aplConfigured } = await apl.isConfigured(); @@ -43,7 +45,7 @@ export const createAppRegisterHandler = ({ apl }: CreateAppRegisterHandlerOption } // Try to get App ID from the API, to confirm that communication can be established - const appId = await getAppId({ domain: saleorDomain, token: authToken }); + const appId = await getAppId({ apiUrl: saleorApiUrl, token: authToken }); if (!appId) { return new Response( { @@ -60,8 +62,25 @@ export const createAppRegisterHandler = ({ apl }: CreateAppRegisterHandlerOption ); } + // Fetch the JWKS which will be used during webhook validation + const jwks = await fetchRemoteJwks(saleorApiUrl); + if (!jwks) { + return new Response( + { + success: false, + error: { + code: "JWKS_NOT_AVAILABLE", + message: "Can't fetch the remote JWKS.", + }, + }, + { + status: 401, + } + ); + } + try { - await apl.set({ domain: saleorDomain, token: authToken }); + await apl.set({ domain: saleorDomain, token: authToken, apiUrl: saleorApiUrl, appId, jwks }); } catch { debug("There was an error during saving the auth data"); return Response.InternalServerError({ diff --git a/src/handlers/next/create-protected-handler.ts b/src/handlers/next/create-protected-handler.ts index 575f418..e41d463 100644 --- a/src/handlers/next/create-protected-handler.ts +++ b/src/handlers/next/create-protected-handler.ts @@ -15,6 +15,7 @@ export const ProtectedHandlerErrorCodeMap: Record ({ throw new Error("Wrong signature"); } }), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + verifySignatureWithJwks: vi.fn((jwks, signature, body) => { + if (signature !== "mocked_signature") { + throw new Error("Wrong signature"); + } + }), })); vi.mock("raw-body", () => ({ @@ -27,11 +33,14 @@ describe("processAsyncSaleorWebhook", () => { let mockRequest: NextApiRequest; const mockAPL: APL = { - get: async (domain: string) => - domain === "example.com" + get: async (apiUrl: string) => + apiUrl === "https://example.com/graphql/" ? { domain: "example.com", token: "mock-token", + apiUrl: "https://example.com/graphql/", + appId: "42", + jwks: "{}", } : undefined, set: vi.fn(), @@ -75,12 +84,12 @@ describe("processAsyncSaleorWebhook", () => { ).rejects.toThrow("Wrong request method"); }); - it("Throw error on missing domain header", async () => { - delete mockRequest.headers["saleor-domain"]; + it("Throw error on missing api url header", async () => { + delete mockRequest.headers["saleor-api-url"]; await expect( processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) - ).rejects.toThrow("Missing saleor-domain header"); + ).rejects.toThrow("Missing saleor-api-url header"); }); it("Throw error on missing event header", async () => { @@ -128,7 +137,7 @@ describe("processAsyncSaleorWebhook", () => { }); it("Throw error on not registered app", async () => { - mockRequest.headers["saleor-domain"] = "not-registered.example.com"; + mockRequest.headers["saleor-api-url"] = "https://not-registered.example.com/graphql/"; await expect( processAsyncSaleorWebhook({ req: mockRequest, @@ -136,7 +145,7 @@ describe("processAsyncSaleorWebhook", () => { allowedEvent: "PRODUCT_UPDATED", }) ).rejects.toThrow( - "Can't find auth data for domain not-registered.example.com. Please register the application" + "Can't find auth data for https://not-registered.example.com/graphql/. Please register the application" ); }); diff --git a/src/handlers/next/process-async-saleor-webhook.ts b/src/handlers/next/process-async-saleor-webhook.ts index 19d7854..5d39f6d 100644 --- a/src/handlers/next/process-async-saleor-webhook.ts +++ b/src/handlers/next/process-async-saleor-webhook.ts @@ -4,8 +4,9 @@ import getRawBody from "raw-body"; import { APL } from "../../APL"; import { AuthData } from "../../APL/apl"; import { createDebug } from "../../debug"; +import { fetchRemoteJwks } from "../../fetch-remote-jwks"; import { getBaseUrl, getSaleorHeaders } from "../../headers"; -import { verifySignature, verifySignatureFromApiUrl } from "../../verify-signature"; +import { verifySignatureWithJwks } from "../../verify-signature"; const debug = createDebug("processAsyncWebhook"); @@ -13,6 +14,7 @@ export type SaleorWebhookError = | "OTHER" | "MISSING_HOST_HEADER" | "MISSING_DOMAIN_HEADER" + | "MISSING_API_URL_HEADER" | "MISSING_EVENT_HEADER" | "MISSING_PAYLOAD_HEADER" | "MISSING_SIGNATURE_HEADER" @@ -74,7 +76,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, saleorApiUrl } = getSaleorHeaders(req.headers); + const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers); const baseUrl = getBaseUrl(req.headers); if (!baseUrl) { @@ -82,9 +84,9 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async ({ throw new WebhookError("Missing host header", "MISSING_HOST_HEADER"); } - if (!domain) { - debug("Missing saleor-domain header"); - throw new WebhookError("Missing saleor-domain header", "MISSING_DOMAIN_HEADER"); + if (!saleorApiUrl) { + debug("Missing saleor-api-url header"); + throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); } if (!event) { @@ -126,30 +128,31 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async ({ } // Check if domain is installed in the app - const authData = await apl.get(domain); + const authData = await apl.get(saleorApiUrl); if (!authData) { - debug("APL didn't found auth data for domain %s", domain); + debug("APL didn't found auth data for %s", saleorApiUrl); throw new WebhookError( - `Can't find auth data for domain ${domain}. Please register the application`, + `Can't find auth data for ${saleorApiUrl}. Please register the application`, "NOT_REGISTERED" ); } // 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); - } + await verifySignatureWithJwks(authData.jwks, signature, rawBody); } catch { - debug("Request signature check failed"); - throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED"); + debug("Request signature check failed. Refresh the JWKS cache and check again"); + const newJwks = await fetchRemoteJwks(authData.apiUrl); + 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 { diff --git a/src/handlers/next/process-protected-handler.test.ts b/src/handlers/next/process-protected-handler.test.ts index 30d386d..794c4e7 100644 --- a/src/handlers/next/process-protected-handler.test.ts +++ b/src/handlers/next/process-protected-handler.test.ts @@ -26,11 +26,14 @@ describe("processSaleorProtectedHandler", () => { let mockRequest: NextApiRequest; const mockAPL: APL = { - get: async (domain: string) => - domain === "example.com" + get: async (apiUrl: string) => + apiUrl === "https://example.com/graphql/" ? { domain: "example.com", token: "mock-token", + apiUrl: "https://example.com/graphql/", + appId: "42", + jwks: "{}", } : undefined, set: vi.fn(), @@ -47,6 +50,7 @@ describe("processSaleorProtectedHandler", () => { 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", "authorization-bearer": validToken, @@ -68,27 +72,22 @@ describe("processSaleorProtectedHandler", () => { authData: { domain: "example.com", token: "mock-token", + apiUrl: "https://example.com/graphql/", + appId: "42", + jwks: "{}", }, baseUrl: "https://some-saleor-host.cloud", }); }); - it("Throw error when app ID can't be fetched", async () => { - vi.mocked(getAppId).mockResolvedValue(""); - - await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( - "Could not get the app ID from the domain example.com" - ); - }); - - it("Throw error when domain header is missing", async () => { + it("Throw error when api url header is missing", async () => { vi.mocked(getAppId).mockResolvedValue(validAppId); vi.mocked(verifyJWT).mockResolvedValue(); - delete mockRequest.headers["saleor-domain"]; + delete mockRequest.headers["saleor-api-url"]; await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( - "Missing saleor-domain header" + "Missing saleor-api-url header" ); }); @@ -107,10 +106,10 @@ describe("processSaleorProtectedHandler", () => { vi.mocked(getAppId).mockResolvedValue(validAppId); vi.mocked(verifyJWT).mockResolvedValue(); - mockRequest.headers["saleor-domain"] = "wrong.example.com"; + mockRequest.headers["saleor-api-url"] = "https://wrong.example.com/graphql/"; await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( - "Can't find auth data for domain wrong.example.com. Please register the application" + "Can't find auth data for saleorApiUrl https://wrong.example.com/graphql/. Please register the application" ); }); diff --git a/src/handlers/next/process-protected-handler.ts b/src/handlers/next/process-protected-handler.ts index 2373764..57085e0 100644 --- a/src/handlers/next/process-protected-handler.ts +++ b/src/handlers/next/process-protected-handler.ts @@ -3,7 +3,6 @@ import { NextApiRequest } from "next"; import { APL } from "../../APL"; import { AuthData } from "../../APL/apl"; import { createDebug } from "../../debug"; -import { getAppId } from "../../get-app-id"; import { getBaseUrl, getSaleorHeaders } from "../../headers"; import { verifyJWT } from "../../verify-jwt"; @@ -13,6 +12,7 @@ export type SaleorProtectedHandlerError = | "OTHER" | "MISSING_HOST_HEADER" | "MISSING_DOMAIN_HEADER" + | "MISSING_API_URL_HEADER" | "MISSING_AUTHORIZATION_BEARER_HEADER" | "NOT_REGISTERED" | "JWT_VERIFICATION_FAILED" @@ -53,7 +53,7 @@ export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = apl, }: ProcessSaleorProtectedHandlerArgs): Promise => { debug("Request processing started"); - const { domain, authorizationBearer: token } = getSaleorHeaders(req.headers); + const { saleorApiUrl, authorizationBearer: token } = getSaleorHeaders(req.headers); const baseUrl = getBaseUrl(req.headers); if (!baseUrl) { @@ -61,9 +61,9 @@ export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = throw new ProtectedHandlerError("Missing host header", "MISSING_HOST_HEADER"); } - if (!domain) { - debug("Missing saleor-domain header"); - throw new ProtectedHandlerError("Missing saleor-domain header", "MISSING_DOMAIN_HEADER"); + if (!saleorApiUrl) { + debug("Missing saleor-api-url header"); + throw new ProtectedHandlerError("Missing saleor-api-url header", "MISSING_API_URL_HEADER"); } if (!token) { @@ -74,27 +74,18 @@ export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = ); } - // Check if domain has been registered in the APL - const authData = await apl.get(domain); + // Check if API URL has been registered in the APL + const authData = await apl.get(saleorApiUrl); if (!authData) { - debug("APL didn't found auth data for domain %s", domain); + debug("APL didn't found auth data for API URL %s", saleorApiUrl); throw new ProtectedHandlerError( - `Can't find auth data for domain ${domain}. Please register the application`, + `Can't find auth data for saleorApiUrl ${saleorApiUrl}. Please register the application`, "NOT_REGISTERED" ); } - const appId = await getAppId(authData); - if (!appId) { - debug("Could not get the app ID."); - throw new ProtectedHandlerError( - `Could not get the app ID from the domain ${domain}`, - "NO_APP_ID" - ); - } - try { - await verifyJWT({ appId, token, domain }); + await verifyJWT({ appId: authData.appId, token, apiUrl: saleorApiUrl }); } catch (e) { throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); } diff --git a/src/handlers/next/saleor-async-webhook.ts b/src/handlers/next/saleor-async-webhook.ts index a9b1358..5a4c5b5 100644 --- a/src/handlers/next/saleor-async-webhook.ts +++ b/src/handlers/next/saleor-async-webhook.ts @@ -38,6 +38,7 @@ export const AsyncWebhookErrorCodeMap: Record = { OTHER: 500, MISSING_HOST_HEADER: 400, MISSING_DOMAIN_HEADER: 400, + MISSING_API_URL_HEADER: 400, MISSING_EVENT_HEADER: 400, MISSING_PAYLOAD_HEADER: 400, MISSING_SIGNATURE_HEADER: 400, @@ -96,7 +97,7 @@ export class SaleorAsyncWebhook { /** * Returns full URL to the webhook, based on provided baseUrl. * - * TODO: Shouldnt it be private? + * TODO: Shouldn't it be private? * * @param baseUrl Base URL used by your application */ diff --git a/src/verify-jwt.test.ts b/src/verify-jwt.test.ts index c3e8b33..c303179 100644 --- a/src/verify-jwt.test.ts +++ b/src/verify-jwt.test.ts @@ -5,7 +5,7 @@ import { verifyJWT } from "./verify-jwt"; const validToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; -const validDomain = "demo.eu.saleor.cloud"; +const validApiUrl = "https://demo.eu.saleor.cloud/graphql/"; const validAppId = "QXBwOjI3NQ=="; @@ -27,18 +27,18 @@ describe("verifyJWT", () => { }); it("Process valid request", async () => { - await verifyJWT({ appId: validAppId, domain: validDomain, token: validToken }); + await verifyJWT({ appId: validAppId, apiUrl: validApiUrl, token: validToken }); }); it("Throw error on decode issue", async () => { await expect( - verifyJWT({ appId: validAppId, domain: validDomain, token: "wrong_token" }) + verifyJWT({ appId: validAppId, apiUrl: validApiUrl, token: "wrong_token" }) ).rejects.toThrow("JWT verification failed: Could not decode authorization token."); }); it("Throw error on app ID missmatch", async () => { await expect( - verifyJWT({ appId: "wrong_id", domain: validDomain, token: validToken }) + verifyJWT({ appId: "wrong_id", apiUrl: validApiUrl, token: validToken }) ).rejects.toThrow("JWT verification failed: Token's app property is different than app ID."); }); }); diff --git a/src/verify-jwt.ts b/src/verify-jwt.ts index 1437dca..5287611 100644 --- a/src/verify-jwt.ts +++ b/src/verify-jwt.ts @@ -1,7 +1,7 @@ import * as jose from "jose"; import { createDebug } from "./debug"; -import { getJwksUrl } from "./urls"; +import { getJwksUrlFromSaleorApiUrl } from "./urls"; const debug = createDebug("verify-jwt"); @@ -11,11 +11,11 @@ export interface DashboardTokenPayload extends jose.JWTPayload { export interface verifyJWTArguments { appId: string; - domain: string; + apiUrl: string; token: string; } -export const verifyJWT = async ({ domain, token, appId }: verifyJWTArguments) => { +export const verifyJWT = async ({ apiUrl, token, appId }: verifyJWTArguments) => { let tokenClaims: DashboardTokenPayload; const ERROR_MESSAGE = "JWT verification failed:"; @@ -38,7 +38,7 @@ export const verifyJWT = async ({ domain, token, appId }: verifyJWTArguments) => try { debug("Trying to create JWKS"); - const JWKS = jose.createRemoteJWKSet(new URL(getJwksUrl(domain))); + const JWKS = jose.createRemoteJWKSet(new URL(getJwksUrlFromSaleorApiUrl(apiUrl))); debug("Trying to compare JWKS with token"); await jose.jwtVerify(token, JWKS); } catch (e) { diff --git a/src/verify-signature.ts b/src/verify-signature.ts index 5bba505..8832c90 100644 --- a/src/verify-signature.ts +++ b/src/verify-signature.ts @@ -62,3 +62,32 @@ export const verifySignatureFromApiUrl = async ( throw new Error("JWKS verification failed"); } }; + +export const verifySignatureWithJwks = async (jwks: string, signature: string, rawBody: string) => { + const [header, , jwsSignature] = signature.split("."); + const jws: jose.FlattenedJWSInput = { + protected: header, + payload: rawBody, + signature: jwsSignature, + }; + + let localJwks: jose.FlattenedVerifyGetKey; + + try { + const parsedJWKS = JSON.parse(jwks); + localJwks = jose.createLocalJWKSet(parsedJWKS) as jose.FlattenedVerifyGetKey; + } catch { + debug("Could not create local JWKSSet from given data: %s", jwks); + throw new Error("JWKS verification failed - could not parse given JWKS"); + } + + debug("Created remote JWKS"); + + try { + await jose.flattenedVerify(jws, localJwks); + debug("JWKS verified"); + } catch { + debug("JWKS verification failed"); + throw new Error("JWKS verification failed"); + } +};