Add protected handlers for next (#121)
* Add protected handlers for next * Remove unneeded awaits
This commit is contained in:
parent
d8b22bf583
commit
a0a19550c2
10 changed files with 439 additions and 3 deletions
43
src/get-app-id.ts
Normal file
43
src/get-app-id.ts
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
57
src/handlers/next/create-protected-handler.ts
Normal file
57
src/handlers/next/create-protected-handler.ts
Normal 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();
|
||||||
|
});
|
||||||
|
};
|
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
|
125
src/handlers/next/process-protected-handler.test.ts
Normal file
125
src/handlers/next/process-protected-handler.test.ts
Normal 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: "
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
106
src/handlers/next/process-protected-handler.ts
Normal file
106
src/handlers/next/process-protected-handler.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
|
@ -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);
|
||||||
|
|
|
@ -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
44
src/verify-jwt.test.ts
Normal 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
52
src/verify-jwt.ts
Normal 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.`);
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in a new issue