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:
parent
fcc7bbba1e
commit
62bdb80385
31 changed files with 409 additions and 267 deletions
12
.github/workflows/main.yml
vendored
12
.github/workflows/main.yml
vendored
|
@ -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:
|
||||||
|
|
72
docs/apl.md
72
docs/apl.md
|
@ -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
|
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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==}
|
||||||
|
|
|
@ -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
|
||||||
|
|
23
src/APL/auth-data-from-object.ts
Normal file
23
src/APL/auth-data-from-object.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
19
src/APL/auth-data-from-string.ts
Normal file
19
src/APL/auth-data-from-string.ts
Normal 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);
|
||||||
|
};
|
|
@ -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", "{}");
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
10
src/APL/has-auth-data.ts
Normal 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");
|
|
@ -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";
|
||||||
|
|
|
@ -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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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");
|
||||||
|
|
|
@ -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
6
src/fetch-remote-jwks.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import { getJwksUrlFromSaleorApiUrl } from "./urls";
|
||||||
|
|
||||||
|
export const fetchRemoteJwks = async (saleorApiUrl: string) => {
|
||||||
|
const jwksResponse = await fetch(getJwksUrlFromSaleorApiUrl(saleorApiUrl));
|
||||||
|
return jwksResponse.text();
|
||||||
|
};
|
|
@ -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: `
|
||||||
|
|
|
@ -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: "{}",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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.");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
Loading…
Reference in a new issue