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
This commit is contained in:
Krzysztof Wolski 2023-01-11 16:55:10 +01:00 committed by GitHub
parent fcc7bbba1e
commit 62bdb80385
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 409 additions and 267 deletions

View file

@ -5,12 +5,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.2 - uses: pnpm/action-setup@v2.2.4
with: with:
version: 7.5.0 # Use 7.5.0 like other Saleor packages, to avoid pnpm version conflicts. version: 7.5.0 # Use 7.5.0 like other Saleor packages, to avoid pnpm version conflicts.
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16.x node-version: 18
cache: pnpm cache: pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
@ -21,12 +21,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.2 - uses: pnpm/action-setup@v2.2.4
with: with:
version: 7.5.0 version: 7.5.0
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16.x node-version: 18
cache: "pnpm" cache: "pnpm"
- name: Install dependencies - name: Install dependencies
run: pnpm install run: pnpm install
@ -50,12 +50,12 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: pnpm/action-setup@v2.2.2 - uses: pnpm/action-setup@v2.2.4
with: with:
version: 7.5.0 version: 7.5.0
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16.x node-version: 18
cache: pnpm cache: pnpm
- uses: actions/cache@v3 - uses: actions/cache@v3
with: with:

View file

@ -4,11 +4,11 @@ APL is an interface for managing auth data of registered Apps. Implementing it d
## Available methods ## Available methods
- `get: (domain: string) => Promise<AuthData | undefined>` - If the entry for given domain exists, returns AuthData (`{ domain: string, token: string }`) object. - `get: (apiUrl: string) => Promise<AuthData | undefined>` - If the entry for given apiUrl exists, returns AuthData object.
- `set: (authData: AuthData) => Promise<void>` - Save auth data. - `set: (authData: AuthData) => Promise<void>` - Save auth data.
- `delete: (domain: string) => Promise<void>` - Remove auth data fot the given domain. - `delete: (apiUrl: string) => Promise<void>` - Remove auth data fot the given API URL.
- `getAll: () => Promise<AuthData[]>` - Returns all auth data available. - `getAll: () => Promise<AuthData[]>` - 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<AplConfiguredResult>` - Check if persistence layer behind APL is configured. For example: env variable required by database connection - `isConfigured: () => Promise<AplConfiguredResult>` - 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://<your-saleor-domain>/.well-known/jwks.json`, cached in the APL for the faster webhook validation
## AplReadyResult & ApConfiguredResult ## AplReadyResult & ApConfiguredResult
Responses from `isReady()` and `isConfigured()` should match following: Responses from `isReady()` and `isConfigured()` should match following:
@ -52,18 +72,18 @@ const client = createClient();
await client.connect(); await client.connect();
const redisAPL: APL = { const redisAPL: APL = {
get: async (domain: string) => { get: async (apiUrl: string) => {
const token = await client.get(domain); const response = await client.get(apiUrl);
if (token) { if (response) {
return { token, domain }; return JSON.parse(response);
} }
return; return;
}, },
set: async (authData: AuthData) => { set: async (authData: AuthData) => {
await client.set(authData.domain, authData.token); await client.set(authData.apiUrl, JSON.stringify(authData));
}, },
delete: async (domain: string) => { delete: async (apiUrl: string) => {
await client.del(domain); await client.del(apiUrl);
}, },
getAll: async () => { getAll: async () => {
throw new Exception("Not implemented."); throw new Exception("Not implemented.");
@ -76,8 +96,8 @@ You'll be able to use it directly:
```ts ```ts
import { redisAPL } from "./apl"; import { redisAPL } from "./apl";
const createTestData = async () => { const getSavedAuthData = async () => {
await redisAPL.set({ domain: "example.com ", token: "test-token" }); 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: Now you can use it for in your view:
```ts ```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 { withRegisteredSaleorDomainHeader } from "@saleor/app-sdk/middleware";
import type { Handler } from "retes"; import type { Handler } from "retes";
import { toNextHandler } from "retes/adapter"; import { toNextHandler } from "retes/adapter";
@ -125,10 +145,10 @@ import { Response } from "retes/response";
import { apl } from "@lib/saleorApp"; import { apl } from "@lib/saleorApp";
const handler: Handler = async (request) => { const handler: Handler = async (request) => {
const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER]; const saleorApiUrl = request.headers[SALEOR_API_URL_HEADER];
// Get auth data // Get auth data
const authData = apl.get(saleorDomain); const authData = apl.get(saleorApiUrl);
// view logic... // view logic...
@ -184,27 +204,3 @@ const apl = new UpstashAPL({
``` ```
Or using environment variables: `UPSTASH_TOKEN`, `UPSTASH_URL`. 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

View file

@ -26,7 +26,7 @@ type AppBridgeOptions = {
## Access app state: ## Access app state:
```js ```js
const { token, domain, ready, id } = appBridge.getState(); const { token, saleorApiUrl, ready, id } = appBridge.getState();
``` ```
Available state represents `AppBridgeState`: Available state represents `AppBridgeState`:
@ -41,8 +41,8 @@ type AppBridgeState = {
theme: ThemeType; theme: ThemeType;
locale: LocaleCode; // See src/locales.ts 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; saleorApiUrl: string;
}; };
``` ```

View file

@ -15,10 +15,10 @@ export type ProtectedHandlerContext = {
`createProtectedHandler` will check if: `createProtectedHandler` will check if:
- the request has `saleor-domain` header - the request has `saleor-api-url` header of the Saleor instance
- the domain has been registered, with help of the APL - the API URL has been registered, with help of the APL
- the request has `authorization-bearer` - 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: For example purposes our endpoint will only log welcome message:
@ -43,17 +43,17 @@ export const handler = async (
export default createProtectedHandler(handler, saleorApp.apl); 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 ```typescript
fetch("/api/protected", { fetch("/api/protected", {
headers: { 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 * headers the backend will check if the request has enough permissions to
* perform the action. * perform the action.
*/ */
"saleor-domain": domain, "saleor-api-url": apiUrl,
"authorization-bearer": token, "authorization-bearer": token,
}, },
}); });

View file

@ -29,7 +29,6 @@
"fast-glob": "^3.2.11", "fast-glob": "^3.2.11",
"graphql": "^16.6.0", "graphql": "^16.6.0",
"jose": "^4.9.2", "jose": "^4.9.2",
"node-fetch": "^2.6.7",
"raw-body": "^2.5.1", "raw-body": "^2.5.1",
"retes": "^0.33.0", "retes": "^0.33.0",
"uuid": "^8.3.2" "uuid": "^8.3.2"

View file

@ -31,7 +31,6 @@ specifiers:
jsdom: ^20.0.3 jsdom: ^20.0.3
lint-staged: ^13.0.3 lint-staged: ^13.0.3
next: ^12.3.0 next: ^12.3.0
node-fetch: ^2.6.7
node-mocks-http: ^1.11.0 node-mocks-http: ^1.11.0
prettier: 2.7.1 prettier: 2.7.1
raw-body: ^2.5.1 raw-body: ^2.5.1
@ -52,7 +51,6 @@ dependencies:
fast-glob: 3.2.11 fast-glob: 3.2.11
graphql: 16.6.0 graphql: 16.6.0
jose: 4.9.2 jose: 4.9.2
node-fetch: 2.6.7
raw-body: 2.5.1 raw-body: 2.5.1
retes: 0.33.0 retes: 0.33.0
uuid: 8.3.2 uuid: 8.3.2
@ -4468,6 +4466,7 @@ packages:
optional: true optional: true
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
dev: true
/node-fetch/3.2.10: /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} 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: /tr46/0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
/tr46/1.0.1: /tr46/1.0.1:
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
@ -6118,6 +6118,7 @@ packages:
/webidl-conversions/3.0.1: /webidl-conversions/3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: true
/webidl-conversions/4.0.2: /webidl-conversions/4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
@ -6153,6 +6154,7 @@ packages:
dependencies: dependencies:
tr46: 0.0.3 tr46: 0.0.3
webidl-conversions: 3.0.1 webidl-conversions: 3.0.1
dev: true
/whatwg-url/7.1.0: /whatwg-url/7.1.0:
resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==}

View file

@ -1,6 +1,9 @@
export interface AuthData { export interface AuthData {
domain: string; domain: string;
token: string; token: string;
apiUrl: string;
appId: string;
jwks: string;
} }
export type AplReadyResult = export type AplReadyResult =
@ -22,9 +25,9 @@ export type AplConfiguredResult =
}; };
export interface APL { export interface APL {
get: (domain: string) => Promise<AuthData | undefined>; get: (apiUrl: string) => Promise<AuthData | undefined>;
set: (authData: AuthData) => Promise<void>; set: (authData: AuthData) => Promise<void>;
delete: (domain: string) => Promise<void>; delete: (apiUrl: string) => Promise<void>;
getAll: () => Promise<AuthData[]>; getAll: () => Promise<AuthData[]>;
/** /**
* Inform that configuration is finished and correct * Inform that configuration is finished and correct

View file

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

View file

@ -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);
};

View file

@ -1,11 +1,15 @@
import { promises as fsPromises } from "fs"; import { promises as fsPromises } from "fs";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { AuthData } from "./apl";
import { FileAPL } from "./file-apl"; import { FileAPL } from "./file-apl";
const stubAuthData = { const stubAuthData: AuthData = {
domain: "example.com", domain: "example.com",
token: "example-token", token: "example-token",
apiUrl: "https://example.com/graphql/",
appId: "42",
jwks: "{}",
}; };
describe("APL", () => { describe("APL", () => {
@ -19,18 +23,18 @@ describe("APL", () => {
vi.spyOn(fsPromises, "readFile").mockResolvedValue("Not a valid JSON"); vi.spyOn(fsPromises, "readFile").mockResolvedValue("Not a valid JSON");
const apl = new FileAPL(); 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)); vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData));
const apl = new FileAPL(); 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)); vi.spyOn(fsPromises, "readFile").mockResolvedValue(JSON.stringify(stubAuthData));
const apl = new FileAPL(); const apl = new FileAPL();
@ -71,7 +75,7 @@ describe("APL", () => {
const apl = new FileAPL(); const apl = new FileAPL();
await apl.delete(stubAuthData.domain); await apl.delete(stubAuthData.apiUrl);
expect(spyWriteFile).toBeCalledWith(".saleor-app-auth.json", "{}"); expect(spyWriteFile).toBeCalledWith(".saleor-app-auth.json", "{}");
}); });

View file

@ -48,11 +48,11 @@ export class FileAPL implements APL {
return undefined; 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]}***`); debug("Token and domain found, returning values: %s, %s", domain, `${token[0]}***`);
return { token, domain }; return { token, domain, apiUrl, appId, jwks };
} }
return undefined; return undefined;
@ -77,9 +77,9 @@ export class FileAPL implements APL {
} }
} }
async get(domain: string) { async get(apiUrl: string) {
const authData = await this.loadDataFromFile(); const authData = await this.loadDataFromFile();
if (domain === authData?.domain) { if (apiUrl === authData?.apiUrl) {
return authData; return authData;
} }
return undefined; return undefined;
@ -89,10 +89,10 @@ export class FileAPL implements APL {
await this.saveDataToFile(authData); await this.saveDataToFile(authData);
} }
async delete(domain: string) { async delete(apiUrl: string) {
const authData = await this.loadDataFromFile(); const authData = await this.loadDataFromFile();
if (domain === authData?.domain) { if (apiUrl === authData?.apiUrl) {
await this.saveDataToFile(); await this.saveDataToFile();
} }
} }

10
src/APL/has-auth-data.ts Normal file
View file

@ -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");

View file

@ -1,5 +1,5 @@
export * from "./apl"; export * from "./apl";
export * from "./file-apl"; export * from "./file-apl";
export * from "./rest-apl"; export * from "./saleor-cloud-apl";
export * from "./upstash-apl"; export * from "./upstash-apl";
export * from "./vercel-apl"; export * from "./vercel-apl";

View file

@ -1,43 +1,60 @@
import { hasProp } from "../has-prop";
import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl"; import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl";
import { createAPLDebug } from "./apl-debug"; 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; resourceUrl: string;
headers?: Record<string, string>; token: string;
}; };
const validateResponseStatus = (response: Response) => { const validateResponseStatus = (response: Response) => {
if (response.status < 200 || response.status >= 400) { if (!response.ok) {
debug("Response failed with status %s", response.status); debug("Response failed with status %s", response.status);
throw new Error(`Fetch returned with non 200 status code ${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 readonly resourceUrl: string;
private headers?: Record<string, string>; private headers: Record<string, string>;
constructor(config: RestAPLConfig) { constructor(config: SaleorCloudAPLConfig) {
this.resourceUrl = config.resourceUrl; this.resourceUrl = config.resourceUrl;
this.headers = config.headers; this.headers = {
Authorization: `Bearer ${config.token}`,
};
} }
private getUrlForDomain(domain: string) { private getUrlForDomain(apiUrl: string) {
return `${this.resourceUrl}/${domain}`; // API URL has to be base64 encoded
return `${this.resourceUrl}/${btoa(apiUrl)}`;
} }
async get(domain: string): Promise<AuthData | undefined> { async get(apiUrl: string): Promise<AuthData | undefined> {
debug("Will fetch data from RestAPL for domain %s", domain); 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", method: "GET",
headers: { "Content-Type": "application/json", ...this.headers }, headers: { "Content-Type": "application/json", ...this.headers },
}).catch((error) => { }).catch((error) => {
@ -51,22 +68,22 @@ export class RestAPL implements APL {
debug("Failed to parse response: %s", e?.message ?? "Unknown error"); debug("Failed to parse response: %s", e?.message ?? "Unknown error");
})) as unknown; })) as unknown;
if (hasProp(parsedResponse, "domain") && hasProp(parsedResponse, "token")) { const authData = authDataFromObject(parsedResponse);
return { domain: parsedResponse.domain as string, token: parsedResponse.token as string }; if (!authData) {
debug("No auth data for given apiUrl");
return undefined;
} }
debug("Response had no domain and token."); return authData;
return undefined;
} }
async set(authData: 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, { const response = await fetch(this.resourceUrl, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...this.headers }, headers: { "Content-Type": "application/json", ...this.headers },
body: JSON.stringify(authData), body: JSON.stringify(mapAuthDataToAPIBody(authData)),
}).catch((e) => { }).catch((e) => {
debug("Failed to reach API call: %s", e?.message ?? "Unknown error"); debug("Failed to reach API call: %s", e?.message ?? "Unknown error");
@ -80,26 +97,25 @@ export class RestAPL implements APL {
return undefined; return undefined;
} }
async delete(domain: string) { async delete(apiUrl: string) {
debug("Deleting data from Rest for domain: %s", domain); debug("Deleting data from SaleorCloud for apiUrl: %s", apiUrl);
try { try {
const response = await fetch(this.getUrlForDomain(domain), { const response = await fetch(this.getUrlForDomain(apiUrl), {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json", ...this.headers }, headers: { "Content-Type": "application/json", ...this.headers },
body: JSON.stringify({ domain }),
}); });
debug(`Delete responded with ${response.status} code`); debug(`Delete responded with ${response.status} code`);
} catch (error) { } catch (error) {
debug("Error during deleting the data: %s", 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() { async getAll() {
debug("Get all data from Rest"); debug("Get all data from SaleorCloud");
try { try {
const response = await fetch(this.resourceUrl, { const response = await fetch(this.resourceUrl, {
@ -126,15 +142,16 @@ export class RestAPL implements APL {
} }
: { : {
ready: false, ready: false,
error: new Error("App is not configured"), error: new Error("SaleorCloudAPL is not configured"),
}; };
} }
async isConfigured(): Promise<AplConfiguredResult> { async isConfigured(): Promise<AplConfiguredResult> {
if (!this.resourceUrl) { if (!this.resourceUrl) {
debug("Resource URL has not been specified.");
return { return {
configured: false, configured: false,
error: new Error("RestAPL required resourceUrl param"), error: new Error("SaleorCloudAPL required resourceUrl param"),
}; };
} }

View file

@ -1,14 +1,11 @@
import fetch from "node-fetch";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { AuthData } from "./apl"; import { AuthData } from "./apl";
import { UpstashAPL, UpstashAPLConfig, UpstashAPLVariables } from "./upstash-apl"; import { UpstashAPL, UpstashAPLConfig, UpstashAPLVariables } from "./upstash-apl";
vi.mock("node-fetch", () => ({ const fetchMock = vi.fn();
default: vi.fn().mockImplementation(() => ""),
}));
const mockFetch = vi.mocked(fetch); vi.stubGlobal("fetch", fetchMock);
const aplConfig: UpstashAPLConfig = { const aplConfig: UpstashAPLConfig = {
restToken: "token", restToken: "token",
@ -18,6 +15,9 @@ const aplConfig: UpstashAPLConfig = {
const stubAuthData: AuthData = { const stubAuthData: AuthData = {
domain: "example.com", domain: "example.com",
token: "example-token", token: "example-token",
apiUrl: "https://example.com/graphql/",
appId: "42",
jwks: "{}",
}; };
describe("APL", () => { describe("APL", () => {
@ -43,7 +43,7 @@ describe("APL", () => {
describe("set", () => { describe("set", () => {
it("Successful save of the auth data", async () => { it("Successful save of the auth data", async () => {
// @ts-ignore Ignore type of mocked response // @ts-ignore Ignore type of mocked response
mockFetch.mockResolvedValue({ fetchMock.mockResolvedValue({
status: 200, status: 200,
json: async () => ({ result: "ok" }), json: async () => ({ result: "ok" }),
}); });
@ -51,13 +51,13 @@ describe("APL", () => {
restURL: "https://example.com", restURL: "https://example.com",
restToken: "token", restToken: "token",
}); });
await apl.set({ domain: "example.com", token: "token" }); await apl.set(stubAuthData);
expect(mockFetch).toBeCalledWith( expect(fetchMock).toBeCalledWith(
"https://example.com", "https://example.com",
{ {
// eslint-disable-next-line quotes // eslint-disable-next-line quotes
body: '["SET", "example.com", "token"]', body: `["SET", "${stubAuthData.apiUrl}", "${JSON.stringify(stubAuthData)}"]`,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: "Bearer token", Authorization: "Bearer token",
@ -69,36 +69,36 @@ describe("APL", () => {
it("Raise error when register service returns non 200 response", async () => { it("Raise error when register service returns non 200 response", async () => {
// @ts-ignore Ignore type of mocked response // @ts-ignore Ignore type of mocked response
mockFetch.mockResolvedValue({ status: 500 }); fetchMock.mockResolvedValue({ status: 500 });
const apl = new UpstashAPL({ const apl = new UpstashAPL({
restURL: "https://example.com", restURL: "https://example.com",
restToken: "token", 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" "Upstash APL responded with the code 500"
); );
}); });
}); });
describe("get", () => { describe("get", () => {
describe("Read existing auth data from env", () => { describe("Read existing auth data from upstash", () => {
it("Read existing auth data", async () => { it("Read existing auth data", async () => {
// @ts-ignore Ignore type of mocked response // @ts-ignore Ignore type of mocked response
mockFetch.mockResolvedValue({ fetchMock.mockResolvedValue({
status: 200, status: 200,
json: async () => ({ json: async () => ({
result: stubAuthData.token, result: JSON.stringify(stubAuthData),
}), }),
}); });
const apl = new UpstashAPL(aplConfig); 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 () => { it("Return undefined when unknown domain requested", async () => {
// @ts-ignore Ignore type of mocked response // @ts-ignore Ignore type of mocked response
mockFetch.mockResolvedValue({ fetchMock.mockResolvedValue({
status: 200, status: 200,
json: async () => ({ json: async () => ({
result: null, result: null,

View file

@ -1,7 +1,5 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
// eslint-disable-next-line max-classes-per-file // eslint-disable-next-line max-classes-per-file
import fetch, { Response } from "node-fetch";
import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl"; import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl";
import { createAPLDebug } from "./apl-debug"; import { createAPLDebug } from "./apl-debug";
@ -87,37 +85,39 @@ export class UpstashAPL implements APL {
return parsedResponse.result; return parsedResponse.result;
} }
private async saveDataToUpstash(authData?: AuthData) { private async saveDataToUpstash(authData: AuthData) {
debug("saveDataToUpstash() called with: %j", { debug("saveDataToUpstash() called with: %j", {
domain: authData?.domain, apiUrl: authData.apiUrl,
token: authData?.token.substring(0, 4), 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) { private async deleteDataFromUpstash(apiUrl: string) {
await this.upstashRequest(`["DEL", "${domain}"]`); await this.upstashRequest(`["DEL", "${apiUrl}"]`);
} }
private async fetchDataFromUpstash(domain: string) { private async fetchDataFromUpstash(apiUrl: string) {
const result = await this.upstashRequest(`["GET", "${domain}"]`); const result = await this.upstashRequest(`["GET", "${apiUrl}"]`);
if (result) { if (result) {
return { domain, token: result }; const authData = JSON.parse(result);
return authData;
} }
return undefined; return undefined;
} }
async get(domain: string) { async get(apiUrl: string) {
return this.fetchDataFromUpstash(domain); return this.fetchDataFromUpstash(apiUrl);
} }
async set(authData: AuthData) { async set(authData: AuthData) {
await this.saveDataToUpstash(authData); await this.saveDataToUpstash(authData);
} }
async delete(domain: string) { async delete(apiUrl: string) {
await this.deleteDataFromUpstash(domain); await this.deleteDataFromUpstash(apiUrl);
} }
async getAll() { async getAll() {

View file

@ -1,22 +1,24 @@
import fetch from "node-fetch";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { AuthData } from "./apl";
import { VercelAPL, VercelAPLVariables } from "./vercel-apl"; import { VercelAPL, VercelAPLVariables } from "./vercel-apl";
vi.mock("node-fetch", () => ({ const fetchMock = vi.fn();
default: vi.fn().mockImplementation(() => ""),
}));
const mockFetch = vi.mocked(fetch); vi.stubGlobal("fetch", fetchMock);
// now you can access it as `IntersectionObserver` or `window.IntersectionObserver`
const aplConfig = { const aplConfig = {
deploymentToken: "token", deploymentToken: "token",
registerAppURL: "http://example.com", registerAppURL: "http://example.com",
}; };
const stubAuthData = { const stubAuthData: AuthData = {
domain: "example.com", domain: "example.com",
token: "example-token", token: "example-token",
apiUrl: "https://example.com/graphql/",
appId: "42",
jwks: "{}",
}; };
describe("APL", () => { describe("APL", () => {
@ -53,21 +55,23 @@ describe("APL", () => {
describe("set", () => { describe("set", () => {
it("Successful save of the auth data", async () => { it("Successful save of the auth data", async () => {
// @ts-ignore Ignore type of mocked response // @ts-ignore Ignore type of mocked response
mockFetch.mockResolvedValue({ status: 200 }); fetchMock.mockResolvedValue({ status: 200 });
const apl = new VercelAPL({ const apl = new VercelAPL({
registerAppURL: "https://registerService.example.com", registerAppURL: "https://registerService.example.com",
deploymentToken: "token", deploymentToken: "token",
}); });
await apl.set({ domain: "example.com", token: "token" }); await apl.set(stubAuthData);
expect(mockFetch).toBeCalledWith( expect(fetchMock).toBeCalledWith(
"https://registerService.example.com", "https://registerService.example.com",
{ {
body: JSON.stringify({ body: JSON.stringify({
token: "token", token: "token",
envs: [ envs: [
{ key: "SALEOR_AUTH_TOKEN", value: "token" }, {
{ key: "SALEOR_DOMAIN", value: "example.com" }, key: VercelAPLVariables.AUTH_DATA_VARIABLE_NAME,
value: JSON.stringify(stubAuthData),
},
], ],
}), }),
headers: { headers: {
@ -79,25 +83,26 @@ describe("APL", () => {
}); });
it("Successful save of the auth data during reinstallation for the same domain", async () => { 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.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData);
process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = "example.com";
// @ts-ignore Ignore type of mocked response // @ts-ignore Ignore type of mocked response
mockFetch.mockResolvedValue({ status: 200 }); fetchMock.mockResolvedValue({ status: 200 });
const apl = new VercelAPL({ const apl = new VercelAPL({
registerAppURL: "https://registerService.example.com", registerAppURL: "https://registerService.example.com",
deploymentToken: "token", deploymentToken: "token",
}); });
await apl.set({ domain: "example.com", token: "token" }); await apl.set({ ...stubAuthData, token: "new_token" });
expect(mockFetch).toBeCalledWith( expect(fetchMock).toBeCalledWith(
"https://registerService.example.com", "https://registerService.example.com",
{ {
body: JSON.stringify({ body: JSON.stringify({
token: "token", token: "token",
envs: [ 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: { headers: {
@ -109,29 +114,30 @@ describe("APL", () => {
}); });
it("Reject save of the auth data during reinstallation for a different domain", async () => { 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.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData);
process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = "not.example.com";
// @ts-ignore Ignore type of mocked response // @ts-ignore Ignore type of mocked response
mockFetch.mockResolvedValue({ status: 200 }); fetchMock.mockResolvedValue({ status: 200 });
const apl = new VercelAPL({ const apl = new VercelAPL({
registerAppURL: "https://registerService.example.com", registerAppURL: "https://registerService.example.com",
deploymentToken: "token", 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" "Vercel APL was not able to save auth data, application already registered"
); );
}); });
it("Raise error when register service returns non 200 response", async () => { it("Raise error when register service returns non 200 response", async () => {
// @ts-ignore Ignore type of mocked response // @ts-ignore Ignore type of mocked response
mockFetch.mockResolvedValue({ status: 500 }); fetchMock.mockResolvedValue({ status: 500 });
const apl = new VercelAPL({ const apl = new VercelAPL({
registerAppURL: "https://registerService.example.com/internalError", registerAppURL: "https://registerService.example.com/internalError",
deploymentToken: "token", 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" "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("get", () => {
describe("Read existing auth data from env", () => { describe("Read existing auth data from env", () => {
it("Read existing auth data", async () => { it("Read existing auth data", async () => {
process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME] = stubAuthData.token; process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData);
process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = stubAuthData.domain;
const apl = new VercelAPL(aplConfig); 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 () => { it("Return undefined when unknown api url requested", async () => {
process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME] = stubAuthData.token; process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData);
process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = stubAuthData.domain;
const apl = new VercelAPL(aplConfig); 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 () => { it("Return undefined when no data is defined", async () => {
delete process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME]; delete process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME];
delete process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME];
const apl = new VercelAPL(aplConfig); 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("getAll", () => {
describe("Read existing auth data from env", () => { describe("Read existing auth data from env", () => {
it("Read existing auth data", async () => { it("Read existing auth data", async () => {
process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME] = stubAuthData.token; process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME] = JSON.stringify(stubAuthData);
process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME] = stubAuthData.domain;
const apl = new VercelAPL(aplConfig); const apl = new VercelAPL(aplConfig);
@ -180,8 +182,7 @@ describe("APL", () => {
}); });
it("Return empty list when no auth data are existing", async () => { it("Return empty list when no auth data are existing", async () => {
delete process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME]; delete process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME];
delete process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME];
const apl = new VercelAPL(aplConfig); const apl = new VercelAPL(aplConfig);
@ -198,7 +199,7 @@ describe("APL", () => {
if (!result.ready) { if (!result.ready) {
expect(result.error.message).toEqual( 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 { } else {
throw new Error("This should not happen"); throw new Error("This should not happen");

View file

@ -1,15 +1,12 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
// eslint-disable-next-line max-classes-per-file // eslint-disable-next-line max-classes-per-file
import fetch, { Response } from "node-fetch";
import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl"; import { APL, AplConfiguredResult, AplReadyResult, AuthData } from "./apl";
import { createAPLDebug } from "./apl-debug"; import { createAPLDebug } from "./apl-debug";
const debug = createAPLDebug("VercelAPL"); const debug = createAPLDebug("VercelAPL");
export const VercelAPLVariables = { export const VercelAPLVariables = {
TOKEN_VARIABLE_NAME: "SALEOR_AUTH_TOKEN", AUTH_DATA_VARIABLE_NAME: "SALEOR_AUTH_DATA",
DOMAIN_VARIABLE_NAME: "SALEOR_DOMAIN",
SALEOR_REGISTER_APP_URL: "SALEOR_REGISTER_APP_URL", SALEOR_REGISTER_APP_URL: "SALEOR_REGISTER_APP_URL",
SALEOR_DEPLOYMENT_TOKEN: "SALEOR_DEPLOYMENT_TOKEN", SALEOR_DEPLOYMENT_TOKEN: "SALEOR_DEPLOYMENT_TOKEN",
}; };
@ -27,15 +24,11 @@ export class VercelAplNotReadyError extends Error {
export class VercelAplNotConfiguredError extends Error {} export class VercelAplNotConfiguredError extends Error {}
const getEnvAuth = (): AuthData | undefined => { const getEnvAuth = (): AuthData | undefined => {
const token = process.env[VercelAPLVariables.TOKEN_VARIABLE_NAME]; const authDataSerialized = process.env[VercelAPLVariables.AUTH_DATA_VARIABLE_NAME];
const domain = process.env[VercelAPLVariables.DOMAIN_VARIABLE_NAME]; if (!authDataSerialized) {
if (!token || !domain) {
return undefined; return undefined;
} }
return { return JSON.parse(authDataSerialized) as AuthData;
token,
domain,
};
}; };
export type VercelAPLConfig = { export type VercelAPLConfig = {
@ -95,8 +88,10 @@ export class VercelAPL implements APL {
body: JSON.stringify({ body: JSON.stringify({
token: this.deploymentToken, token: this.deploymentToken,
envs: [ 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"); debug("Register service responded successfully");
} }
async get(domain: string) { async get(apiUrl: string) {
const authData = getEnvAuth(); const authData = getEnvAuth();
if (authData && domain === authData?.domain) { if (authData && apiUrl === authData.apiUrl) {
return authData; return authData;
} }
return undefined; return undefined;
@ -131,8 +126,8 @@ export class VercelAPL implements APL {
await this.saveDataToVercel(authData); await this.saveDataToVercel(authData);
} }
async delete(domain: string) { async delete(apiUrl: string) {
if (domain === getEnvAuth()?.domain) { if (apiUrl === getEnvAuth()?.apiUrl) {
// Override existing data with the empty values // Override existing data with the empty values
await this.saveDataToVercel(); await this.saveDataToVercel();
} }

6
src/fetch-remote-jwks.ts Normal file
View file

@ -0,0 +1,6 @@
import { getJwksUrlFromSaleorApiUrl } from "./urls";
export const fetchRemoteJwks = async (saleorApiUrl: string) => {
const jwksResponse = await fetch(getJwksUrlFromSaleorApiUrl(saleorApiUrl));
return jwksResponse.text();
};

View file

@ -1,4 +1,3 @@
import { AuthData } from "./APL";
import { createDebug } from "./debug"; import { createDebug } from "./debug";
const debug = createDebug("getAppId"); const debug = createDebug("getAppId");
@ -11,13 +10,21 @@ type GetIdResponseType = {
}; };
}; };
export const getAppId = async (authData: AuthData): Promise<string | undefined> => { export interface GetAppIdProperties {
apiUrl: string;
token: string;
}
export const getAppId = async ({
apiUrl,
token,
}: GetAppIdProperties): Promise<string | undefined> => {
try { try {
const response = await fetch(`https://${authData.domain}/graphql/`, { const response = await fetch(apiUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${authData.token}`, Authorization: `Bearer ${token}`,
}, },
body: JSON.stringify({ body: JSON.stringify({
query: ` query: `

View file

@ -10,6 +10,10 @@ describe("create-app-register-handler", () => {
getAppId: vi.fn().mockResolvedValue("42"), getAppId: vi.fn().mockResolvedValue("42"),
})); }));
vi.mock("../../fetch-remote-jwks", () => ({
fetchRemoteJwks: vi.fn().mockResolvedValue("{}"),
}));
const mockApl: APL = { const mockApl: APL = {
get: vi.fn(), get: vi.fn(),
set: vi.fn(), set: vi.fn(),
@ -34,7 +38,8 @@ describe("create-app-register-handler", () => {
headers: { headers: {
host: "some-saleor-host.cloud", host: "some-saleor-host.cloud",
"x-forwarded-proto": "https", "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", method: "POST",
}); });
@ -49,8 +54,11 @@ describe("create-app-register-handler", () => {
* It fails -> params.auth_token isn't present * It fails -> params.auth_token isn't present
*/ */
expect(mockApl.set).toHaveBeenCalledWith({ 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", token: "mock-auth-token",
appId: "42",
jwks: "{}",
}); });
}); });
}); });

View file

@ -3,8 +3,9 @@ import { toNextHandler } from "retes/adapter";
import { withMethod } from "retes/middleware"; import { withMethod } from "retes/middleware";
import { Response } from "retes/response"; 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 { createDebug } from "../../debug";
import { fetchRemoteJwks } from "../../fetch-remote-jwks";
import { getAppId } from "../../get-app-id"; import { getAppId } from "../../get-app-id";
import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware"; import { withAuthTokenRequired, withSaleorDomainPresent } from "../../middleware";
import { HasAPL } from "../../saleor-app"; import { HasAPL } from "../../saleor-app";
@ -23,6 +24,7 @@ export const createAppRegisterHandler = ({ apl }: CreateAppRegisterHandlerOption
debug("Request received"); debug("Request received");
const authToken = request.params.auth_token; const authToken = request.params.auth_token;
const saleorDomain = request.headers[SALEOR_DOMAIN_HEADER] as string; 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(); 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 // 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) { if (!appId) {
return new Response( 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 { try {
await apl.set({ domain: saleorDomain, token: authToken }); await apl.set({ domain: saleorDomain, token: authToken, apiUrl: saleorApiUrl, appId, jwks });
} catch { } catch {
debug("There was an error during saving the auth data"); debug("There was an error during saving the auth data");
return Response.InternalServerError({ return Response.InternalServerError({

View file

@ -15,6 +15,7 @@ export const ProtectedHandlerErrorCodeMap: Record<SaleorProtectedHandlerError, n
OTHER: 500, OTHER: 500,
MISSING_HOST_HEADER: 400, MISSING_HOST_HEADER: 400,
MISSING_DOMAIN_HEADER: 400, MISSING_DOMAIN_HEADER: 400,
MISSING_API_URL_HEADER: 400,
NOT_REGISTERED: 401, NOT_REGISTERED: 401,
JWT_VERIFICATION_FAILED: 401, JWT_VERIFICATION_FAILED: 401,
NO_APP_ID: 401, NO_APP_ID: 401,

View file

@ -18,6 +18,12 @@ vi.mock("./../../verify-signature", () => ({
throw new Error("Wrong signature"); 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", () => ({ vi.mock("raw-body", () => ({
@ -27,11 +33,14 @@ describe("processAsyncSaleorWebhook", () => {
let mockRequest: NextApiRequest; let mockRequest: NextApiRequest;
const mockAPL: APL = { const mockAPL: APL = {
get: async (domain: string) => get: async (apiUrl: string) =>
domain === "example.com" apiUrl === "https://example.com/graphql/"
? { ? {
domain: "example.com", domain: "example.com",
token: "mock-token", token: "mock-token",
apiUrl: "https://example.com/graphql/",
appId: "42",
jwks: "{}",
} }
: undefined, : undefined,
set: vi.fn(), set: vi.fn(),
@ -75,12 +84,12 @@ describe("processAsyncSaleorWebhook", () => {
).rejects.toThrow("Wrong request method"); ).rejects.toThrow("Wrong request method");
}); });
it("Throw error on missing domain header", async () => { it("Throw error on missing api url header", async () => {
delete mockRequest.headers["saleor-domain"]; delete mockRequest.headers["saleor-api-url"];
await expect( await expect(
processAsyncSaleorWebhook({ req: mockRequest, apl: mockAPL, allowedEvent: "PRODUCT_UPDATED" }) 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 () => { it("Throw error on missing event header", async () => {
@ -128,7 +137,7 @@ describe("processAsyncSaleorWebhook", () => {
}); });
it("Throw error on not registered app", async () => { 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( await expect(
processAsyncSaleorWebhook({ processAsyncSaleorWebhook({
req: mockRequest, req: mockRequest,
@ -136,7 +145,7 @@ describe("processAsyncSaleorWebhook", () => {
allowedEvent: "PRODUCT_UPDATED", allowedEvent: "PRODUCT_UPDATED",
}) })
).rejects.toThrow( ).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"
); );
}); });

View file

@ -4,8 +4,9 @@ import getRawBody from "raw-body";
import { APL } from "../../APL"; import { APL } from "../../APL";
import { AuthData } from "../../APL/apl"; import { AuthData } from "../../APL/apl";
import { createDebug } from "../../debug"; import { createDebug } from "../../debug";
import { fetchRemoteJwks } from "../../fetch-remote-jwks";
import { getBaseUrl, getSaleorHeaders } from "../../headers"; import { getBaseUrl, getSaleorHeaders } from "../../headers";
import { verifySignature, verifySignatureFromApiUrl } from "../../verify-signature"; import { verifySignatureWithJwks } from "../../verify-signature";
const debug = createDebug("processAsyncWebhook"); const debug = createDebug("processAsyncWebhook");
@ -13,6 +14,7 @@ export type SaleorWebhookError =
| "OTHER" | "OTHER"
| "MISSING_HOST_HEADER" | "MISSING_HOST_HEADER"
| "MISSING_DOMAIN_HEADER" | "MISSING_DOMAIN_HEADER"
| "MISSING_API_URL_HEADER"
| "MISSING_EVENT_HEADER" | "MISSING_EVENT_HEADER"
| "MISSING_PAYLOAD_HEADER" | "MISSING_PAYLOAD_HEADER"
| "MISSING_SIGNATURE_HEADER" | "MISSING_SIGNATURE_HEADER"
@ -74,7 +76,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, saleorApiUrl } = getSaleorHeaders(req.headers); const { event, signature, saleorApiUrl } = getSaleorHeaders(req.headers);
const baseUrl = getBaseUrl(req.headers); const baseUrl = getBaseUrl(req.headers);
if (!baseUrl) { if (!baseUrl) {
@ -82,9 +84,9 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
throw new WebhookError("Missing host header", "MISSING_HOST_HEADER"); throw new WebhookError("Missing host header", "MISSING_HOST_HEADER");
} }
if (!domain) { if (!saleorApiUrl) {
debug("Missing saleor-domain header"); debug("Missing saleor-api-url header");
throw new WebhookError("Missing saleor-domain header", "MISSING_DOMAIN_HEADER"); throw new WebhookError("Missing saleor-api-url header", "MISSING_API_URL_HEADER");
} }
if (!event) { if (!event) {
@ -126,30 +128,31 @@ export const processAsyncSaleorWebhook: ProcessAsyncSaleorWebhook = async <T>({
} }
// Check if domain is installed in the app // Check if domain is installed in the app
const authData = await apl.get(domain); const authData = await apl.get(saleorApiUrl);
if (!authData) { 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( 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" "NOT_REGISTERED"
); );
} }
// Payload signature check // Payload signature check
// TODO: Since it require additional request, can we cache it's response?
try { try {
/** await verifySignatureWithJwks(authData.jwks, 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. Refresh the JWKS cache and check again");
throw new WebhookError("Request signature check failed", "SIGNATURE_VERIFICATION_FAILED"); 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 { return {

View file

@ -26,11 +26,14 @@ describe("processSaleorProtectedHandler", () => {
let mockRequest: NextApiRequest; let mockRequest: NextApiRequest;
const mockAPL: APL = { const mockAPL: APL = {
get: async (domain: string) => get: async (apiUrl: string) =>
domain === "example.com" apiUrl === "https://example.com/graphql/"
? { ? {
domain: "example.com", domain: "example.com",
token: "mock-token", token: "mock-token",
apiUrl: "https://example.com/graphql/",
appId: "42",
jwks: "{}",
} }
: undefined, : undefined,
set: vi.fn(), set: vi.fn(),
@ -47,6 +50,7 @@ describe("processSaleorProtectedHandler", () => {
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",
"authorization-bearer": validToken, "authorization-bearer": validToken,
@ -68,27 +72,22 @@ describe("processSaleorProtectedHandler", () => {
authData: { authData: {
domain: "example.com", domain: "example.com",
token: "mock-token", token: "mock-token",
apiUrl: "https://example.com/graphql/",
appId: "42",
jwks: "{}",
}, },
baseUrl: "https://some-saleor-host.cloud", baseUrl: "https://some-saleor-host.cloud",
}); });
}); });
it("Throw error when app ID can't be fetched", async () => { it("Throw error when api url header is missing", 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 () => {
vi.mocked(getAppId).mockResolvedValue(validAppId); vi.mocked(getAppId).mockResolvedValue(validAppId);
vi.mocked(verifyJWT).mockResolvedValue(); vi.mocked(verifyJWT).mockResolvedValue();
delete mockRequest.headers["saleor-domain"]; delete mockRequest.headers["saleor-api-url"];
await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( 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(getAppId).mockResolvedValue(validAppId);
vi.mocked(verifyJWT).mockResolvedValue(); 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( 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"
); );
}); });

View file

@ -3,7 +3,6 @@ import { NextApiRequest } from "next";
import { APL } from "../../APL"; import { APL } from "../../APL";
import { AuthData } from "../../APL/apl"; import { AuthData } from "../../APL/apl";
import { createDebug } from "../../debug"; import { createDebug } from "../../debug";
import { getAppId } from "../../get-app-id";
import { getBaseUrl, getSaleorHeaders } from "../../headers"; import { getBaseUrl, getSaleorHeaders } from "../../headers";
import { verifyJWT } from "../../verify-jwt"; import { verifyJWT } from "../../verify-jwt";
@ -13,6 +12,7 @@ export type SaleorProtectedHandlerError =
| "OTHER" | "OTHER"
| "MISSING_HOST_HEADER" | "MISSING_HOST_HEADER"
| "MISSING_DOMAIN_HEADER" | "MISSING_DOMAIN_HEADER"
| "MISSING_API_URL_HEADER"
| "MISSING_AUTHORIZATION_BEARER_HEADER" | "MISSING_AUTHORIZATION_BEARER_HEADER"
| "NOT_REGISTERED" | "NOT_REGISTERED"
| "JWT_VERIFICATION_FAILED" | "JWT_VERIFICATION_FAILED"
@ -53,7 +53,7 @@ export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler =
apl, apl,
}: ProcessSaleorProtectedHandlerArgs): Promise<ProtectedHandlerContext> => { }: ProcessSaleorProtectedHandlerArgs): Promise<ProtectedHandlerContext> => {
debug("Request processing started"); debug("Request processing started");
const { domain, authorizationBearer: token } = getSaleorHeaders(req.headers); const { saleorApiUrl, authorizationBearer: token } = getSaleorHeaders(req.headers);
const baseUrl = getBaseUrl(req.headers); const baseUrl = getBaseUrl(req.headers);
if (!baseUrl) { if (!baseUrl) {
@ -61,9 +61,9 @@ export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler =
throw new ProtectedHandlerError("Missing host header", "MISSING_HOST_HEADER"); throw new ProtectedHandlerError("Missing host header", "MISSING_HOST_HEADER");
} }
if (!domain) { if (!saleorApiUrl) {
debug("Missing saleor-domain header"); debug("Missing saleor-api-url header");
throw new ProtectedHandlerError("Missing saleor-domain header", "MISSING_DOMAIN_HEADER"); throw new ProtectedHandlerError("Missing saleor-api-url header", "MISSING_API_URL_HEADER");
} }
if (!token) { if (!token) {
@ -74,27 +74,18 @@ export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler =
); );
} }
// Check if domain has been registered in the APL // Check if API URL has been registered in the APL
const authData = await apl.get(domain); const authData = await apl.get(saleorApiUrl);
if (!authData) { 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( 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" "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 { try {
await verifyJWT({ appId, token, domain }); await verifyJWT({ appId: authData.appId, token, apiUrl: saleorApiUrl });
} catch (e) { } catch (e) {
throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED");
} }

View file

@ -38,6 +38,7 @@ export const AsyncWebhookErrorCodeMap: Record<SaleorWebhookError, number> = {
OTHER: 500, OTHER: 500,
MISSING_HOST_HEADER: 400, MISSING_HOST_HEADER: 400,
MISSING_DOMAIN_HEADER: 400, MISSING_DOMAIN_HEADER: 400,
MISSING_API_URL_HEADER: 400,
MISSING_EVENT_HEADER: 400, MISSING_EVENT_HEADER: 400,
MISSING_PAYLOAD_HEADER: 400, MISSING_PAYLOAD_HEADER: 400,
MISSING_SIGNATURE_HEADER: 400, MISSING_SIGNATURE_HEADER: 400,
@ -96,7 +97,7 @@ export class SaleorAsyncWebhook<TPayload = unknown> {
/** /**
* Returns full URL to the webhook, based on provided baseUrl. * 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 * @param baseUrl Base URL used by your application
*/ */

View file

@ -5,7 +5,7 @@ import { verifyJWT } from "./verify-jwt";
const validToken = const validToken =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q";
const validDomain = "demo.eu.saleor.cloud"; const validApiUrl = "https://demo.eu.saleor.cloud/graphql/";
const validAppId = "QXBwOjI3NQ=="; const validAppId = "QXBwOjI3NQ==";
@ -27,18 +27,18 @@ describe("verifyJWT", () => {
}); });
it("Process valid request", async () => { 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 () => { it("Throw error on decode issue", async () => {
await expect( 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."); ).rejects.toThrow("JWT verification failed: Could not decode authorization token.");
}); });
it("Throw error on app ID missmatch", async () => { it("Throw error on app ID missmatch", async () => {
await expect( 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."); ).rejects.toThrow("JWT verification failed: Token's app property is different than app ID.");
}); });
}); });

View file

@ -1,7 +1,7 @@
import * as jose from "jose"; import * as jose from "jose";
import { createDebug } from "./debug"; import { createDebug } from "./debug";
import { getJwksUrl } from "./urls"; import { getJwksUrlFromSaleorApiUrl } from "./urls";
const debug = createDebug("verify-jwt"); const debug = createDebug("verify-jwt");
@ -11,11 +11,11 @@ export interface DashboardTokenPayload extends jose.JWTPayload {
export interface verifyJWTArguments { export interface verifyJWTArguments {
appId: string; appId: string;
domain: string; apiUrl: string;
token: string; token: string;
} }
export const verifyJWT = async ({ domain, token, appId }: verifyJWTArguments) => { export const verifyJWT = async ({ apiUrl, token, appId }: verifyJWTArguments) => {
let tokenClaims: DashboardTokenPayload; let tokenClaims: DashboardTokenPayload;
const ERROR_MESSAGE = "JWT verification failed:"; const ERROR_MESSAGE = "JWT verification failed:";
@ -38,7 +38,7 @@ export const verifyJWT = async ({ domain, token, appId }: verifyJWTArguments) =>
try { try {
debug("Trying to create JWKS"); 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"); debug("Trying to compare JWKS with token");
await jose.jwtVerify(token, JWKS); await jose.jwtVerify(token, JWKS);
} catch (e) { } catch (e) {

View file

@ -62,3 +62,32 @@ export const verifySignatureFromApiUrl = async (
throw new Error("JWKS verification failed"); 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");
}
};