diff --git a/.changeset/friendly-melons-approve.md b/.changeset/friendly-melons-approve.md new file mode 100644 index 0000000..3aa9a1b --- /dev/null +++ b/.changeset/friendly-melons-approve.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": minor +--- + +Add EnvAPL which is a read-only single-tenant APL implementation. It can print AuthData from registration process, but the developer is responsible to configure ENV and pass data to the constructor. diff --git a/.changeset/long-hornets-yawn.md b/.changeset/long-hornets-yawn.md new file mode 100644 index 0000000..d981123 --- /dev/null +++ b/.changeset/long-hornets-yawn.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": patch +--- + +"domain" field in AuthData is no longer required. It will be set by registerHandler, but if missing, domain can be resolved from saleorApiUrl diff --git a/.changeset/strong-rings-help.md b/.changeset/strong-rings-help.md new file mode 100644 index 0000000..1d58e8f --- /dev/null +++ b/.changeset/strong-rings-help.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": patch +--- + +JWKS field in AuthData is no longer required. registerHandler will try to set it for cache purposes, but not every time it is possible. If JWKS is not found, registerHandler will fetch it diff --git a/docs/apl.md b/docs/apl.md index a9c6d93..6e7e21f 100644 --- a/docs/apl.md +++ b/docs/apl.md @@ -184,3 +184,72 @@ const apl = new UpstashAPL({ ``` Or using environment variables: `UPSTASH_TOKEN`, `UPSTASH_URL`. + +### EnvAPL + +For very simple, static & single-tenant applications, you may want to just set everything once, usually in env variables and just leave it. + +There is a simple APL for this use case - `EnvAPL` + +> **Warning** +> Using this APL is highly discouraged in any production environment. +> It will break if app token is regenerated, it will not work with any flow related to updates of the app. + +#### EnvAPL usage + +##### 1. Configure app to use env apl + +```tsx +import { EnvAPL } from "@saleor/app-sdk/APL"; + +const apl = new EnvAPL({ + env: { + /** + * Map your env variables here. You dont have these values yet + */ + token: envVars.SALEOR_APP_TOKEN, + appId: envVars.SALEOR_APP_ID, + saleorApiUrl: envVars.SALEOR_API_URL, + }, + /** + * Set it to "true" - during app registration check you app logs. APL will print values you need + */ + printAuthDataOnRegister: true, +}); +``` + +##### 2. Set env variables + +After step 1, you should see your logs in similar way: + +``` +┌──────────────┬─────────────────────────────────────────────┐ +│ (index) │ Values │ +├──────────────┼─────────────────────────────────────────────┤ +│ saleorApiUrl │ 'https://my-saleor-instance.cloud/graphql/' │ +│ appId │ 'app-id' │ +│ token │ 'some-token' │ +│ jwks │ '{}' │ +│ domain │ 'my-saleor-instance.cloud' │ +└──────────────┴─────────────────────────────────────────────┘ +``` + +You need to se env variables in your provider, eg. in Vercel - you need appId, token and saleorApiUrl. +Ensure name of variables match your step 1 constructor options + +##### 3. Turn off logging and redeploy + +After env is set, you should disable printing it for security reasons + +Change constructor to include + +``` +printAuthDataOnRegister: false +``` + +Then, redeploy the app. It should be configured and work for this specific Saleor instance + +Keep in mind that + +- If you promote environment, you need to update saleorApiUrl +- If you reinstall the app, you need to repeat the process to receive new token and ID diff --git a/package.json b/package.json index 8d28e8a..f01a6cf 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "build": "tsup && clear-package-json package.json -o dist/package.json --fields publishConfig && pnpm copy-readme", "clean": "rm -rf ./dist/*", "test": "vitest", - "test:ci": "CI=true vitest --coverage", + "test:ci": "CI=true vitest --coverage --silent", "prepare": "husky install", "lint": "prettier --loglevel warn --write . && eslint --fix .", "copy-readme": "cp README.md dist/README.md", diff --git a/src/APL/apl.ts b/src/APL/apl.ts index fd150db..fc9edbb 100644 --- a/src/APL/apl.ts +++ b/src/APL/apl.ts @@ -1,9 +1,9 @@ export interface AuthData { - domain: string; + domain?: string; token: string; saleorApiUrl: string; appId: string; - jwks: string; + jwks?: string; } export type AplReadyResult = diff --git a/src/APL/env-apl.test.ts b/src/APL/env-apl.test.ts new file mode 100644 index 0000000..5cf6048 --- /dev/null +++ b/src/APL/env-apl.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; + +import { AuthData } from "./apl"; +import { EnvAPL } from "./env-apl"; + +const getMockEnvVars = () => ({ + SALEOR_APP_TOKEN: "some-token", + SALEOR_APP_ID: "app-id", + SALEOR_API_URL: "https://my-saleor-instance.cloud/graphql/", +}); + +const getMockAuthData = (): AuthData => ({ + saleorApiUrl: "https://my-saleor-instance.cloud/graphql/", + appId: "app-id", + token: "some-token", + jwks: "{}", + domain: "my-saleor-instance.cloud", +}); + +describe("EnvAPL", () => { + it("Constructs when values are provided in constructor", () => { + const envVars = getMockEnvVars(); + + expect( + new EnvAPL({ + env: { + token: envVars.SALEOR_APP_TOKEN, + appId: envVars.SALEOR_APP_ID, + saleorApiUrl: envVars.SALEOR_API_URL, + }, + }) + ).toBeDefined(); + }); + + it("Prints auth data from \"set\" method in stdout if printAuthDataOnRegister set to \"true\"", async () => { + const envVars = getMockEnvVars(); + + vi.spyOn(console, "log"); + + const mockAuthData = getMockAuthData(); + + await new EnvAPL({ + env: { + token: envVars.SALEOR_APP_TOKEN, + appId: envVars.SALEOR_APP_ID, + saleorApiUrl: envVars.SALEOR_API_URL, + }, + printAuthDataOnRegister: true, + }).set(mockAuthData); + + // eslint-disable-next-line no-console + return expect(console.log).toHaveBeenNthCalledWith( + 2, + /** + * Assert stringified values for formatting + */ + `{ + "saleorApiUrl": "https://my-saleor-instance.cloud/graphql/", + "appId": "app-id", + "token": "some-token", + "jwks": "{}", + "domain": "my-saleor-instance.cloud" +}` + ); + }); + + it("Returns authData from constructor in get() and getAll()", async () => { + const envVars = getMockEnvVars(); + + const apl = new EnvAPL({ + env: { + token: envVars.SALEOR_APP_TOKEN, + appId: envVars.SALEOR_APP_ID, + saleorApiUrl: envVars.SALEOR_API_URL, + }, + printAuthDataOnRegister: true, + }); + + expect(await apl.get(envVars.SALEOR_API_URL)).toEqual({ + token: envVars.SALEOR_APP_TOKEN, + appId: envVars.SALEOR_APP_ID, + saleorApiUrl: envVars.SALEOR_API_URL, + }); + + expect(await apl.getAll()).toEqual([ + { + token: envVars.SALEOR_APP_TOKEN, + appId: envVars.SALEOR_APP_ID, + saleorApiUrl: envVars.SALEOR_API_URL, + }, + ]); + }); +}); diff --git a/src/APL/env-apl.ts b/src/APL/env-apl.ts new file mode 100644 index 0000000..100de6c --- /dev/null +++ b/src/APL/env-apl.ts @@ -0,0 +1,108 @@ +import { APL, AuthData } from "./apl"; +import { createAPLDebug } from "./apl-debug"; + +const debug = createAPLDebug("EnvAPL"); + +type AuthDataRequired = Omit; + +type Options = { + env: Record; + /** + * Enable to log auth data to stdout. + * Do it once to save data in ENV and disable it later. + */ + printAuthDataOnRegister?: boolean; +}; + +export class EnvAPL implements APL { + private defaultOptions: Partial = { + printAuthDataOnRegister: false, + }; + + options: Options; + + constructor(options: Options) { + if (!this.isAuthDataValid(options.env)) { + // eslint-disable-next-line no-console + console.warn( + "EnvAPL constructor not filled with valid AuthData config. Try to install the app with \"printAuthDataOnRegister\" enabled and check console logs" + ); + } + + this.options = { + ...this.defaultOptions, + ...options, + }; + } + + private isAuthDataValid(authData: AuthData): boolean { + const keysToValidateAgainst: Array = ["appId", "saleorApiUrl", "token"]; + + return keysToValidateAgainst.every( + (key) => authData[key] && typeof authData[key] === "string" && authData[key]!.length > 0 + ); + } + + async isReady() { + return this.isAuthDataValid(this.options.env) + ? ({ + ready: true, + } as const) + : { + ready: false, + error: new Error("Auth data not valid, check constructor and pass env variables"), + }; + } + + /** + * Always return its configured, because otherwise .set() will never be called + * so env can't be printed + */ + async isConfigured() { + return { + configured: true, + } as const; + } + + async set(authData: AuthData) { + if (this.options.printAuthDataOnRegister) { + // eslint-disable-next-line no-console + console.log("Displaying registration values for the app. Use them to configure EnvAPL"); + // eslint-disable-next-line no-console + console.log(JSON.stringify(authData, null, 2)); + console.warn( + "🛑'printAuthDataOnRegister' option should be turned off once APL is configured, to avoid possible leaks" + ); + } + debug("Called set method"); + } + + async get(saleorApiUrl: string) { + if (!this.isAuthDataValid(this.options.env)) { + debug("Trying to get AuthData but APL constructor was not filled with proper AuthData"); + return undefined; + } + + if (saleorApiUrl !== this.options.env.saleorApiUrl) { + throw new Error( + `Requested AuthData for domain "${saleorApiUrl}", however APL is configured for ${this.options.env.saleorApiUrl}. You may trying to install app in invalid Saleor URL ` + ); + } + + return this.options.env; + } + + async getAll() { + if (!this.isAuthDataValid(this.options.env)) { + return []; + } + + const authData = await this.get(this.options.env.saleorApiUrl); + + return authData ? [authData] : []; + } + + async delete() { + debug("Called delete method"); + } +} diff --git a/src/APL/index.ts b/src/APL/index.ts index 5a036ee..6441263 100644 --- a/src/APL/index.ts +++ b/src/APL/index.ts @@ -1,4 +1,5 @@ export * from "./apl"; +export * from "./env-apl"; export * from "./file-apl"; export * from "./saleor-cloud-apl"; export * from "./upstash-apl"; diff --git a/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts b/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts index 2e36043..91a542f 100644 --- a/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts +++ b/src/handlers/next/saleor-webhooks/process-saleor-webhook.ts @@ -157,6 +157,10 @@ export const processSaleorWebhook: ProcessSaleorWebhook = async ({ try { debug("Will verify signature with JWKS saved in AuthData"); + if (!authData.jwks) { + throw new Error("JWKS not found in AuthData"); + } + await verifySignatureWithJwks(authData.jwks, signature, rawBody); } catch { debug("Request signature check failed. Refresh the JWKS cache and check again");