diff --git a/.eslintrc b/.eslintrc index 8541367..aa6d44a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,7 +12,7 @@ "ignorePatterns": ["pnpm-lock.yaml"], "plugins": ["simple-import-sort", "@typescript-eslint"], "rules": { - "import/no-extraneous-dependencies": ["error", {"devDependencies": true}], + "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], "quotes": ["error", "double"], "react/react-in-jsx-scope": "off", // next does not require react imports "import/extensions": "off", // file extension not required when importing diff --git a/docs/apl.md b/docs/apl.md new file mode 100644 index 0000000..f6acb68 --- /dev/null +++ b/docs/apl.md @@ -0,0 +1,68 @@ +# APL - Auth Persistance Layer + +APL is an interface for managing auth data of registered Apps. Implementing it does not restrict you from choosing the right technology for your project (for example Redis, Postgres, S3, etc.), and provides a common set of operations which can be used by your application and functions provided by App SDK. + +## Available methods + +- `get: (domain: string) => Promise` - If the entry for given domain exists, returns AuthData (`{ domain: string, token: string }`) object. + +- `set: (authData: AuthData) => Promise` - Save auth data. + +- `delete: (domain: string) => Promise` - Remove auth data fot the given domain. + +- `getAll: () => Promise` - Returns all auth data available. + +## Example implementation + +Let's create an APL, which uses redis for data storage: + +```ts +import { createClient } from "redis"; +import { APL, AuthData } from "@saleor/app-sdk/types/apl"; + +const client = createClient(); +await client.connect(); + +const redisAPL: APL = { + get: async (domain: string) => { + const token = await client.get(domain); + if (token) { + return { token, domain }; + } + return; + }, + set: async (authData: AuthData) => { + await client.set(authData.domain, authData.token); + }, + delete: async (domain: string) => { + await client.del(domain); + }, + getAll: async () => { + throw new Exception("Not implemented."); + }, +}; +``` + +You'll be able to use it directly: + +```ts +import { redisAPL } from "./apl"; + +const createTestData = async () => { + await redisAPL.set({ domain: "example.com ", token: "test-token" }); +}; +``` + +And with middleware from the SDK: + +```ts +import { withRegisteredSaleorDomainHeader } from "@saleor/app-sdk/middleware"; +import { redisAPL } from "./apl"; + +const handler = async (request) => { + return Response.OK({ message: "If you see this, your app is registered!" }); +}; + +// the middleware will reject request if it's domain has not been registered +export default withRegisteredSaleorDomainHeader({ apl: redisAPL })(handler); +``` diff --git a/package.json b/package.json index 54c8980..387bbbf 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,11 @@ "import": "./const.mjs", "require": "./const.js" }, + "./apl": { + "types": "./apl.d.ts", + "import": "./apl.mjs", + "require": "./apl.js" + }, "./middleware": { "types": "./middleware.d.ts", "import": "./middleware.mjs", diff --git a/src/APL/apl.ts b/src/APL/apl.ts new file mode 100644 index 0000000..a761f3a --- /dev/null +++ b/src/APL/apl.ts @@ -0,0 +1,11 @@ +export interface AuthData { + domain: string; + token: string; +} + +export interface APL { + get: (domain: string) => Promise; + set: (authData: AuthData) => Promise; + delete: (domain: string) => Promise; + getAll: () => Promise; +} diff --git a/src/APL/index.ts b/src/APL/index.ts new file mode 100644 index 0000000..3831bbc --- /dev/null +++ b/src/APL/index.ts @@ -0,0 +1 @@ +export * from "./apl"; diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 4a4255f..af4a2b2 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,17 +1,19 @@ import { Handler, Request } from "retes"; -import { Response } from "retes/types"; +import { Response } from "retes/response"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { withBaseURL } from "./middleware"; +import { APL } from "./APL"; +import { SALEOR_DOMAIN_HEADER } from "./const"; +import { withBaseURL, withRegisteredSaleorDomainHeader } from "./middleware"; -const getMockEmptyResponse = async () => ({} as Response); +const getMockSuccessResponse = async () => Response.OK({}); describe("middleware.test.ts", () => { describe("withBaseURL", () => { - let mockHandlerFn: Handler = vi.fn(getMockEmptyResponse); + let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); beforeEach(() => { - mockHandlerFn = vi.fn(); + mockHandlerFn = vi.fn(getMockSuccessResponse); }); it("Adds base URL from request header to context and calls handler", async () => { @@ -29,4 +31,60 @@ describe("middleware.test.ts", () => { expect(mockHandlerFn).toHaveBeenCalledOnce(); }); }); + + describe("withRegisteredSaleorDomainHeader", () => { + let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); + + const mockAPL: APL = { + get: async (domain: string) => + domain === "example.com" + ? { + domain: "example.com", + token: "mock-token", + } + : undefined, + set: vi.fn(), + delete: vi.fn(), + getAll: vi.fn(), + }; + + beforeEach(() => { + mockHandlerFn = vi.fn(getMockSuccessResponse); + }); + + it("Pass request when auth data are available", async () => { + const mockRequest = { + context: {}, + headers: { + host: "my-saleor-env.saleor.cloud", + "x-forwarded-proto": "https", + [SALEOR_DOMAIN_HEADER]: "example.com", + }, + } as unknown as Request; + + const response = await withRegisteredSaleorDomainHeader({ apl: mockAPL })(mockHandlerFn)( + mockRequest + ); + + expect(response.status).toBe(200); + expect(mockHandlerFn).toHaveBeenCalledOnce(); + }); + + it("Reject request when auth data are not available", async () => { + const mockRequest = { + context: {}, + headers: { + host: "my-saleor-env.saleor.cloud", + "x-forwarded-proto": "https", + [SALEOR_DOMAIN_HEADER]: "not-registered.example.com", + }, + } as unknown as Request; + + const response = await withRegisteredSaleorDomainHeader({ apl: mockAPL })(mockHandlerFn)( + mockRequest + ); + expect(response.status).eq(403); + expect(mockHandlerFn).toBeCalledTimes(0); + }); + }); }); diff --git a/src/middleware.ts b/src/middleware.ts index e31e7c4..a9844c1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,6 +3,7 @@ import * as jose from "jose"; import type { Middleware, Request } from "retes"; import { Response } from "retes/response"; +import { APL } from "./APL"; import { SALEOR_AUTHORIZATION_BEARER_HEADER, SALEOR_SIGNATURE_HEADER } from "./const"; import { getSaleorHeaders } from "./headers"; import { getJwksUrl } from "./urls"; @@ -187,3 +188,19 @@ export const withJWTVerified = return handler(request); }; + +export const withRegisteredSaleorDomainHeader = + ({ apl }: { apl: APL }): Middleware => + (handler) => + async (request) => { + const { domain: saleorDomain } = getSaleorHeaders(request.headers); + const authData = await apl.get(saleorDomain); + if (!authData) { + return Response.Forbidden({ + success: false, + message: `Domain ${saleorDomain} not registered.`, + }); + } + + return handler(request); + };