From 7413de221b76f6ed26a0cb0c3b186d1774262cca Mon Sep 17 00:00:00 2001 From: Lukasz Ostrowski Date: Tue, 7 Feb 2023 09:30:46 +0100 Subject: [PATCH] Add user email and permissions to app bridge (#167) * Add user email and permissions to app bridge * Update src/app-bridge/app-bridge-state.ts Co-authored-by: Krzysztof Wolski * Fix permissions name --------- Co-authored-by: Krzysztof Wolski --- src/app-bridge/app-bridge-state.ts | 12 +++++++ src/app-bridge/app-bridge.test.ts | 23 ++++++++++---- src/app-bridge/app-bridge.ts | 7 +++++ src/types.ts | 29 +++++++++++------ src/util/extract-user-from-jwt.test.ts | 43 ++++++++++++++++++++++++++ src/util/extract-user-from-jwt.ts | 19 ++++++++++++ 6 files changed, 118 insertions(+), 15 deletions(-) create mode 100644 src/util/extract-user-from-jwt.test.ts create mode 100644 src/util/extract-user-from-jwt.ts diff --git a/src/app-bridge/app-bridge-state.ts b/src/app-bridge/app-bridge-state.ts index 9a7f6dd..77b022c 100644 --- a/src/app-bridge/app-bridge-state.ts +++ b/src/app-bridge/app-bridge-state.ts @@ -1,4 +1,5 @@ import { LocaleCode } from "../locales"; +import { Permission } from "../types"; import { ThemeType } from "./events"; export type AppBridgeState = { @@ -10,6 +11,17 @@ export type AppBridgeState = { theme: ThemeType; locale: LocaleCode; saleorApiUrl: string; + user?: { + /** + * Original permissions of the user that is using the app. + * *Not* the same permissions as the app itself. + * + * Can be used by app to check if user is authorized to perform + * domain specific actions + */ + permissions: Permission[]; + email: string; + }; }; type Options = { diff --git a/src/app-bridge/app-bridge.test.ts b/src/app-bridge/app-bridge.test.ts index eed43e3..9049f2a 100644 --- a/src/app-bridge/app-bridge.test.ts +++ b/src/app-bridge/app-bridge.test.ts @@ -1,5 +1,5 @@ import { fireEvent } from "@testing-library/dom"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; import { LocaleCode } from "../locales"; // eslint-disable-next-line @@ -15,6 +15,8 @@ import { // mock document.referrer const origin = "http://example.com"; const domain = "saleor.domain.host"; +const validJwtToken = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; Object.defineProperty(window.document, "referrer", { value: origin, @@ -30,7 +32,7 @@ Object.defineProperty(window, "location", { const handshakeEvent: HandshakeEvent = { payload: { - token: "mock-token", + token: validJwtToken, version: 1, }, type: "handshake", @@ -76,11 +78,20 @@ describe("AppBridge", () => { beforeEach(() => { appBridge = new AppBridge(); + + vi.spyOn(console, "warn").mockImplementation(() => { + // noop + }); + vi.spyOn(console, "error").mockImplementation(() => { // noop }); }); + afterAll(() => { + vi.clearAllMocks(); + }); + it("correctly sets the default domain, if not set in constructor", () => { expect(appBridge.getState().domain).toEqual(domain); }); @@ -88,7 +99,7 @@ describe("AppBridge", () => { it("authenticates", () => { expect(appBridge.getState().ready).toBe(false); - const token = "test-token"; + const token = validJwtToken; fireEvent( window, new MessageEvent("message", { @@ -107,7 +118,7 @@ describe("AppBridge", () => { expect(callback).not.toHaveBeenCalled(); - const token = "fresh-token"; + const token = validJwtToken; // First call proper event fireEvent( @@ -146,13 +157,13 @@ describe("AppBridge", () => { fireEvent( window, new MessageEvent("message", { - data: { type: "handshake", payload: { token: "123" } }, + data: { type: "handshake", payload: { token: validJwtToken } }, origin, }) ); expect(callback).toHaveBeenCalledTimes(1); - expect(appBridge.getState().token).toEqual("123"); + expect(appBridge.getState().token).toEqual(validJwtToken); }); it("Subscribes to theme change event and runs callback with new value after delay", async () => { diff --git a/src/app-bridge/app-bridge.ts b/src/app-bridge/app-bridge.ts index f72da2b..3494f98 100644 --- a/src/app-bridge/app-bridge.ts +++ b/src/app-bridge/app-bridge.ts @@ -1,6 +1,7 @@ import debugPkg from "debug"; import { LocaleCode } from "../locales"; +import { extractUserFromJwt } from "../util/extract-user-from-jwt"; import { Actions, actions } from "./actions"; import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state"; import { AppIframeParams } from "./app-iframe-params"; @@ -21,10 +22,16 @@ function eventStateReducer(state: AppBridgeState, event: Events) { switch (event.type) { case EventType.handshake: { + const userJwtPayload = extractUserFromJwt(event.payload.token); + return { ...state, ready: true, token: event.payload.token, + user: { + email: userJwtPayload.email, + permissions: userJwtPayload.userPermissions, + }, }; } case EventType.redirect: { diff --git a/src/types.ts b/src/types.ts index b6ea941..1f93a2c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -14,25 +14,36 @@ export type AppExtensionMount = | "ORDER_OVERVIEW_CREATE" | "ORDER_OVERVIEW_MORE_ACTIONS"; -export type AppPermission = - | "HANDLE_CHECKOUTS" - | "HANDLE_PAYMENTS" - | "HANDLE_TAXES" +/** + * TODO: Extract from Saleor graphQL schema + * Reference https://docs.saleor.io/docs/3.x/api-reference/enums/permission-enum + */ +export type Permission = + | "MANAGE_USERS" + | "MANAGE_STAFF" + | "IMPERSONATE_USER" | "MANAGE_APPS" + | "MANAGE_OBSERVABILITY" | "MANAGE_CHECKOUTS" + | "HANDLE_CHECKOUTS" + | "HANDLE_TAXES" + | "MANAGE_TAXES" + | "MANAGE_CHANNELS" | "MANAGE_DISCOUNTS" | "MANAGE_GIFT_CARD" | "MANAGE_MENUS" | "MANAGE_ORDERS" | "MANAGE_PAGES" + | "MANAGE_PAGE_TYPES_AND_ATTRIBUTES" + | "HANDLE_PAYMENTS" | "MANAGE_PLUGINS" - | "MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES" | "MANAGE_PRODUCTS" - | "MANAGE_SETTINGS" + | "MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES" | "MANAGE_SHIPPING" - | "MANAGE_STAFF" - | "MANAGE_TRANSLATIONS" - | "MANAGE_USERS"; + | "MANAGE_SETTINGS" + | "MANAGE_TRANSLATIONS"; + +export type AppPermission = Permission; export type WebhookEvent = string; diff --git a/src/util/extract-user-from-jwt.test.ts b/src/util/extract-user-from-jwt.test.ts new file mode 100644 index 0000000..2e3d51a --- /dev/null +++ b/src/util/extract-user-from-jwt.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { extractUserFromJwt } from "./extract-user-from-jwt"; + +const validJwtToken = + "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; + +const invalidToken = "foo"; + +describe("extractUserFromJwt", () => { + it("Throws if token is invalid", () => { + expect(() => extractUserFromJwt(invalidToken)).toThrow(); + }); + + it("Extracts email and user permissions from the token", () => { + expect(extractUserFromJwt(validJwtToken)).toEqual({ + email: "admin@example.com", + userPermissions: [ + "MANAGE_PAGE_TYPES_AND_ATTRIBUTES", + "MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES", + "MANAGE_DISCOUNTS", + "MANAGE_PLUGINS", + "MANAGE_STAFF", + "MANAGE_PRODUCTS", + "MANAGE_SHIPPING", + "MANAGE_TRANSLATIONS", + "MANAGE_OBSERVABILITY", + "MANAGE_USERS", + "MANAGE_APPS", + "MANAGE_CHANNELS", + "MANAGE_GIFT_CARD", + "HANDLE_PAYMENTS", + "IMPERSONATE_USER", + "MANAGE_SETTINGS", + "MANAGE_PAGES", + "MANAGE_MENUS", + "MANAGE_CHECKOUTS", + "HANDLE_CHECKOUTS", + "MANAGE_ORDERS", + ], + }); + }); +}); diff --git a/src/util/extract-user-from-jwt.ts b/src/util/extract-user-from-jwt.ts new file mode 100644 index 0000000..4e1fa10 --- /dev/null +++ b/src/util/extract-user-from-jwt.ts @@ -0,0 +1,19 @@ +import * as jose from "jose"; + +import { AppPermission } from "../types"; + +type TokenUserPayload = { + email: string; + userPermissions: AppPermission[]; +}; + +export const extractUserFromJwt = (jwtToken: string): TokenUserPayload => { + const tokenDecoded = jose.decodeJwt(jwtToken); + + const { email, user_permissions: userPermissions } = tokenDecoded; + + return { + email, + userPermissions, + } as TokenUserPayload; +};