diff --git a/.changeset/five-teachers-push.md b/.changeset/five-teachers-push.md new file mode 100644 index 0000000..9588b06 --- /dev/null +++ b/.changeset/five-teachers-push.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": minor +--- + +Added "appPermissions" field to AppBridgeState. Now, when app is mounted and handshake is complete, app will automatically extract permissions and save them. diff --git a/.changeset/sixty-frogs-breathe.md b/.changeset/sixty-frogs-breathe.md new file mode 100644 index 0000000..c7221b2 --- /dev/null +++ b/.changeset/sixty-frogs-breathe.md @@ -0,0 +1,5 @@ +--- +"@saleor/app-sdk": minor +--- + +Exposed "@saleor/app-sdk/headers" path. It contains helper methods: getSaleorHeaders and getBaseUrl diff --git a/docs/app-bridge.md b/docs/app-bridge.md index 3ee2e02..1550f58 100644 --- a/docs/app-bridge.md +++ b/docs/app-bridge.md @@ -50,6 +50,21 @@ type AppBridgeState = { */ saleorVersion?: string; dashboardVersion?: 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; + }; + /** + * Permissions of the app itself + */ + appPermissions?: AppPermission[]; }; ``` diff --git a/package.json b/package.json index 2ec18f3..dc5d144 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,11 @@ "types": "./verify-signature.d.ts", "import": "./verify-signature.mjs", "require": "./verify-signature.js" + }, + "./headers": { + "types": "./headers.d.ts", + "import": "./headers.mjs", + "require": "./headers.js" } }, "publishConfig": { diff --git a/src/app-bridge/app-bridge-state.ts b/src/app-bridge/app-bridge-state.ts index 6e1472e..03823b0 100644 --- a/src/app-bridge/app-bridge-state.ts +++ b/src/app-bridge/app-bridge-state.ts @@ -1,5 +1,5 @@ import { LocaleCode } from "../locales"; -import { Permission } from "../types"; +import { AppPermission, Permission } from "../types"; import { ThemeType } from "./events"; export type AppBridgeState = { @@ -28,6 +28,7 @@ export type AppBridgeState = { permissions: Permission[]; email: string; }; + appPermissions?: AppPermission[]; }; type Options = { diff --git a/src/app-bridge/app-bridge.test.ts b/src/app-bridge/app-bridge.test.ts index 559fefb..4694d59 100644 --- a/src/app-bridge/app-bridge.test.ts +++ b/src/app-bridge/app-bridge.test.ts @@ -19,6 +19,9 @@ const domain = "saleor.domain.host"; const validJwtToken = "eyJ0eXAiOiJKV1QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk4ZTEzNDk4YmM5NThjM2QyNzk2NjY5Zjk0NzYxMzZkIn0.eyJpYXQiOjE2NjkxOTE4NDUsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiZGVtby5ldS5zYWxlb3IuY2xvdWQiLCJleHAiOjE2NjkyNzgyNDUsInRva2VuIjoic2JsRmVrWnVCSUdXIiwiZW1haWwiOiJhZG1pbkBleGFtcGxlLmNvbSIsInR5cGUiOiJ0aGlyZHBhcnR5IiwidXNlcl9pZCI6IlZYTmxjam95TWc9PSIsImlzX3N0YWZmIjp0cnVlLCJhcHAiOiJRWEJ3T2pJM05RPT0iLCJwZXJtaXNzaW9ucyI6W10sInVzZXJfcGVybWlzc2lvbnMiOlsiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiLCJNQU5BR0VfUFJPRFVDVF9UWVBFU19BTkRfQVRUUklCVVRFUyIsIk1BTkFHRV9ESVNDT1VOVFMiLCJNQU5BR0VfUExVR0lOUyIsIk1BTkFHRV9TVEFGRiIsIk1BTkFHRV9QUk9EVUNUUyIsIk1BTkFHRV9TSElQUElORyIsIk1BTkFHRV9UUkFOU0xBVElPTlMiLCJNQU5BR0VfT0JTRVJWQUJJTElUWSIsIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9BUFBTIiwiTUFOQUdFX0NIQU5ORUxTIiwiTUFOQUdFX0dJRlRfQ0FSRCIsIkhBTkRMRV9QQVlNRU5UUyIsIklNUEVSU09OQVRFX1VTRVIiLCJNQU5BR0VfU0VUVElOR1MiLCJNQU5BR0VfUEFHRVMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfQ0hFQ0tPVVRTIiwiSEFORExFX0NIRUNLT1VUUyIsIk1BTkFHRV9PUkRFUlMiXX0.PUyvuUlDvUBXMGSaexusdlkY5wF83M8tsjefVXOknaKuVgLbafvLOgx78YGVB4kdAybC7O3Yjs7IIFOzz5U80Q"; +const validTokenWithAppPermissions = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEiLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE2ODk4NTIyOTEsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiaHR0cHM6Ly9oYWNrYXRob24tc2hpcHBpbmcuZXUuc2FsZW9yLmNsb3VkL2dyYXBocWwvIiwiZXhwIjoxNjg5OTM4NjkxLCJ0b2tlbiI6IjNHTWRUSVpab3FRSSIsImVtYWlsIjoibHVrYXN6Lm9zdHJvd3NraUBzYWxlb3IuaW8iLCJ0eXBlIjoidGhpcmRwYXJ0eSIsInVzZXJfaWQiOiJWWE5sY2pveU1nPT0iLCJpc19zdGFmZiI6dHJ1ZSwiYXBwIjoiUVhCd09qSXdNalE9IiwicGVybWlzc2lvbnMiOlsiTUFOQUdFX09SREVSUyIsIkhBTkRMRV9UQVhFUyIsIk1BTkFHRV9DSEFOTkVMUyJdLCJ1c2VyX3Blcm1pc3Npb25zIjpbIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9TRVRUSU5HUyIsIkhBTkRMRV9UQVhFUyIsIk1BTkFHRV9QQUdFUyIsIkhBTkRMRV9DSEVDS09VVFMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfVFJBTlNMQVRJT05TIiwiTUFOQUdFX1BST0RVQ1RTIiwiTUFOQUdFX1RBWEVTIiwiTUFOQUdFX09CU0VSVkFCSUxJVFkiLCJNQU5BR0VfT1JERVJTX0lNUE9SVCIsIk1BTkFHRV9DSEFOTkVMUyIsIk1BTkFHRV9BUFBTIiwiSU1QRVJTT05BVEVfVVNFUiIsIk1BTkFHRV9QUk9EVUNUX1RZUEVTX0FORF9BVFRSSUJVVEVTIiwiSEFORExFX1BBWU1FTlRTIiwiTUFOQUdFX0NIRUNLT1VUUyIsIk1BTkFHRV9HSUZUX0NBUkQiLCJNQU5BR0VfU0hJUFBJTkciLCJNQU5BR0VfU1RBRkYiLCJNQU5BR0VfRElTQ09VTlRTIiwiTUFOQUdFX1BMVUdJTlMiLCJNQU5BR0VfT1JERVJTIiwiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiXX0.zGglCWxuOBgGJKyyZ-6m9Th4_tGUMCMjF6W3UQhaTl5P_tQ2694Pcjwnr2zDzeF0Hl4J-gPWlyH4fLnfHIaJpDds9POtZv1D-bE5kChtkcUC1hfBUzb7iL8SwtQhtvSWy-XmsVDpQTMeD7q5McRSaKNPf3IzPXPJx-F_y5OGpgTukXoweVOufG7jcyrKWyePTqJn1evQTawQOYlzp3nj22uE4sn4UQvpbPgHIbcPohoJSdKigwAPaUqTIz_q8Mrpn4EBUezrs0_24E49kILt4K6Otupbba7rJxQe5664-o7FnSunp-2gtr6zdUaY5hV3bR84WjQZFtgCOgPVd_YT9Q"; + Object.defineProperty(window.document, "referrer", { value: origin, writable: true, @@ -100,7 +103,7 @@ describe("AppBridge", () => { it("authenticates", () => { expect(appBridge.getState().ready).toBe(false); - const token = validJwtToken; + const token = validTokenWithAppPermissions; fireEvent( window, new MessageEvent("message", { @@ -111,6 +114,11 @@ describe("AppBridge", () => { expect(appBridge.getState().ready).toBe(true); expect(appBridge.getState().token).toEqual(token); + expect(appBridge.getState().appPermissions).toEqual([ + "MANAGE_ORDERS", + "HANDLE_TAXES", + "MANAGE_CHANNELS", + ]); }); it("subscribes to an event and returns unsubscribe function", () => { diff --git a/src/app-bridge/app-bridge.ts b/src/app-bridge/app-bridge.ts index 9d912c7..f1b360e 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 { extractAppPermissionsFromJwt } from "../util/extract-app-permissions-from-jwt"; import { extractUserFromJwt } from "../util/extract-user-from-jwt"; import { Actions, actions } from "./actions"; import { AppBridgeState, AppBridgeStateContainer } from "./app-bridge-state"; @@ -23,6 +24,7 @@ function eventStateReducer(state: AppBridgeState, event: Events) { switch (event.type) { case EventType.handshake: { const userJwtPayload = extractUserFromJwt(event.payload.token); + const appPermissions = extractAppPermissionsFromJwt(event.payload.token); return { ...state, @@ -34,6 +36,7 @@ function eventStateReducer(state: AppBridgeState, event: Events) { email: userJwtPayload.email, permissions: userJwtPayload.userPermissions, }, + appPermissions, }; } case EventType.redirect: { diff --git a/src/headers.ts b/src/headers.ts index c0759fc..485c60e 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -9,6 +9,9 @@ import { const toStringOrUndefined = (value: string | string[] | undefined) => value ? value.toString() : undefined; +/** + * Extracts Saleor-specific headers from the response. + */ export const getSaleorHeaders = (headers: { [name: string]: string | string[] | undefined }) => ({ domain: toStringOrUndefined(headers[SALEOR_DOMAIN_HEADER]), authorizationBearer: toStringOrUndefined(headers[SALEOR_AUTHORIZATION_BEARER_HEADER]), @@ -17,6 +20,9 @@ export const getSaleorHeaders = (headers: { [name: string]: string | string[] | saleorApiUrl: toStringOrUndefined(headers[SALEOR_API_URL_HEADER]), }); +/** + * Extracts the app's url from headers from the response. + */ export const getBaseUrl = (headers: { [name: string]: string | string[] | undefined }): string => { const { host, "x-forwarded-proto": xForwardedProto = "http" } = headers; diff --git a/src/util/extract-app-permissions-from-jwt.test.ts b/src/util/extract-app-permissions-from-jwt.test.ts new file mode 100644 index 0000000..e256744 --- /dev/null +++ b/src/util/extract-app-permissions-from-jwt.test.ts @@ -0,0 +1,24 @@ +import { describe, expect,it } from "vitest"; + +import { extractAppPermissionsFromJwt } from "./extract-app-permissions-from-jwt"; + +/** + * Contains + * "permissions": [ + "MANAGE_ORDERS", + "HANDLE_TAXES", + "MANAGE_CHANNELS" + ], + + https://jwt.io/ + */ +const jwtWithPermissions = + "eyJhbGciOiJSUzI1NiIsImtpZCI6IjEiLCJ0eXAiOiJKV1QifQ.eyJpYXQiOjE2ODk4NTIyOTEsIm93bmVyIjoic2FsZW9yIiwiaXNzIjoiaHR0cHM6Ly9oYWNrYXRob24tc2hpcHBpbmcuZXUuc2FsZW9yLmNsb3VkL2dyYXBocWwvIiwiZXhwIjoxNjg5OTM4NjkxLCJ0b2tlbiI6IjNHTWRUSVpab3FRSSIsImVtYWlsIjoibHVrYXN6Lm9zdHJvd3NraUBzYWxlb3IuaW8iLCJ0eXBlIjoidGhpcmRwYXJ0eSIsInVzZXJfaWQiOiJWWE5sY2pveU1nPT0iLCJpc19zdGFmZiI6dHJ1ZSwiYXBwIjoiUVhCd09qSXdNalE9IiwicGVybWlzc2lvbnMiOlsiTUFOQUdFX09SREVSUyIsIkhBTkRMRV9UQVhFUyIsIk1BTkFHRV9DSEFOTkVMUyJdLCJ1c2VyX3Blcm1pc3Npb25zIjpbIk1BTkFHRV9VU0VSUyIsIk1BTkFHRV9TRVRUSU5HUyIsIkhBTkRMRV9UQVhFUyIsIk1BTkFHRV9QQUdFUyIsIkhBTkRMRV9DSEVDS09VVFMiLCJNQU5BR0VfTUVOVVMiLCJNQU5BR0VfVFJBTlNMQVRJT05TIiwiTUFOQUdFX1BST0RVQ1RTIiwiTUFOQUdFX1RBWEVTIiwiTUFOQUdFX09CU0VSVkFCSUxJVFkiLCJNQU5BR0VfT1JERVJTX0lNUE9SVCIsIk1BTkFHRV9DSEFOTkVMUyIsIk1BTkFHRV9BUFBTIiwiSU1QRVJTT05BVEVfVVNFUiIsIk1BTkFHRV9QUk9EVUNUX1RZUEVTX0FORF9BVFRSSUJVVEVTIiwiSEFORExFX1BBWU1FTlRTIiwiTUFOQUdFX0NIRUNLT1VUUyIsIk1BTkFHRV9HSUZUX0NBUkQiLCJNQU5BR0VfU0hJUFBJTkciLCJNQU5BR0VfU1RBRkYiLCJNQU5BR0VfRElTQ09VTlRTIiwiTUFOQUdFX1BMVUdJTlMiLCJNQU5BR0VfT1JERVJTIiwiTUFOQUdFX1BBR0VfVFlQRVNfQU5EX0FUVFJJQlVURVMiXX0.zGglCWxuOBgGJKyyZ-6m9Th4_tGUMCMjF6W3UQhaTl5P_tQ2694Pcjwnr2zDzeF0Hl4J-gPWlyH4fLnfHIaJpDds9POtZv1D-bE5kChtkcUC1hfBUzb7iL8SwtQhtvSWy-XmsVDpQTMeD7q5McRSaKNPf3IzPXPJx-F_y5OGpgTukXoweVOufG7jcyrKWyePTqJn1evQTawQOYlzp3nj22uE4sn4UQvpbPgHIbcPohoJSdKigwAPaUqTIz_q8Mrpn4EBUezrs0_24E49kILt4K6Otupbba7rJxQe5664-o7FnSunp-2gtr6zdUaY5hV3bR84WjQZFtgCOgPVd_YT9Q"; + +describe("extractAppPermissionsFromJwt", () => { + it("Returns permissions field from JWT token as an array of AppPermission", () => { + const permissions = extractAppPermissionsFromJwt(jwtWithPermissions); + + expect(permissions).toEqual(["MANAGE_ORDERS", "HANDLE_TAXES", "MANAGE_CHANNELS"]); + }); +}); diff --git a/src/util/extract-app-permissions-from-jwt.ts b/src/util/extract-app-permissions-from-jwt.ts new file mode 100644 index 0000000..c92d0f1 --- /dev/null +++ b/src/util/extract-app-permissions-from-jwt.ts @@ -0,0 +1,9 @@ +import * as jose from "jose"; + +import { AppPermission } from "../types"; + +export const extractAppPermissionsFromJwt = (jwtToken: string): AppPermission[] => { + const tokenDecoded = jose.decodeJwt(jwtToken); + + return tokenDecoded.permissions as AppPermission[]; +};