diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index a9844c1..0000000 --- a/src/middleware.ts +++ /dev/null @@ -1,206 +0,0 @@ -import crypto from "crypto"; -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"; - -export const withBaseURL: Middleware = (handler) => async (request) => { - const { host, "x-forwarded-proto": protocol = "http" } = request.headers; - - request.context.baseURL = `${protocol}://${host}`; - - return handler(request); -}; - -export const withSaleorDomainPresent: Middleware = (handler) => async (request) => { - const { domain } = getSaleorHeaders(request.headers); - - if (!domain) { - return Response.BadRequest({ - success: false, - message: "Missing Saleor domain header.", - }); - } - - return handler(request); -}; - -export const withSaleorEventMatch = - (expectedEvent: `${Lowercase}`): Middleware => - (handler) => - async (request) => { - const { event } = getSaleorHeaders(request.headers); - - if (event !== expectedEvent) { - return Response.BadRequest({ - success: false, - message: `Invalid Saleor event. Expecting ${expectedEvent}.`, - }); - } - - return handler(request); - }; - -export const withAuthTokenRequired: Middleware = (handler) => async (request) => { - const authToken = request.params.auth_token; - if (!authToken) { - return Response.BadRequest({ - success: false, - message: "Missing auth token.", - }); - } - - return handler(request); -}; - -export const withWebhookSignatureVerified = - (secretKey: string | undefined = undefined): Middleware => - (handler) => - async (request) => { - const ERROR_MESSAGE = "Webhook signature verification failed:"; - - if (request.rawBody === undefined) { - return Response.InternalServerError({ - success: false, - message: `${ERROR_MESSAGE} Request payload already parsed.`, - }); - } - - const { domain: saleorDomain, signature: payloadSignature } = getSaleorHeaders(request.headers); - - if (!payloadSignature) { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Missing ${SALEOR_SIGNATURE_HEADER} header.`, - }); - } - - if (secretKey !== undefined) { - const calculatedSignature = crypto - .createHmac("sha256", secretKey) - .update(request.rawBody) - .digest("hex"); - - if (calculatedSignature !== payloadSignature) { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Verification using secret key has failed.`, - }); - } - } else { - const [header, , signature] = payloadSignature.split("."); - const jws = { - protected: header, - payload: request.rawBody, - signature, - }; - - const remoteJwks = jose.createRemoteJWKSet( - new URL(getJwksUrl(saleorDomain)) - ) as jose.FlattenedVerifyGetKey; - - try { - await jose.flattenedVerify(jws, remoteJwks); - } catch { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Verification using public key has failed.`, - }); - } - } - - return handler(request); - }; - -export interface DashboardTokenPayload extends jose.JWTPayload { - app: string; -} - -export const withJWTVerified = - (getAppId: (request: Request) => Promise): Middleware => - (handler) => - async (request) => { - const { domain, authorizationBearer: token } = getSaleorHeaders(request.headers); - const ERROR_MESSAGE = "JWT verification failed:"; - - if (token === undefined) { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Missing ${SALEOR_AUTHORIZATION_BEARER_HEADER} header.`, - }); - } - - let tokenClaims: DashboardTokenPayload; - try { - tokenClaims = jose.decodeJwt(token as string) as DashboardTokenPayload; - } catch (e) { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Could not decode authorization token.`, - }); - } - - if (tokenClaims.iss !== domain) { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Token iss property is different than domain header.`, - }); - } - - let appId: string | undefined; - try { - appId = await getAppId(request); - } catch (error) { - return Response.InternalServerError({ - success: false, - message: `${ERROR_MESSAGE} Could not obtain the app ID.`, - }); - } - - if (!appId) { - return Response.InternalServerError({ - success: false, - message: `${ERROR_MESSAGE} No value for app ID.`, - }); - } - - if (tokenClaims.app !== appId) { - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} Token's app property is different than app ID.`, - }); - } - - try { - const JWKS = jose.createRemoteJWKSet(new URL(getJwksUrl(domain))); - await jose.jwtVerify(token, JWKS); - } catch (e) { - console.error(e); - return Response.BadRequest({ - success: false, - message: `${ERROR_MESSAGE} JWT signature verification failed.`, - }); - } - - 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); - }; diff --git a/src/middleware/index.ts b/src/middleware/index.ts new file mode 100644 index 0000000..b35497e --- /dev/null +++ b/src/middleware/index.ts @@ -0,0 +1,6 @@ +export * from "./with-auth-token-required"; +export * from "./with-base-url"; +export * from "./with-registered-saleor-domain-header"; +export * from "./with-saleor-domain-present"; +export * from "./with-saleor-event-match"; +export * from "./with-webhook-signature-verified"; diff --git a/src/middleware/with-auth-token-required.ts b/src/middleware/with-auth-token-required.ts new file mode 100644 index 0000000..31f3996 --- /dev/null +++ b/src/middleware/with-auth-token-required.ts @@ -0,0 +1,14 @@ +import { Middleware } from "retes"; +import { Response } from "retes/response"; + +export const withAuthTokenRequired: Middleware = (handler) => async (request) => { + const authToken = request.params.auth_token; + if (!authToken) { + return Response.BadRequest({ + success: false, + message: "Missing auth token.", + }); + } + + return handler(request); +}; diff --git a/src/middleware/with-base-url.test.ts b/src/middleware/with-base-url.test.ts new file mode 100644 index 0000000..a951ccc --- /dev/null +++ b/src/middleware/with-base-url.test.ts @@ -0,0 +1,32 @@ +import { Handler, Request } from "retes"; +import { Response } from "retes/response"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { withBaseURL } from "./with-base-url"; + +const getMockEmptyResponse = async () => ({} as Response); + +describe("middleware.test.ts", () => { + describe("withBaseURL", () => { + let mockHandlerFn: Handler = vi.fn(getMockEmptyResponse); + + beforeEach(() => { + mockHandlerFn = vi.fn(); + }); + + it("Adds base URL from request header to context and calls handler", async () => { + const mockRequest = { + context: {}, + headers: { + host: "my-saleor-env.saleor.cloud", + "x-forwarded-proto": "https", + }, + } as unknown as Request; + + await withBaseURL(mockHandlerFn)(mockRequest); + + expect(mockRequest.context.baseURL).toBe("https://my-saleor-env.saleor.cloud"); + expect(mockHandlerFn).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/middleware/with-base-url.ts b/src/middleware/with-base-url.ts new file mode 100644 index 0000000..68ab840 --- /dev/null +++ b/src/middleware/with-base-url.ts @@ -0,0 +1,9 @@ +import { Middleware } from "retes"; + +export const withBaseURL: Middleware = (handler) => async (request) => { + const { host, "x-forwarded-proto": protocol = "http" } = request.headers; + + request.context.baseURL = `${protocol}://${host}`; + + return handler(request); +}; diff --git a/src/middleware/with-jwt-verified.ts b/src/middleware/with-jwt-verified.ts new file mode 100644 index 0000000..062f0f2 --- /dev/null +++ b/src/middleware/with-jwt-verified.ts @@ -0,0 +1,80 @@ +import * as jose from "jose"; +import type { Middleware, Request } from "retes"; +import { Response } from "retes/response"; + +import { SALEOR_AUTHORIZATION_BEARER_HEADER } from "../const"; +import { getSaleorHeaders } from "../headers"; +import { getJwksUrl } from "../urls"; + +export interface DashboardTokenPayload extends jose.JWTPayload { + app: string; +} + +export const withJWTVerified = + (getAppId: (request: Request) => Promise): Middleware => + (handler) => + async (request) => { + const { domain, authorizationBearer: token } = getSaleorHeaders(request.headers); + const ERROR_MESSAGE = "JWT verification failed:"; + + if (token === undefined) { + return Response.BadRequest({ + success: false, + message: `${ERROR_MESSAGE} Missing ${SALEOR_AUTHORIZATION_BEARER_HEADER} header.`, + }); + } + + let tokenClaims: DashboardTokenPayload; + try { + tokenClaims = jose.decodeJwt(token as string) as DashboardTokenPayload; + } catch (e) { + return Response.BadRequest({ + success: false, + message: `${ERROR_MESSAGE} Could not decode authorization token.`, + }); + } + + if (tokenClaims.iss !== domain) { + return Response.BadRequest({ + success: false, + message: `${ERROR_MESSAGE} Token iss property is different than domain header.`, + }); + } + + let appId: string | undefined; + try { + appId = await getAppId(request); + } catch (error) { + return Response.InternalServerError({ + success: false, + message: `${ERROR_MESSAGE} Could not obtain the app ID.`, + }); + } + + if (!appId) { + return Response.InternalServerError({ + success: false, + message: `${ERROR_MESSAGE} No value for app ID.`, + }); + } + + if (tokenClaims.app !== appId) { + return Response.BadRequest({ + success: false, + message: `${ERROR_MESSAGE} Token's app property is different than app ID.`, + }); + } + + try { + const JWKS = jose.createRemoteJWKSet(new URL(getJwksUrl(domain))); + await jose.jwtVerify(token, JWKS); + } catch (e) { + console.error(e); + return Response.BadRequest({ + success: false, + message: `${ERROR_MESSAGE} JWT signature verification failed.`, + }); + } + + return handler(request); + }; diff --git a/src/middleware.test.ts b/src/middleware/with-registered-saleor-domain-header.test.ts similarity index 69% rename from src/middleware.test.ts rename to src/middleware/with-registered-saleor-domain-header.test.ts index af4a2b2..6dc7fa7 100644 --- a/src/middleware.test.ts +++ b/src/middleware/with-registered-saleor-domain-header.test.ts @@ -2,36 +2,13 @@ import { Handler, Request } from "retes"; import { Response } from "retes/response"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { APL } from "./APL"; -import { SALEOR_DOMAIN_HEADER } from "./const"; -import { withBaseURL, withRegisteredSaleorDomainHeader } from "./middleware"; +import { APL } from "../APL"; +import { SALEOR_DOMAIN_HEADER } from "../const"; +import { withRegisteredSaleorDomainHeader } from "./with-registered-saleor-domain-header"; const getMockSuccessResponse = async () => Response.OK({}); describe("middleware.test.ts", () => { - describe("withBaseURL", () => { - let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); - - beforeEach(() => { - mockHandlerFn = vi.fn(getMockSuccessResponse); - }); - - it("Adds base URL from request header to context and calls handler", async () => { - const mockRequest = { - context: {}, - headers: { - host: "my-saleor-env.saleor.cloud", - "x-forwarded-proto": "https", - }, - } as unknown as Request; - - await withBaseURL(mockHandlerFn)(mockRequest); - - expect(mockRequest.context.baseURL).toBe("https://my-saleor-env.saleor.cloud"); - expect(mockHandlerFn).toHaveBeenCalledOnce(); - }); - }); - describe("withRegisteredSaleorDomainHeader", () => { let mockHandlerFn: Handler = vi.fn(getMockSuccessResponse); diff --git a/src/middleware/with-registered-saleor-domain-header.ts b/src/middleware/with-registered-saleor-domain-header.ts new file mode 100644 index 0000000..86c7e04 --- /dev/null +++ b/src/middleware/with-registered-saleor-domain-header.ts @@ -0,0 +1,21 @@ +import { Middleware } from "retes"; +import { Response } from "retes/response"; + +import { APL } from "../APL"; +import { getSaleorHeaders } from "../headers"; + +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); + }; diff --git a/src/middleware/with-saleor-domain-present.ts b/src/middleware/with-saleor-domain-present.ts new file mode 100644 index 0000000..4125718 --- /dev/null +++ b/src/middleware/with-saleor-domain-present.ts @@ -0,0 +1,17 @@ +import { Middleware } from "retes"; +import { Response } from "retes/response"; + +import { getSaleorHeaders } from "../headers"; + +export const withSaleorDomainPresent: Middleware = (handler) => async (request) => { + const { domain } = getSaleorHeaders(request.headers); + + if (!domain) { + return Response.BadRequest({ + success: false, + message: "Missing Saleor domain header.", + }); + } + + return handler(request); +}; diff --git a/src/middleware/with-saleor-event-match.ts b/src/middleware/with-saleor-event-match.ts new file mode 100644 index 0000000..4813e86 --- /dev/null +++ b/src/middleware/with-saleor-event-match.ts @@ -0,0 +1,20 @@ +import { Middleware } from "retes"; +import { Response } from "retes/response"; + +import { getSaleorHeaders } from "../headers"; + +export const withSaleorEventMatch = + (expectedEvent: `${Lowercase}`): Middleware => + (handler) => + async (request) => { + const { event } = getSaleorHeaders(request.headers); + + if (event !== expectedEvent) { + return Response.BadRequest({ + success: false, + message: `Invalid Saleor event. Expecting ${expectedEvent}.`, + }); + } + + return handler(request); + }; diff --git a/src/middleware/with-webhook-signature-verified.ts b/src/middleware/with-webhook-signature-verified.ts new file mode 100644 index 0000000..dc3c0d3 --- /dev/null +++ b/src/middleware/with-webhook-signature-verified.ts @@ -0,0 +1,67 @@ +import crypto from "crypto"; +import * as jose from "jose"; +import { Middleware } from "retes"; +import { Response } from "retes/response"; + +import { SALEOR_SIGNATURE_HEADER } from "../const"; +import { getSaleorHeaders } from "../headers"; +import { getJwksUrl } from "../urls"; + +export const withWebhookSignatureVerified = + (secretKey: string | undefined = undefined): Middleware => + (handler) => + async (request) => { + const ERROR_MESSAGE = "Webhook signature verification failed:"; + + if (request.rawBody === undefined) { + return Response.InternalServerError({ + success: false, + message: `${ERROR_MESSAGE} Request payload already parsed.`, + }); + } + + const { domain: saleorDomain, signature: payloadSignature } = getSaleorHeaders(request.headers); + + if (!payloadSignature) { + return Response.BadRequest({ + success: false, + message: `${ERROR_MESSAGE} Missing ${SALEOR_SIGNATURE_HEADER} header.`, + }); + } + + if (secretKey !== undefined) { + const calculatedSignature = crypto + .createHmac("sha256", secretKey) + .update(request.rawBody) + .digest("hex"); + + if (calculatedSignature !== payloadSignature) { + return Response.BadRequest({ + success: false, + message: `${ERROR_MESSAGE} Verification using secret key has failed.`, + }); + } + } else { + const [header, , signature] = payloadSignature.split("."); + const jws = { + protected: header, + payload: request.rawBody, + signature, + }; + + const remoteJwks = jose.createRemoteJWKSet( + new URL(getJwksUrl(saleorDomain)) + ) as jose.FlattenedVerifyGetKey; + + try { + await jose.flattenedVerify(jws, remoteJwks); + } catch { + return Response.BadRequest({ + success: false, + message: `${ERROR_MESSAGE} Verification using public key has failed.`, + }); + } + } + + return handler(request); + };