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 <krzysztof.k.wolski@gmail.com>

* Fix permissions name

---------

Co-authored-by: Krzysztof Wolski <krzysztof.k.wolski@gmail.com>
This commit is contained in:
Lukasz Ostrowski 2023-02-07 09:30:46 +01:00 committed by GitHub
parent 450a3ce741
commit 7413de221b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 118 additions and 15 deletions

View file

@ -1,4 +1,5 @@
import { LocaleCode } from "../locales"; import { LocaleCode } from "../locales";
import { Permission } from "../types";
import { ThemeType } from "./events"; import { ThemeType } from "./events";
export type AppBridgeState = { export type AppBridgeState = {
@ -10,6 +11,17 @@ export type AppBridgeState = {
theme: ThemeType; theme: ThemeType;
locale: LocaleCode; locale: LocaleCode;
saleorApiUrl: string; 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 = { type Options = {

View file

@ -1,5 +1,5 @@
import { fireEvent } from "@testing-library/dom"; 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"; import { LocaleCode } from "../locales";
// eslint-disable-next-line // eslint-disable-next-line
@ -15,6 +15,8 @@ import {
// mock document.referrer // mock document.referrer
const origin = "http://example.com"; const origin = "http://example.com";
const domain = "saleor.domain.host"; const domain = "saleor.domain.host";
const validJwtToken =
"eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q";
Object.defineProperty(window.document, "referrer", { Object.defineProperty(window.document, "referrer", {
value: origin, value: origin,
@ -30,7 +32,7 @@ Object.defineProperty(window, "location", {
const handshakeEvent: HandshakeEvent = { const handshakeEvent: HandshakeEvent = {
payload: { payload: {
token: "mock-token", token: validJwtToken,
version: 1, version: 1,
}, },
type: "handshake", type: "handshake",
@ -76,11 +78,20 @@ describe("AppBridge", () => {
beforeEach(() => { beforeEach(() => {
appBridge = new AppBridge(); appBridge = new AppBridge();
vi.spyOn(console, "warn").mockImplementation(() => {
// noop
});
vi.spyOn(console, "error").mockImplementation(() => { vi.spyOn(console, "error").mockImplementation(() => {
// noop // noop
}); });
}); });
afterAll(() => {
vi.clearAllMocks();
});
it("correctly sets the default domain, if not set in constructor", () => { it("correctly sets the default domain, if not set in constructor", () => {
expect(appBridge.getState().domain).toEqual(domain); expect(appBridge.getState().domain).toEqual(domain);
}); });
@ -88,7 +99,7 @@ describe("AppBridge", () => {
it("authenticates", () => { it("authenticates", () => {
expect(appBridge.getState().ready).toBe(false); expect(appBridge.getState().ready).toBe(false);
const token = "test-token"; const token = validJwtToken;
fireEvent( fireEvent(
window, window,
new MessageEvent("message", { new MessageEvent("message", {
@ -107,7 +118,7 @@ describe("AppBridge", () => {
expect(callback).not.toHaveBeenCalled(); expect(callback).not.toHaveBeenCalled();
const token = "fresh-token"; const token = validJwtToken;
// First call proper event // First call proper event
fireEvent( fireEvent(
@ -146,13 +157,13 @@ describe("AppBridge", () => {
fireEvent( fireEvent(
window, window,
new MessageEvent("message", { new MessageEvent("message", {
data: { type: "handshake", payload: { token: "123" } }, data: { type: "handshake", payload: { token: validJwtToken } },
origin, origin,
}) })
); );
expect(callback).toHaveBeenCalledTimes(1); 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 () => { it("Subscribes to theme change event and runs callback with new value after delay", async () => {

View file

@ -1,6 +1,7 @@
import debugPkg from "debug"; import debugPkg from "debug";
import { LocaleCode } from "../locales"; import { LocaleCode } from "../locales";
import { extractUserFromJwt } from "../util/extract-user-from-jwt";
import { Actions, actions } from "./actions"; import { Actions, actions } from "./actions";
import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state"; import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state";
import { AppIframeParams } from "./app-iframe-params"; import { AppIframeParams } from "./app-iframe-params";
@ -21,10 +22,16 @@ function eventStateReducer(state: AppBridgeState, event: Events) {
switch (event.type) { switch (event.type) {
case EventType.handshake: { case EventType.handshake: {
const userJwtPayload = extractUserFromJwt(event.payload.token);
return { return {
...state, ...state,
ready: true, ready: true,
token: event.payload.token, token: event.payload.token,
user: {
email: userJwtPayload.email,
permissions: userJwtPayload.userPermissions,
},
}; };
} }
case EventType.redirect: { case EventType.redirect: {

View file

@ -14,25 +14,36 @@ export type AppExtensionMount =
| "ORDER_OVERVIEW_CREATE" | "ORDER_OVERVIEW_CREATE"
| "ORDER_OVERVIEW_MORE_ACTIONS"; | "ORDER_OVERVIEW_MORE_ACTIONS";
export type AppPermission = /**
| "HANDLE_CHECKOUTS" * TODO: Extract from Saleor graphQL schema
| "HANDLE_PAYMENTS" * Reference https://docs.saleor.io/docs/3.x/api-reference/enums/permission-enum
| "HANDLE_TAXES" */
export type Permission =
| "MANAGE_USERS"
| "MANAGE_STAFF"
| "IMPERSONATE_USER"
| "MANAGE_APPS" | "MANAGE_APPS"
| "MANAGE_OBSERVABILITY"
| "MANAGE_CHECKOUTS" | "MANAGE_CHECKOUTS"
| "HANDLE_CHECKOUTS"
| "HANDLE_TAXES"
| "MANAGE_TAXES"
| "MANAGE_CHANNELS"
| "MANAGE_DISCOUNTS" | "MANAGE_DISCOUNTS"
| "MANAGE_GIFT_CARD" | "MANAGE_GIFT_CARD"
| "MANAGE_MENUS" | "MANAGE_MENUS"
| "MANAGE_ORDERS" | "MANAGE_ORDERS"
| "MANAGE_PAGES" | "MANAGE_PAGES"
| "MANAGE_PAGE_TYPES_AND_ATTRIBUTES"
| "HANDLE_PAYMENTS"
| "MANAGE_PLUGINS" | "MANAGE_PLUGINS"
| "MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES"
| "MANAGE_PRODUCTS" | "MANAGE_PRODUCTS"
| "MANAGE_SETTINGS" | "MANAGE_PRODUCT_TYPES_AND_ATTRIBUTES"
| "MANAGE_SHIPPING" | "MANAGE_SHIPPING"
| "MANAGE_STAFF" | "MANAGE_SETTINGS"
| "MANAGE_TRANSLATIONS" | "MANAGE_TRANSLATIONS";
| "MANAGE_USERS";
export type AppPermission = Permission;
export type WebhookEvent = string; export type WebhookEvent = string;

View file

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

View file

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