From eca691164f869e4ca50c449f6ca8d5b53658b264 Mon Sep 17 00:00:00 2001 From: Jonatan Witoszek Date: Fri, 8 Mar 2024 12:54:27 +0100 Subject: [PATCH] Add middlewares for FetchAPI, add Edge register handler --- package.json | 5 + src/fetch-middleware/index.ts | 6 + src/fetch-middleware/middleware-debug.ts | 4 + src/fetch-middleware/to-next-edge-handler.ts | 20 ++ src/fetch-middleware/types.ts | 5 + .../with-auth-token-required.ts | 32 ++ src/fetch-middleware/with-method.ts | 25 ++ .../with-registered-saleor-domain-header.ts | 51 +++ src/fetch-middleware/with-saleor-app.ts | 20 ++ .../with-saleor-domain-present.ts | 25 ++ .../next-edge/create-app-register-handler.ts | 301 ++++++++++++++++++ src/handlers/next-edge/index.ts | 1 + tsup.config.ts | 1 + 13 files changed, 496 insertions(+) create mode 100644 src/fetch-middleware/index.ts create mode 100644 src/fetch-middleware/middleware-debug.ts create mode 100644 src/fetch-middleware/to-next-edge-handler.ts create mode 100644 src/fetch-middleware/types.ts create mode 100644 src/fetch-middleware/with-auth-token-required.ts create mode 100644 src/fetch-middleware/with-method.ts create mode 100644 src/fetch-middleware/with-registered-saleor-domain-header.ts create mode 100644 src/fetch-middleware/with-saleor-app.ts create mode 100644 src/fetch-middleware/with-saleor-domain-present.ts create mode 100644 src/handlers/next-edge/create-app-register-handler.ts diff --git a/package.json b/package.json index d277f17a..12ffa985 100644 --- a/package.json +++ b/package.json @@ -108,6 +108,11 @@ "import": "./settings-manager/index.mjs", "require": "./settings-manager/index.js" }, + "./fetch-middleware": { + "types": "./fetch-middleware/index.d.ts", + "import": "./fetch-middleware/index.mjs", + "require": "./fetch-middleware/index.js" + }, "./middleware": { "types": "./middleware/index.d.ts", "import": "./middleware/index.mjs", diff --git a/src/fetch-middleware/index.ts b/src/fetch-middleware/index.ts new file mode 100644 index 00000000..b0e4eef1 --- /dev/null +++ b/src/fetch-middleware/index.ts @@ -0,0 +1,6 @@ +export * from "./to-next-edge-handler"; +export * from "./with-auth-token-required"; +export * from "./with-method"; +export * from "./with-registered-saleor-domain-header"; +export * from "./with-saleor-app"; +export * from "./with-saleor-domain-present"; diff --git a/src/fetch-middleware/middleware-debug.ts b/src/fetch-middleware/middleware-debug.ts new file mode 100644 index 00000000..8ae78f70 --- /dev/null +++ b/src/fetch-middleware/middleware-debug.ts @@ -0,0 +1,4 @@ +import { createDebug } from "../debug"; + +export const createFetchMiddlewareDebug = (middleware: string) => + createDebug(`FetchMiddleware:${middleware}`); diff --git a/src/fetch-middleware/to-next-edge-handler.ts b/src/fetch-middleware/to-next-edge-handler.ts new file mode 100644 index 00000000..df35a3d6 --- /dev/null +++ b/src/fetch-middleware/to-next-edge-handler.ts @@ -0,0 +1,20 @@ +import { FetchHandler, FetchPipeline, ReveredFetchPipeline } from "./types"; + +const isPipeline = (maybePipeline: unknown): maybePipeline is FetchPipeline => + Array.isArray(maybePipeline); + +const compose = + (...functions: T[]) => + (args: any) => + functions.reduce((arg, fn) => fn(arg), args); + +const preparePipeline = (pipeline: FetchPipeline): FetchHandler => { + const [action, ...middleware] = pipeline.reverse() as ReveredFetchPipeline; + return compose(...middleware)(action); +}; + +export const toNextEdgeHandler = (flow: FetchHandler | FetchPipeline): FetchHandler => { + const handler = isPipeline(flow) ? preparePipeline(flow) : flow; + + return async (request: Request) => handler(request); +}; diff --git a/src/fetch-middleware/types.ts b/src/fetch-middleware/types.ts new file mode 100644 index 00000000..0cdca82d --- /dev/null +++ b/src/fetch-middleware/types.ts @@ -0,0 +1,5 @@ +export type SaleorRequest = Request & { context?: Record }; +export type FetchHandler = (req: SaleorRequest) => Response | Promise; +export type FetchMiddleware = (handler: FetchHandler) => FetchHandler; +export type FetchPipeline = [...FetchMiddleware[], FetchHandler]; +export type ReveredFetchPipeline = [FetchHandler, ...FetchMiddleware[]]; diff --git a/src/fetch-middleware/with-auth-token-required.ts b/src/fetch-middleware/with-auth-token-required.ts new file mode 100644 index 00000000..29327b48 --- /dev/null +++ b/src/fetch-middleware/with-auth-token-required.ts @@ -0,0 +1,32 @@ +import { createFetchMiddlewareDebug } from "./middleware-debug"; +import { FetchMiddleware } from "./types"; + +const debug = createFetchMiddlewareDebug("withAuthTokenRequired"); + +export const withAuthTokenRequired: FetchMiddleware = (handler) => async (request) => { + debug("Middleware called"); + + try { + // If we read `request.json()` without cloning it will throw an error + // next time we run request.json() + const clone = request.clone(); + const json = await clone.json(); + const authToken = json.auth_token; + + if (!authToken) { + debug("Found missing authToken param"); + + return Response.json( + { + success: false, + message: "Missing auth token.", + }, + { status: 400 } + ); + } + } catch { + return Response.json({ success: false, message: "Invalid request body" }, { status: 400 }); + } + + return handler(request); +}; diff --git a/src/fetch-middleware/with-method.ts b/src/fetch-middleware/with-method.ts new file mode 100644 index 00000000..73becfe2 --- /dev/null +++ b/src/fetch-middleware/with-method.ts @@ -0,0 +1,25 @@ +import { FetchMiddleware } from "./types"; + +export const HTTPMethod = { + GET: "GET", + POST: "POST", + PUT: "PUT", + PATH: "PATCH", + HEAD: "HEAD", + OPTIONS: "OPTIONS", + DELETE: "DELETE", +} as const; +export type HTTPMethod = typeof HTTPMethod[keyof typeof HTTPMethod]; + +export const withMethod = + (...methods: HTTPMethod[]): FetchMiddleware => + (handler) => + async (request) => { + if (!methods.includes(request.method as HTTPMethod)) { + return new Response("Method not allowed", { status: 405 }); + } + + const response = await handler(request); + + return response; + }; diff --git a/src/fetch-middleware/with-registered-saleor-domain-header.ts b/src/fetch-middleware/with-registered-saleor-domain-header.ts new file mode 100644 index 00000000..6e3edb12 --- /dev/null +++ b/src/fetch-middleware/with-registered-saleor-domain-header.ts @@ -0,0 +1,51 @@ +import { getSaleorHeadersFetchAPI } from "../headers"; +import { createFetchMiddlewareDebug } from "./middleware-debug"; +import { FetchMiddleware } from "./types"; +import { getSaleorAppFromRequest } from "./with-saleor-app"; + +const debug = createFetchMiddlewareDebug("withRegisteredSaleorDomainHeader"); + +export const withRegisteredSaleorDomainHeader: FetchMiddleware = (handler) => async (request) => { + const { saleorApiUrl } = getSaleorHeadersFetchAPI(request.headers); + + if (!saleorApiUrl) { + return Response.json( + { success: false, message: "saleorApiUrl header missing" }, + { status: 400 } + ); + } + + debug("Middleware called with saleorApiUrl: \"%s\"", saleorApiUrl); + + const saleorApp = getSaleorAppFromRequest(request); + + if (!saleorApp) { + console.error( + "SaleorApp not found in request context. Ensure your API handler is wrapped with withSaleorApp middleware" + ); + + return Response.json( + { + success: false, + message: "SaleorApp is misconfigured", + }, + { status: 500 } + ); + } + + const authData = await saleorApp?.apl.get(saleorApiUrl); + + if (!authData) { + debug("Auth was not found in APL, will respond with Forbidden status"); + + return Response.json( + { + success: false, + message: `Saleor: ${saleorApiUrl} not registered.`, + }, + { status: 403 } + ); + } + + return handler(request); +}; diff --git a/src/fetch-middleware/with-saleor-app.ts b/src/fetch-middleware/with-saleor-app.ts new file mode 100644 index 00000000..b95a754f --- /dev/null +++ b/src/fetch-middleware/with-saleor-app.ts @@ -0,0 +1,20 @@ +import { SaleorApp } from "../saleor-app"; +import { createFetchMiddlewareDebug } from "./middleware-debug"; +import { FetchMiddleware, SaleorRequest } from "./types"; + +const debug = createFetchMiddlewareDebug("withSaleorApp"); + +export const withSaleorApp = + (saleorApp: SaleorApp): FetchMiddleware => + (handler) => + async (request: SaleorRequest) => { + debug("Middleware called"); + + request.context ??= {}; + request.context.saleorApp = saleorApp; + + return handler(request); + }; + +export const getSaleorAppFromRequest = (request: SaleorRequest): SaleorApp | undefined => + request.context?.saleorApp; diff --git a/src/fetch-middleware/with-saleor-domain-present.ts b/src/fetch-middleware/with-saleor-domain-present.ts new file mode 100644 index 00000000..9955aa38 --- /dev/null +++ b/src/fetch-middleware/with-saleor-domain-present.ts @@ -0,0 +1,25 @@ +import { getSaleorHeadersFetchAPI } from "../headers"; +import { createFetchMiddlewareDebug } from "./middleware-debug"; +import { FetchMiddleware } from "./types"; + +const debug = createFetchMiddlewareDebug("withSaleorDomainPresent"); + +export const withSaleorDomainPresent: FetchMiddleware = (handler) => async (request) => { + const { domain } = getSaleorHeadersFetchAPI(request.headers); + + debug("Middleware called with domain in header: %s", domain); + + if (!domain) { + debug("Domain not found in header, will respond with Bad Request"); + + return Response.json( + { + success: false, + message: "Missing Saleor domain header.", + }, + { status: 400 } + ); + } + + return handler(request); +}; diff --git a/src/handlers/next-edge/create-app-register-handler.ts b/src/handlers/next-edge/create-app-register-handler.ts new file mode 100644 index 00000000..65d130eb --- /dev/null +++ b/src/handlers/next-edge/create-app-register-handler.ts @@ -0,0 +1,301 @@ +import { AuthData } from "../../APL"; +import { SALEOR_API_URL_HEADER, SALEOR_DOMAIN_HEADER } from "../../const"; +import { createDebug } from "../../debug"; +import { toNextEdgeHandler } from "../../fetch-middleware/to-next-edge-handler"; +import { FetchHandler } from "../../fetch-middleware/types"; +import { withAuthTokenRequired } from "../../fetch-middleware/with-auth-token-required"; +import { withMethod } from "../../fetch-middleware/with-method"; +import { withSaleorDomainPresent } from "../../fetch-middleware/with-saleor-domain-present"; +import { fetchRemoteJwks } from "../../fetch-remote-jwks"; +import { getAppId } from "../../get-app-id"; +import { HasAPL } from "../../saleor-app"; +import { validateAllowSaleorUrls } from "../next/validate-allow-saleor-urls"; + +const debug = createDebug("createAppRegisterHandler"); + +type HookCallbackErrorParams = { + status?: number; + message?: string; +}; + +class RegisterCallbackError extends Error { + public status = 500; + + constructor(errorParams: HookCallbackErrorParams) { + super(errorParams.message); + + if (errorParams.status) { + this.status = errorParams.status; + } + } +} + +const createCallbackError = (params: HookCallbackErrorParams) => { + throw new RegisterCallbackError(params); +}; + +export type RegisterHandlerResponseBody = { + success: boolean; + error?: { + code?: string; + message?: string; + }; +}; + +export const createRegisterHandlerResponseBody = ( + success: boolean, + error?: RegisterHandlerResponseBody["error"] +): RegisterHandlerResponseBody => ({ + success, + error, +}); + +const handleHookError = (e: RegisterCallbackError | unknown) => { + if (e instanceof RegisterCallbackError) { + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "REGISTER_HANDLER_HOOK_ERROR", + message: e.message, + }), + { status: e.status } + ); + } + return new Response("Error during app installation", { status: 500 }); +}; + +export type CreateAppRegisterHandlerOptions = HasAPL & { + /** + * Protect app from being registered in Saleor other than specific. + * By default, allow everything. + * + * Provide array of either a full Saleor API URL (eg. my-shop.saleor.cloud/graphql/) + * or a function that receives a full Saleor API URL ad returns true/false. + */ + allowedSaleorUrls?: Array boolean)>; + /** + * Run right after Saleor calls this endpoint + */ + onRequestStart?( + request: Request, + context: { + authToken?: string; + saleorDomain?: string; + saleorApiUrl?: string; + respondWithError: typeof createCallbackError; + } + ): Promise; + /** + * Run after all security checks + */ + onRequestVerified?( + request: Request, + context: { + authData: AuthData; + respondWithError: typeof createCallbackError; + } + ): Promise; + /** + * Run after APL successfully AuthData, assuming that APL.set will reject a Promise in case of error + */ + onAuthAplSaved?( + request: Request, + context: { + authData: AuthData; + respondWithError: typeof createCallbackError; + } + ): Promise; + /** + * Run after APL fails to set AuthData + */ + onAplSetFailed?( + request: Request, + context: { + authData: AuthData; + error: unknown; + respondWithError: typeof createCallbackError; + } + ): Promise; +}; + +/** + * Creates API handler for Next.js. Creates handler called by Saleor that registers app. + * Hides implementation details if possible + * In the future this will be extracted to separate sdk/next package + */ +export const createAppRegisterHandler = ({ + apl, + allowedSaleorUrls, + onAplSetFailed, + onAuthAplSaved, + onRequestVerified, + onRequestStart, +}: CreateAppRegisterHandlerOptions) => { + const baseHandler: FetchHandler = async (inputRequest) => { + debug("Request received"); + + const request = inputRequest.clone(); + const json = await request.json(); + const authToken = json.auth_token; + const saleorDomain = request.headers.get(SALEOR_DOMAIN_HEADER) as string; + const saleorApiUrl = request.headers.get(SALEOR_API_URL_HEADER) as string; + + if (onRequestStart) { + debug("Calling \"onRequestStart\" hook"); + + try { + await onRequestStart(request, { + authToken, + saleorApiUrl, + saleorDomain, + respondWithError: createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onRequestStart\" hook thrown error: %o", e); + + return handleHookError(e); + } + } + + if (!saleorApiUrl) { + debug("saleorApiUrl doesnt exist in headers"); + } + + if (!validateAllowSaleorUrls(saleorApiUrl, allowedSaleorUrls)) { + debug( + "Validation of URL %s against allowSaleorUrls param resolves to false, throwing", + saleorApiUrl + ); + + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "SALEOR_URL_PROHIBITED", + message: "This app expects to be installed only in allowed Saleor instances", + }), + { status: 403 } + ); + } + + const { configured: aplConfigured } = await apl.isConfigured(); + + if (!aplConfigured) { + debug("The APL has not been configured"); + + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "APL_NOT_CONFIGURED", + message: "APL_NOT_CONFIGURED. App is configured properly. Check APL docs for help.", + }), + { + status: 503, + } + ); + } + + // Try to get App ID from the API, to confirm that communication can be established + const appId = await getAppId({ saleorApiUrl, token: authToken }); + if (!appId) { + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "UNKNOWN_APP_ID", + message: `The auth data given during registration request could not be used to fetch app ID. + This usually means that App could not connect to Saleor during installation. Saleor URL that App tried to connect: ${saleorApiUrl}`, + }), + { + status: 401, + } + ); + } + + // Fetch the JWKS which will be used during webhook validation + const jwks = await fetchRemoteJwks(saleorApiUrl); + if (!jwks) { + return Response.json( + createRegisterHandlerResponseBody(false, { + code: "JWKS_NOT_AVAILABLE", + message: "Can't fetch the remote JWKS.", + }), + { + status: 401, + } + ); + } + + const authData = { + domain: saleorDomain, + token: authToken, + saleorApiUrl, + appId, + jwks, + }; + + if (onRequestVerified) { + debug("Calling \"onRequestVerified\" hook"); + + try { + await onRequestVerified(request, { + authData, + respondWithError: createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onRequestVerified\" hook thrown error: %o", e); + + return handleHookError(e); + } + } + + try { + await apl.set(authData); + + if (onAuthAplSaved) { + debug("Calling \"onAuthAplSaved\" hook"); + + try { + await onAuthAplSaved(request, { + authData, + respondWithError: createCallbackError, + }); + } catch (e: RegisterCallbackError | unknown) { + debug("\"onAuthAplSaved\" hook thrown error: %o", e); + + return handleHookError(e); + } + } + } catch (aplError: unknown) { + debug("There was an error during saving the auth data"); + + if (onAplSetFailed) { + debug("Calling \"onAuthAplFailed\" hook"); + + try { + await onAplSetFailed(request, { + authData, + error: aplError, + respondWithError: createCallbackError, + }); + } catch (hookError: RegisterCallbackError | unknown) { + debug("\"onAuthAplFailed\" hook thrown error: %o", hookError); + + return handleHookError(hookError); + } + } + + return Response.json( + createRegisterHandlerResponseBody(false, { + message: "Registration failed: could not save the auth data.", + }), + { status: 500 } + ); + } + + debug("Register complete"); + + return Response.json(createRegisterHandlerResponseBody(true)); + }; + + return toNextEdgeHandler([ + withMethod("POST"), + withSaleorDomainPresent, + withAuthTokenRequired, + baseHandler, + ]); +}; diff --git a/src/handlers/next-edge/index.ts b/src/handlers/next-edge/index.ts index 037eb059..9edd583e 100644 --- a/src/handlers/next-edge/index.ts +++ b/src/handlers/next-edge/index.ts @@ -1 +1,2 @@ +export * from "./create-app-register-handler"; export * from "./create-manifest-handler"; diff --git a/tsup.config.ts b/tsup.config.ts index 5d26e0be..7b73e8df 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ "src/app-bridge/next/index.ts", "src/handlers/next/index.ts", "src/handlers/next-edge/index.ts", + "src/fetch-middleware/index.ts", "src/middleware/index.ts", "src/settings-manager/index.ts", ],