From a0a19550c233b6b2752475ecec216d5262cfb384 Mon Sep 17 00:00:00 2001 From: Krzysztof Wolski Date: Mon, 28 Nov 2022 10:04:26 +0100 Subject: [PATCH] Add protected handlers for next (#121) * Add protected handlers for next * Remove unneeded awaits --- src/get-app-id.ts | 43 ++++++ src/handlers/next/create-protected-handler.ts | 57 ++++++++ src/handlers/next/index.ts | 2 + .../next/process-async-saleor-webhook.ts | 5 + .../next/process-protected-handler.test.ts | 125 ++++++++++++++++++ .../next/process-protected-handler.ts | 106 +++++++++++++++ src/handlers/next/saleor-async-webhook.ts | 6 +- src/infer-webhooks.ts | 2 + src/verify-jwt.test.ts | 44 ++++++ src/verify-jwt.ts | 52 ++++++++ 10 files changed, 439 insertions(+), 3 deletions(-) create mode 100644 src/get-app-id.ts create mode 100644 src/handlers/next/create-protected-handler.ts create mode 100644 src/handlers/next/process-protected-handler.test.ts create mode 100644 src/handlers/next/process-protected-handler.ts create mode 100644 src/verify-jwt.test.ts create mode 100644 src/verify-jwt.ts diff --git a/src/get-app-id.ts b/src/get-app-id.ts new file mode 100644 index 00000000..fffc17d5 --- /dev/null +++ b/src/get-app-id.ts @@ -0,0 +1,43 @@ +import { AuthData } from "./APL"; +import { createDebug } from "./debug"; + +const debug = createDebug("getAppId"); + +type GetIdResponseType = { + data?: { + app?: { + id: string; + }; + }; +}; + +export const getAppId = async (authData: AuthData): Promise => { + try { + const response = await fetch(`https://${authData.domain}/graphql/`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authData.token}`, + }, + body: JSON.stringify({ + query: ` + { + app{ + id + } + } + `, + }), + }); + if (response.status !== 200) { + debug(`Could not get the app ID: Saleor API has response code ${response.status}`); + return undefined; + } + const body = (await response.json()) as GetIdResponseType; + const appId = body.data?.app?.id; + return appId; + } catch (e) { + debug("Could not get the app ID: %O", e); + return undefined; + } +}; diff --git a/src/handlers/next/create-protected-handler.ts b/src/handlers/next/create-protected-handler.ts new file mode 100644 index 00000000..575f4189 --- /dev/null +++ b/src/handlers/next/create-protected-handler.ts @@ -0,0 +1,57 @@ +import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; + +import { APL } from "../../APL"; +import { createDebug } from "../../debug"; +import { ProtectedHandlerContext } from "./process-async-saleor-webhook"; +import { + processSaleorProtectedHandler, + ProtectedHandlerError, + SaleorProtectedHandlerError, +} from "./process-protected-handler"; + +const debug = createDebug("ProtectedHandler"); + +export const ProtectedHandlerErrorCodeMap: Record = { + OTHER: 500, + MISSING_HOST_HEADER: 400, + MISSING_DOMAIN_HEADER: 400, + NOT_REGISTERED: 401, + JWT_VERIFICATION_FAILED: 401, + NO_APP_ID: 401, + MISSING_AUTHORIZATION_BEARER_HEADER: 400, +}; + +export type NextProtectedApiHandler = ( + req: NextApiRequest, + res: NextApiResponse, + ctx: ProtectedHandlerContext +) => unknown | Promise; + +/** + * Wraps provided function, to ensure incoming request comes from Saleor Dashboard. + * Also provides additional `context` object containing request properties. + */ +export const createProtectedHandler = + (handlerFn: NextProtectedApiHandler, apl: APL): NextApiHandler => + (req, res) => { + debug("Protected handler called"); + processSaleorProtectedHandler({ + req, + apl, + }) + .then(async (context) => { + debug("Incoming request validated. Call handlerFn"); + return handlerFn(req, res, context); + }) + .catch((e) => { + debug("Unexpected error during processing the request"); + + if (e instanceof ProtectedHandlerError) { + debug(`Validation error: ${e.message}`); + res.status(ProtectedHandlerErrorCodeMap[e.errorType] || 400).end(); + return; + } + debug("Unexpected error: %O", e); + res.status(500).end(); + }); + }; diff --git a/src/handlers/next/index.ts b/src/handlers/next/index.ts index ba57aa6c..862f1d2c 100644 --- a/src/handlers/next/index.ts +++ b/src/handlers/next/index.ts @@ -1,3 +1,5 @@ export * from "./create-app-register-handler"; export * from "./create-manifest-handler"; +export * from "./create-protected-handler"; +export * from "./process-protected-handler"; export * from "./saleor-async-webhook"; diff --git a/src/handlers/next/process-async-saleor-webhook.ts b/src/handlers/next/process-async-saleor-webhook.ts index b88f33fc..19d78541 100644 --- a/src/handlers/next/process-async-saleor-webhook.ts +++ b/src/handlers/next/process-async-saleor-webhook.ts @@ -43,6 +43,11 @@ export type WebhookContext = { authData: AuthData; }; +export type ProtectedHandlerContext = { + baseUrl: string; + authData: AuthData; +}; + interface ProcessSaleorWebhookArgs { req: NextApiRequest; apl: APL; diff --git a/src/handlers/next/process-protected-handler.test.ts b/src/handlers/next/process-protected-handler.test.ts new file mode 100644 index 00000000..30d386d7 --- /dev/null +++ b/src/handlers/next/process-protected-handler.test.ts @@ -0,0 +1,125 @@ +import { NextApiRequest } from "next/types"; +import { createMocks } from "node-mocks-http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { APL } from "../../APL"; +import { getAppId } from "../../get-app-id"; +import { verifyJWT } from "../../verify-jwt"; +import { processSaleorProtectedHandler } from "./process-protected-handler"; + +const validToken = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; + +const validAppId = "QXBwOjI3NQ=="; + +vi.mock("./../../get-app-id", () => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getAppId: vi.fn(), +})); + +vi.mock("./../../verify-jwt", () => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + verifyJWT: vi.fn(), +})); + +describe("processSaleorProtectedHandler", () => { + let mockRequest: NextApiRequest; + + 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(), + isReady: vi.fn(), + isConfigured: vi.fn(), + }; + + beforeEach(() => { + // Create request method which passes all the tests + const { req } = createMocks({ + headers: { + host: "some-saleor-host.cloud", + "x-forwarded-proto": "https", + "saleor-domain": "example.com", + "saleor-event": "product_updated", + "saleor-signature": "mocked_signature", + "authorization-bearer": validToken, + }, + method: "POST", + }); + mockRequest = req; + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it("Process valid request", async () => { + vi.mocked(getAppId).mockResolvedValue(validAppId); + vi.mocked(verifyJWT).mockResolvedValue(); + + expect(await processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).toStrictEqual({ + authData: { + domain: "example.com", + token: "mock-token", + }, + baseUrl: "https://some-saleor-host.cloud", + }); + }); + + it("Throw error when app ID can't be fetched", 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(verifyJWT).mockResolvedValue(); + + delete mockRequest.headers["saleor-domain"]; + + await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( + "Missing saleor-domain header" + ); + }); + + it("Throw error when token header is missing", async () => { + vi.mocked(getAppId).mockResolvedValue(validAppId); + vi.mocked(verifyJWT).mockResolvedValue(); + + delete mockRequest.headers["authorization-bearer"]; + + await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( + "Missing authorization-bearer header" + ); + }); + + it("Throw error when APL has no auth data for the given domain", async () => { + vi.mocked(getAppId).mockResolvedValue(validAppId); + vi.mocked(verifyJWT).mockResolvedValue(); + + mockRequest.headers["saleor-domain"] = "wrong.example.com"; + + await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( + "Can't find auth data for domain wrong.example.com. Please register the application" + ); + }); + + it("Throw error when token verification fails", async () => { + vi.mocked(getAppId).mockResolvedValue(validAppId); + vi.mocked(verifyJWT).mockRejectedValue("Verification error"); + + await expect(processSaleorProtectedHandler({ apl: mockAPL, req: mockRequest })).rejects.toThrow( + "JWT verification failed: " + ); + }); +}); diff --git a/src/handlers/next/process-protected-handler.ts b/src/handlers/next/process-protected-handler.ts new file mode 100644 index 00000000..23737645 --- /dev/null +++ b/src/handlers/next/process-protected-handler.ts @@ -0,0 +1,106 @@ +import { NextApiRequest } from "next"; + +import { APL } from "../../APL"; +import { AuthData } from "../../APL/apl"; +import { createDebug } from "../../debug"; +import { getAppId } from "../../get-app-id"; +import { getBaseUrl, getSaleorHeaders } from "../../headers"; +import { verifyJWT } from "../../verify-jwt"; + +const debug = createDebug("processProtectedHandler"); + +export type SaleorProtectedHandlerError = + | "OTHER" + | "MISSING_HOST_HEADER" + | "MISSING_DOMAIN_HEADER" + | "MISSING_AUTHORIZATION_BEARER_HEADER" + | "NOT_REGISTERED" + | "JWT_VERIFICATION_FAILED" + | "NO_APP_ID"; + +export class ProtectedHandlerError extends Error { + errorType: SaleorProtectedHandlerError = "OTHER"; + + constructor(message: string, errorType: SaleorProtectedHandlerError) { + super(message); + if (errorType) { + this.errorType = errorType; + } + Object.setPrototypeOf(this, ProtectedHandlerError.prototype); + } +} + +export type ProtectedHandlerContext = { + baseUrl: string; + authData: AuthData; +}; + +interface ProcessSaleorProtectedHandlerArgs { + req: NextApiRequest; + apl: APL; +} + +type ProcessAsyncSaleorProtectedHandler = ( + props: ProcessSaleorProtectedHandlerArgs +) => Promise; + +/** + * Perform security checks on given request and return ProtectedHandlerContext object. + * In case of validation issues, instance of the ProtectedHandlerError will be thrown. + */ +export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = async ({ + req, + apl, +}: ProcessSaleorProtectedHandlerArgs): Promise => { + debug("Request processing started"); + const { domain, authorizationBearer: token } = getSaleorHeaders(req.headers); + + const baseUrl = getBaseUrl(req.headers); + if (!baseUrl) { + debug("Missing host header"); + throw new ProtectedHandlerError("Missing host header", "MISSING_HOST_HEADER"); + } + + if (!domain) { + debug("Missing saleor-domain header"); + throw new ProtectedHandlerError("Missing saleor-domain header", "MISSING_DOMAIN_HEADER"); + } + + if (!token) { + debug("Missing authorization-bearer header"); + throw new ProtectedHandlerError( + "Missing authorization-bearer header", + "MISSING_AUTHORIZATION_BEARER_HEADER" + ); + } + + // Check if domain has been registered in the APL + const authData = await apl.get(domain); + if (!authData) { + debug("APL didn't found auth data for domain %s", domain); + throw new ProtectedHandlerError( + `Can't find auth data for domain ${domain}. Please register the application`, + "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 { + await verifyJWT({ appId, token, domain }); + } catch (e) { + throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); + } + + return { + baseUrl, + authData, + }; +}; diff --git a/src/handlers/next/saleor-async-webhook.ts b/src/handlers/next/saleor-async-webhook.ts index fba8b737..a9b1358a 100644 --- a/src/handlers/next/saleor-async-webhook.ts +++ b/src/handlers/next/saleor-async-webhook.ts @@ -4,7 +4,7 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { APL } from "../../APL"; import { createDebug } from "../../debug"; import { gqlAstToString } from "../../gql-ast-to-string"; -import { WebhookEvent,WebhookManifest } from "../../types"; +import { WebhookEvent, WebhookManifest } from "../../types"; import { processAsyncSaleorWebhook, SaleorWebhookError, @@ -34,7 +34,7 @@ type WebhookManifestConfiguration = | WebhookManifestConfigurationWithAst | WebhookManifestConfigurationWithQuery; -export const ErrorCodeMap: Record = { +export const AsyncWebhookErrorCodeMap: Record = { OTHER: 500, MISSING_HOST_HEADER: 400, MISSING_DOMAIN_HEADER: 400, @@ -145,7 +145,7 @@ export class SaleorAsyncWebhook { if (e instanceof WebhookError) { debug(`Validation error: ${e.message}`); - res.status(ErrorCodeMap[e.errorType] || 400).end(); + res.status(AsyncWebhookErrorCodeMap[e.errorType] || 400).end(); return; } debug("Unexpected error: %O", e); diff --git a/src/infer-webhooks.ts b/src/infer-webhooks.ts index 6bbda0c2..74c04bb6 100644 --- a/src/infer-webhooks.ts +++ b/src/infer-webhooks.ts @@ -12,6 +12,7 @@ const dropFileExtension = (filename: string) => path.parse(filename).name; export const inferWebhooks = async ( baseURL: string, webhooksPath: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any generatedGraphQL: any ) => { let entries; @@ -38,6 +39,7 @@ export const inferWebhooks = async ( const statement = `${camelCaseName}SubscriptionDocument`; let query: string; if (statement in generatedGraphQL) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any query = print((generatedGraphQL as any)[statement]); } else { throw Error("Subscription not found."); diff --git a/src/verify-jwt.test.ts b/src/verify-jwt.test.ts new file mode 100644 index 00000000..c3e8b332 --- /dev/null +++ b/src/verify-jwt.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { verifyJWT } from "./verify-jwt"; + +const validToken = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; + +const validDomain = "demo.eu.saleor.cloud"; + +const validAppId = "QXBwOjI3NQ=="; + +describe("verifyJWT", () => { + beforeEach(() => { + vi.mock("jose", async () => { + const original = await vi.importActual("jose"); + return { + // @ts-ignore + ...original, + createRemoteJWKSet: vi.fn().mockImplementation(() => ""), + jwtVerify: vi.fn().mockImplementation(() => ""), + }; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("Process valid request", async () => { + await verifyJWT({ appId: validAppId, domain: validDomain, token: validToken }); + }); + + it("Throw error on decode issue", async () => { + await expect( + verifyJWT({ appId: validAppId, domain: validDomain, token: "wrong_token" }) + ).rejects.toThrow("JWT verification failed: Could not decode authorization token."); + }); + + it("Throw error on app ID missmatch", async () => { + await expect( + verifyJWT({ appId: "wrong_id", domain: validDomain, token: validToken }) + ).rejects.toThrow("JWT verification failed: Token's app property is different than app ID."); + }); +}); diff --git a/src/verify-jwt.ts b/src/verify-jwt.ts new file mode 100644 index 00000000..1437dca1 --- /dev/null +++ b/src/verify-jwt.ts @@ -0,0 +1,52 @@ +import * as jose from "jose"; + +import { createDebug } from "./debug"; +import { getJwksUrl } from "./urls"; + +const debug = createDebug("verify-jwt"); + +export interface DashboardTokenPayload extends jose.JWTPayload { + app: string; +} + +export interface verifyJWTArguments { + appId: string; + domain: string; + token: string; +} + +export const verifyJWT = async ({ domain, token, appId }: verifyJWTArguments) => { + let tokenClaims: DashboardTokenPayload; + const ERROR_MESSAGE = "JWT verification failed:"; + + try { + tokenClaims = jose.decodeJwt(token as string) as DashboardTokenPayload; + debug("Token Claims decoded from jwt"); + } catch (e) { + debug("Token Claims could not be decoded from JWT, will respond with Bad Request"); + throw new Error(`${ERROR_MESSAGE} Could not decode authorization token.`); + } + + if (tokenClaims.app !== appId) { + debug( + "Resolved App ID value from token to be different than in request, will respond with Bad Request" + ); + + throw new Error(`${ERROR_MESSAGE} Token's app property is different than app ID.`); + } + + try { + debug("Trying to create JWKS"); + + const JWKS = jose.createRemoteJWKSet(new URL(getJwksUrl(domain))); + debug("Trying to compare JWKS with token"); + await jose.jwtVerify(token, JWKS); + } catch (e) { + debug("Failure: %s", e); + debug("Will return with Bad Request"); + + console.error(e); + + throw new Error(`${ERROR_MESSAGE} JWT signature verification failed.`); + } +};