Add protected handlers for next (#121)

* Add protected handlers for next

* Remove unneeded awaits
This commit is contained in:
Krzysztof Wolski 2022-11-28 10:04:26 +01:00 committed by GitHub
parent d8b22bf583
commit a0a19550c2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 439 additions and 3 deletions

43
src/get-app-id.ts Normal file
View file

@ -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;
}
};

View file

@ -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();
});
};

View file

@ -1,3 +1,5 @@
export * from "./create-app-register-handler"; export * from "./create-app-register-handler";
export * from "./create-manifest-handler"; export * from "./create-manifest-handler";
export * from "./create-protected-handler";
export * from "./process-protected-handler";
export * from "./saleor-async-webhook"; export * from "./saleor-async-webhook";

View file

@ -43,6 +43,11 @@ export type WebhookContext<T> = {
authData: AuthData; authData: AuthData;
}; };
export type ProtectedHandlerContext = {
baseUrl: string;
authData: AuthData;
};
interface ProcessSaleorWebhookArgs { interface ProcessSaleorWebhookArgs {
req: NextApiRequest; req: NextApiRequest;
apl: APL; apl: APL;

View file

@ -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: "
);
});
});

View file

@ -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,
};
};

View file

@ -4,7 +4,7 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { APL } from "../../APL"; import { APL } from "../../APL";
import { createDebug } from "../../debug"; import { createDebug } from "../../debug";
import { gqlAstToString } from "../../gql-ast-to-string"; import { gqlAstToString } from "../../gql-ast-to-string";
import { WebhookEvent,WebhookManifest } from "../../types"; import { WebhookEvent, WebhookManifest } from "../../types";
import { import {
processAsyncSaleorWebhook, processAsyncSaleorWebhook,
SaleorWebhookError, SaleorWebhookError,
@ -34,7 +34,7 @@ type WebhookManifestConfiguration =
| WebhookManifestConfigurationWithAst | WebhookManifestConfigurationWithAst
| WebhookManifestConfigurationWithQuery; | WebhookManifestConfigurationWithQuery;
export const ErrorCodeMap: Record<SaleorWebhookError, number> = { export const AsyncWebhookErrorCodeMap: Record<SaleorWebhookError, number> = {
OTHER: 500, OTHER: 500,
MISSING_HOST_HEADER: 400, MISSING_HOST_HEADER: 400,
MISSING_DOMAIN_HEADER: 400, MISSING_DOMAIN_HEADER: 400,
@ -145,7 +145,7 @@ export class SaleorAsyncWebhook<TPayload = unknown> {
if (e instanceof WebhookError) { if (e instanceof WebhookError) {
debug(`Validation error: ${e.message}`); debug(`Validation error: ${e.message}`);
res.status(ErrorCodeMap[e.errorType] || 400).end(); res.status(AsyncWebhookErrorCodeMap[e.errorType] || 400).end();
return; return;
} }
debug("Unexpected error: %O", e); debug("Unexpected error: %O", e);

View file

@ -12,6 +12,7 @@ const dropFileExtension = (filename: string) => path.parse(filename).name;
export const inferWebhooks = async ( export const inferWebhooks = async (
baseURL: string, baseURL: string,
webhooksPath: string, webhooksPath: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
generatedGraphQL: any generatedGraphQL: any
) => { ) => {
let entries; let entries;
@ -38,6 +39,7 @@ export const inferWebhooks = async (
const statement = `${camelCaseName}SubscriptionDocument`; const statement = `${camelCaseName}SubscriptionDocument`;
let query: string; let query: string;
if (statement in generatedGraphQL) { if (statement in generatedGraphQL) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query = print((generatedGraphQL as any)[statement]); query = print((generatedGraphQL as any)[statement]);
} else { } else {
throw Error("Subscription not found."); throw Error("Subscription not found.");

44
src/verify-jwt.test.ts Normal file
View file

@ -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.");
});
});

52
src/verify-jwt.ts Normal file
View file

@ -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.`);
}
};