Skip to content

Commit

Permalink
Add protected handlers for next (#121)
Browse files Browse the repository at this point in the history
* Add protected handlers for next

* Remove unneeded awaits
  • Loading branch information
krzysztofwolski authored Nov 28, 2022
1 parent d8b22bf commit a0a1955
Show file tree
Hide file tree
Showing 10 changed files with 439 additions and 3 deletions.
43 changes: 43 additions & 0 deletions src/get-app-id.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined> => {
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;
}
};
57 changes: 57 additions & 0 deletions src/handlers/next/create-protected-handler.ts
Original file line number Diff line number Diff line change
@@ -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<SaleorProtectedHandlerError, number> = {
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<TResp = unknown> = (
req: NextApiRequest,
res: NextApiResponse<TResp>,
ctx: ProtectedHandlerContext
) => unknown | Promise<unknown>;

/**
* 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();
});
};
2 changes: 2 additions & 0 deletions src/handlers/next/index.ts
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 5 additions & 0 deletions src/handlers/next/process-async-saleor-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export type WebhookContext<T> = {
authData: AuthData;
};

export type ProtectedHandlerContext = {
baseUrl: string;
authData: AuthData;
};

interface ProcessSaleorWebhookArgs {
req: NextApiRequest;
apl: APL;
Expand Down
125 changes: 125 additions & 0 deletions src/handlers/next/process-protected-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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: "
);
});
});
106 changes: 106 additions & 0 deletions src/handlers/next/process-protected-handler.ts
Original file line number Diff line number Diff line change
@@ -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<ProtectedHandlerContext>;

/**
* 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<ProtectedHandlerContext> => {
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,
};
};
6 changes: 3 additions & 3 deletions src/handlers/next/saleor-async-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -34,7 +34,7 @@ type WebhookManifestConfiguration =
| WebhookManifestConfigurationWithAst
| WebhookManifestConfigurationWithQuery;

export const ErrorCodeMap: Record<SaleorWebhookError, number> = {
export const AsyncWebhookErrorCodeMap: Record<SaleorWebhookError, number> = {
OTHER: 500,
MISSING_HOST_HEADER: 400,
MISSING_DOMAIN_HEADER: 400,
Expand Down Expand Up @@ -145,7 +145,7 @@ export class SaleorAsyncWebhook<TPayload = unknown> {

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);
Expand Down
2 changes: 2 additions & 0 deletions src/infer-webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.");
Expand Down
Loading

0 comments on commit a0a1955

Please sign in to comment.