diff --git a/wallet/src/background/Permissions.ts b/wallet/src/background/Permissions.ts index 5b99454fcaba2..610e84473f0f3 100644 --- a/wallet/src/background/Permissions.ts +++ b/wallet/src/background/Permissions.ts @@ -1,35 +1,142 @@ // Copyright (c) 2022, Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -import { filter, lastValueFrom, map, race, Subject, take, tap } from 'rxjs'; +import { + catchError, + concatMap, + filter, + from, + mergeWith, + share, + Subject, +} from 'rxjs'; import { v4 as uuidV4 } from 'uuid'; import Browser from 'webextension-polyfill'; +import Tabs from './Tabs'; import { Window } from './Window'; +import { + ALL_PERMISSION_TYPES, + isValidPermissionTypes, +} from '_payloads/permissions'; import type { ContentScriptConnection } from './connections/ContentScriptConnection'; import type { Permission, PermissionResponse, PermissionType, -} from '_messages/payloads/permissions'; - -function openPermissionWindow(permissionID: string) { - return new Window( - Browser.runtime.getURL('ui.html') + - `#/dapp/connect/${encodeURIComponent(permissionID)}` - ); -} +} from '_payloads/permissions'; +import type { Observable } from 'rxjs'; const PERMISSIONS_STORAGE_KEY = 'permissions'; +const PERMISSION_UI_URL = `${Browser.runtime.getURL('ui.html')}#/dapp/connect/`; +const PERMISSION_UI_URL_REGEX = new RegExp( + `${PERMISSION_UI_URL}([0-9a-f-]+$)`, + 'i' +); class Permissions { + public static getUiUrl(permissionID: string) { + return `${PERMISSION_UI_URL}${encodeURIComponent(permissionID)}`; + } + + public static isPermissionUiUrl(url: string) { + return PERMISSION_UI_URL_REGEX.test(url); + } + + public static getPermissionIDFromUrl(url: string) { + const match = PERMISSION_UI_URL_REGEX.exec(url); + if (match) { + return match[1]; + } + return null; + } + private _permissionResponses: Subject = new Subject(); + //NOTE: we need at least one subscription in order for this to handle permission requests + public readonly permissionReply: Observable; - public async acquirePermissions( + constructor() { + this.permissionReply = this._permissionResponses.pipe( + mergeWith( + Tabs.onRemoved.pipe( + filter((aTab) => + Permissions.isPermissionUiUrl(aTab.url || '') + ) + ) + ), + concatMap((data) => + from( + (async () => { + let permissionID: string | null; + const response: Partial = { + allowed: false, + accounts: [], + responseDate: new Date().toISOString(), + }; + if ('url' in data) { + permissionID = Permissions.getPermissionIDFromUrl( + data.url || '' + ); + } else { + permissionID = data.id; + response.allowed = data.allowed; + response.accounts = data.accounts; + response.responseDate = data.responseDate; + } + let aPermissionRequest: Permission | null = null; + if (permissionID) { + aPermissionRequest = await this.getPermissionByID( + permissionID + ); + } + if ( + aPermissionRequest && + this.isPendingPermissionRequest(aPermissionRequest) + ) { + const finalPermission: Permission = { + ...aPermissionRequest, + ...response, + }; + return finalPermission; + } + // ignore the event + return null; + })() + ).pipe( + filter((data) => data !== null), + concatMap((permission) => + from( + (async () => { + if (permission) { + await this.storePermission(permission); + return permission; + } + return null; + })() + ) + ) + ) + ), + // we ignore any errors and continue to handle other requests + // TODO: expose those errors to dapp? + catchError((_error, originalSource) => originalSource), + share() + ); + } + + public async startRequestPermissions( permissionTypes: readonly PermissionType[], - connection: ContentScriptConnection - ): Promise { + connection: ContentScriptConnection, + requestMsgID: string + ): Promise { + if (!isValidPermissionTypes(permissionTypes)) { + throw new Error( + `Invalid permission types. Allowed type are ${ALL_PERMISSION_TYPES.join( + ', ' + )}` + ); + } const { origin } = connection; const existingPermission = await this.getPermission(origin); const hasPendingRequest = await this.hasPendingPermissionRequest( @@ -37,6 +144,13 @@ class Permissions { existingPermission ); if (hasPendingRequest) { + if (existingPermission) { + const uiUrl = Permissions.getUiUrl(existingPermission.id); + const found = await Tabs.highlight({ url: uiUrl }); + if (!found) { + await new Window(uiUrl).show(); + } + } throw new Error('Another permission request is pending.'); } const alreadyAllowed = await this.hasPermissions( @@ -51,44 +165,11 @@ class Permissions { connection.origin, permissionTypes, connection.originFavIcon, + requestMsgID, existingPermission ); - const permissionWindow = openPermissionWindow(pRequest.id); - const onWindowCloseStream = await permissionWindow.show(); - const responseStream = this._permissionResponses.pipe( - filter((resp) => resp.id === pRequest.id), - map((resp) => { - pRequest.allowed = resp.allowed; - pRequest.accounts = resp.accounts; - pRequest.responseDate = resp.responseDate; - return pRequest; - }), - tap(() => permissionWindow.close()) - ); - return lastValueFrom( - race( - onWindowCloseStream.pipe( - map(() => { - pRequest.allowed = false; - pRequest.accounts = []; - pRequest.responseDate = new Date().toISOString(); - return pRequest; - }) - ), - responseStream - ).pipe( - take(1), - tap(async (permission) => { - await this.storePermission(permission); - }), - map((permission) => { - if (!permission.allowed) { - throw new Error('Permission rejected'); - } - return permission; - }) - ) - ); + await new Window(Permissions.getUiUrl(pRequest.id)).show(); + return null; } public handlePermissionResponse(response: PermissionResponse) { @@ -122,7 +203,10 @@ class Permissions { permission?: Permission | null ): Promise { const existingPermission = await this.getPermission(origin, permission); - return !!existingPermission && existingPermission.responseDate === null; + return ( + !!existingPermission && + this.isPendingPermissionRequest(existingPermission) + ); } public async hasPermissions( @@ -144,17 +228,24 @@ class Permissions { origin: string, permissionTypes: readonly PermissionType[], favIcon: string | undefined, + requestMsgID: string, existingPermission?: Permission | null ): Promise { let permissionToStore: Permission; if (existingPermission) { - existingPermission.allowed = null; existingPermission.responseDate = null; - permissionTypes.forEach((aPermission) => { - if (!existingPermission.permissions.includes(aPermission)) { - existingPermission.permissions.push(aPermission); - } - }); + existingPermission.requestMsgID = requestMsgID; + if (existingPermission.allowed) { + permissionTypes.forEach((aPermission) => { + if (!existingPermission.permissions.includes(aPermission)) { + existingPermission.permissions.push(aPermission); + } + }); + } else { + existingPermission.permissions = + permissionTypes as PermissionType[]; + } + existingPermission.allowed = null; permissionToStore = existingPermission; } else { permissionToStore = { @@ -166,6 +257,7 @@ class Permissions { favIcon, permissions: permissionTypes as PermissionType[], responseDate: null, + requestMsgID, }; } await this.storePermission(permissionToStore); @@ -179,6 +271,20 @@ class Permissions { [PERMISSIONS_STORAGE_KEY]: permissions, }); } + + private async getPermissionByID(id: string) { + const permissions = await this.getPermissions(); + for (const aPermission of Object.values(permissions)) { + if (aPermission.id === id) { + return aPermission; + } + } + return null; + } + + private isPendingPermissionRequest(permissionRequest: Permission) { + return permissionRequest.responseDate === null; + } } export default new Permissions(); diff --git a/wallet/src/background/connections/ContentScriptConnection.ts b/wallet/src/background/connections/ContentScriptConnection.ts index 655c3c3d90968..1ba2fe638113b 100644 --- a/wallet/src/background/connections/ContentScriptConnection.ts +++ b/wallet/src/background/connections/ContentScriptConnection.ts @@ -20,6 +20,7 @@ import type { GetAccountResponse } from '_payloads/account/GetAccountResponse'; import type { HasPermissionsResponse, AcquirePermissionsResponse, + Permission, } from '_payloads/permissions'; import type { ExecuteTransactionResponse } from '_payloads/transactions'; import type { Runtime } from 'webextension-polyfill'; @@ -69,19 +70,14 @@ export class ContentScriptConnection extends Connection { ); } else if (isAcquirePermissionsRequest(payload)) { try { - const permission = await Permissions.acquirePermissions( + const permission = await Permissions.startRequestPermissions( payload.permissions, - this - ); - this.send( - createMessage( - { - type: 'acquire-permissions-response', - result: !!permission.allowed, - }, - msg.id - ) + this, + msg.id ); + if (permission) { + this.permissionReply(permission, msg.id); + } } catch (e) { this.sendError( { @@ -125,6 +121,33 @@ export class ContentScriptConnection extends Connection { } } + public permissionReply(permission: Permission, msgID?: string) { + if (permission.origin !== this.origin) { + return; + } + const requestMsgID = msgID || permission.requestMsgID; + if (permission.allowed) { + this.send( + createMessage( + { + type: 'acquire-permissions-response', + result: !!permission.allowed, + }, + requestMsgID + ) + ); + } else { + this.sendError( + { + error: true, + message: 'Permission rejected', + code: -1, + }, + requestMsgID + ); + } + } + private getOrigin(port: Runtime.Port) { if (port.sender?.origin) { return port.sender.origin; diff --git a/wallet/src/background/connections/index.ts b/wallet/src/background/connections/index.ts index 4bc02e4cc0b60..d63aba3a6a490 100644 --- a/wallet/src/background/connections/index.ts +++ b/wallet/src/background/connections/index.ts @@ -7,6 +7,7 @@ import { ContentScriptConnection } from './ContentScriptConnection'; import { UiConnection } from './UiConnection'; import type { Connection } from './Connection'; +import type { Permission } from '_payloads/permissions'; export class Connections { private _connections: Connection[] = []; @@ -40,4 +41,15 @@ export class Connections { } }); } + + notifyForPermissionReply(permission: Permission) { + for (const aConnection of this._connections) { + if ( + aConnection instanceof ContentScriptConnection && + aConnection.origin === permission.origin + ) { + aConnection.permissionReply(permission); + } + } + } } diff --git a/wallet/src/background/index.ts b/wallet/src/background/index.ts index 6a4d5792c62bb..572a3fcc7f3d7 100644 --- a/wallet/src/background/index.ts +++ b/wallet/src/background/index.ts @@ -3,6 +3,7 @@ import Browser from 'webextension-polyfill'; +import Permissions from './Permissions'; import { Connections } from './connections'; import { openInNewTab } from '_shared/utils'; @@ -12,4 +13,10 @@ Browser.runtime.onInstalled.addListener((details) => { } }); -new Connections(); +const connections = new Connections(); + +Permissions.permissionReply.subscribe((permission) => { + if (permission) { + connections.notifyForPermissionReply(permission); + } +}); diff --git a/wallet/src/shared/messaging/messages/payloads/permissions/Permission.ts b/wallet/src/shared/messaging/messages/payloads/permissions/Permission.ts index 4c79b9855ad26..b0c6eefe34189 100644 --- a/wallet/src/shared/messaging/messages/payloads/permissions/Permission.ts +++ b/wallet/src/shared/messaging/messages/payloads/permissions/Permission.ts @@ -13,4 +13,5 @@ export interface Permission { permissions: PermissionType[]; createdDate: string; responseDate: string | null; + requestMsgID: string; } diff --git a/wallet/src/shared/messaging/messages/payloads/permissions/PermissionType.ts b/wallet/src/shared/messaging/messages/payloads/permissions/PermissionType.ts index a9c2a40cfd968..595f8e826e34d 100644 --- a/wallet/src/shared/messaging/messages/payloads/permissions/PermissionType.ts +++ b/wallet/src/shared/messaging/messages/payloads/permissions/PermissionType.ts @@ -7,3 +7,12 @@ export const ALL_PERMISSION_TYPES = [ ] as const; type AllPermissionsType = typeof ALL_PERMISSION_TYPES; export type PermissionType = AllPermissionsType[number]; + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export function isValidPermissionTypes(types: any): types is PermissionType[] { + return ( + Array.isArray(types) && + !!types.length && + types.every((aType) => ALL_PERMISSION_TYPES.includes(aType)) + ); +}