diff --git a/src/handlers/next/create-protected-handler.ts b/src/handlers/next/create-protected-handler.ts index e41d463..c17fb22 100644 --- a/src/handlers/next/create-protected-handler.ts +++ b/src/handlers/next/create-protected-handler.ts @@ -2,6 +2,7 @@ import { NextApiHandler, NextApiRequest, NextApiResponse } from "next"; import { APL } from "../../APL"; import { createDebug } from "../../debug"; +import { AppPermission } from "../../types"; import { ProtectedHandlerContext } from "./process-async-saleor-webhook"; import { processSaleorProtectedHandler, @@ -33,12 +34,17 @@ export type NextProtectedApiHandler = ( * Also provides additional `context` object containing request properties. */ export const createProtectedHandler = - (handlerFn: NextProtectedApiHandler, apl: APL): NextApiHandler => + ( + handlerFn: NextProtectedApiHandler, + apl: APL, + requiredPermissions?: AppPermission[] + ): NextApiHandler => (req, res) => { debug("Protected handler called"); processSaleorProtectedHandler({ req, apl, + requiredPermissions, }) .then(async (context) => { debug("Incoming request validated. Call handlerFn"); diff --git a/src/handlers/next/process-protected-handler.ts b/src/handlers/next/process-protected-handler.ts index cb8f2fc..d6834b8 100644 --- a/src/handlers/next/process-protected-handler.ts +++ b/src/handlers/next/process-protected-handler.ts @@ -4,6 +4,7 @@ import { APL } from "../../APL"; import { AuthData } from "../../APL/apl"; import { createDebug } from "../../debug"; import { getBaseUrl, getSaleorHeaders } from "../../headers"; +import { AppPermission } from "../../types"; import { verifyJWT } from "../../verify-jwt"; const debug = createDebug("processProtectedHandler"); @@ -38,6 +39,7 @@ export type ProtectedHandlerContext = { interface ProcessSaleorProtectedHandlerArgs { req: NextApiRequest; apl: APL; + requiredPermissions?: AppPermission[]; } type ProcessAsyncSaleorProtectedHandler = ( @@ -51,8 +53,10 @@ type ProcessAsyncSaleorProtectedHandler = ( export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = async ({ req, apl, + requiredPermissions, }: ProcessSaleorProtectedHandlerArgs): Promise => { debug("Request processing started"); + const { saleorApiUrl, authorizationBearer: token } = getSaleorHeaders(req.headers); const baseUrl = getBaseUrl(req.headers); @@ -85,7 +89,7 @@ export const processSaleorProtectedHandler: ProcessAsyncSaleorProtectedHandler = } try { - await verifyJWT({ appId: authData.appId, token, saleorApiUrl }); + await verifyJWT({ appId: authData.appId, token, saleorApiUrl, requiredPermissions }); } catch (e) { throw new ProtectedHandlerError("JWT verification failed: ", "JWT_VERIFICATION_FAILED"); } diff --git a/src/has-permissions-in-jwt-token.ts b/src/has-permissions-in-jwt-token.ts new file mode 100644 index 0000000..7306488 --- /dev/null +++ b/src/has-permissions-in-jwt-token.ts @@ -0,0 +1,36 @@ +import { createDebug } from "./debug"; +import { AppPermission } from "./types"; +import { DashboardTokenPayload } from "./verify-jwt"; + +const debug = createDebug("checkJwtPermissions"); + +export const hasPermissionsInJwtToken = ( + tokenData?: Pick, + permissionsToCheckAgainst?: AppPermission[] +) => { + debug(`Permissions required ${permissionsToCheckAgainst}`); + + if (!permissionsToCheckAgainst?.length) { + debug("No permissions specified, check passed"); + return true; + } + + const userPermissions = tokenData?.user_permissions || undefined; + + if (!userPermissions?.length) { + debug("User has no permissions assigned. Rejected"); + return false; + } + + const arePermissionsSatisfied = permissionsToCheckAgainst.every((permission) => + userPermissions.includes(permission) + ); + + if (!arePermissionsSatisfied) { + debug("Permissions check not passed"); + return false; + } + + debug("Permissions check successful"); + return true; +}; diff --git a/src/has-permissions.in-jwt-token.test.ts b/src/has-permissions.in-jwt-token.test.ts new file mode 100644 index 0000000..238097c --- /dev/null +++ b/src/has-permissions.in-jwt-token.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; + +import { hasPermissionsInJwtToken } from "./has-permissions-in-jwt-token"; +import { DashboardTokenPayload } from "./verify-jwt"; + +describe("hasPermissionsInJwtToken", () => { + it("Pass if no required permissions, and user has none", async () => { + const tokenData: Pick = { + user_permissions: [], + }; + await expect(hasPermissionsInJwtToken(tokenData)).toBeTruthy(); + }); + + it("Pass if no required permissions", async () => { + const tokenData: Pick = { + user_permissions: ["MANAGE_ORDERS"], + }; + await expect(hasPermissionsInJwtToken(tokenData)).toBeTruthy(); + }); + + it("Pass if user has assigned required permissions", async () => { + const tokenData: Pick = { + user_permissions: ["MANAGE_ORDERS", "MANAGE_CHECKOUTS", "HANDLE_TAXES"], + }; + await expect( + hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]) + ).toBeTruthy(); + }); + + it("Reject if user is missing any of required permissions", async () => { + const tokenData: Pick = { + user_permissions: ["MANAGE_ORDERS", "HANDLE_TAXES"], + }; + await expect( + hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]) + ).toBeFalsy(); + }); + + it("Reject if user is missing permission data", async () => { + const tokenData: Pick = { + user_permissions: [], + }; + await expect( + hasPermissionsInJwtToken(tokenData, ["MANAGE_ORDERS", "MANAGE_CHECKOUTS"]) + ).toBeFalsy(); + }); +}); diff --git a/src/verify-jwt.test.ts b/src/verify-jwt.test.ts index 29b38cd..94ba6b0 100644 --- a/src/verify-jwt.test.ts +++ b/src/verify-jwt.test.ts @@ -41,4 +41,15 @@ describe("verifyJWT", () => { verifyJWT({ appId: "wrong_id", saleorApiUrl: validApiUrl, token: validToken }) ).rejects.toThrow("JWT verification failed: Token's app property is different than app ID."); }); + + it("Throw error on user missing the permissions", async () => { + await expect( + verifyJWT({ + appId: validAppId, + saleorApiUrl: validApiUrl, + token: validToken, + requiredPermissions: ["HANDLE_TAXES"], + }) + ).rejects.toThrow("JWT verification failed: Token's permissions are not sufficient."); + }); }); diff --git a/src/verify-jwt.ts b/src/verify-jwt.ts index 29fe9c2..e28ef3b 100644 --- a/src/verify-jwt.ts +++ b/src/verify-jwt.ts @@ -1,21 +1,30 @@ import * as jose from "jose"; import { createDebug } from "./debug"; +import { hasPermissionsInJwtToken } from "./has-permissions-in-jwt-token"; +import { AppPermission } from "./types"; import { getJwksUrlFromSaleorApiUrl } from "./urls"; const debug = createDebug("verify-jwt"); export interface DashboardTokenPayload extends jose.JWTPayload { app: string; + user_permissions: AppPermission[]; } export interface verifyJWTArguments { appId: string; saleorApiUrl: string; token: string; + requiredPermissions?: AppPermission[]; } -export const verifyJWT = async ({ saleorApiUrl, token, appId }: verifyJWTArguments) => { +export const verifyJWT = async ({ + saleorApiUrl, + token, + appId, + requiredPermissions, +}: verifyJWTArguments) => { let tokenClaims: DashboardTokenPayload; const ERROR_MESSAGE = "JWT verification failed:"; @@ -35,6 +44,11 @@ export const verifyJWT = async ({ saleorApiUrl, token, appId }: verifyJWTArgumen throw new Error(`${ERROR_MESSAGE} Token's app property is different than app ID.`); } + if (!hasPermissionsInJwtToken(tokenClaims, requiredPermissions)) { + debug("Token did not meet requirements for permissions: %s", requiredPermissions); + throw new Error(`${ERROR_MESSAGE} Token's permissions are not sufficient.`); + } + try { debug("Trying to create JWKS");