diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index a5001e0c5b7..e89e8144d35 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -43,6 +43,10 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; +import { + DeviceManagementApprovalService, + DevicesManagementApprovalAbstraction, +} from "@bitwarden/common/auth/services/devices/device-management-approval.service"; import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service"; @@ -110,6 +114,7 @@ import { AbstractStorageService, ObservableStorageService, } from "@bitwarden/common/platform/abstractions/storage.service"; +import { SystemNotificationServiceAbstraction } from "@bitwarden/common/platform/abstractions/system-notification-service.abstraction"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/platform/abstractions/system.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { IpcService } from "@bitwarden/common/platform/ipc"; @@ -130,6 +135,7 @@ import { WorkerWebPushConnectionService, } from "@bitwarden/common/platform/notifications/internal"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; +import { ChromeExtensionSystemNotificationService } from "@bitwarden/common/platform/services/chrome-extension-system-notification.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; @@ -331,6 +337,8 @@ export default class MainBackground { exportService: VaultExportServiceAbstraction; searchService: SearchServiceAbstraction; notificationsService: NotificationsService; + deviceManagementApprovalService: DevicesManagementApprovalAbstraction; + systemNotificationService: SystemNotificationServiceAbstraction; stateService: StateServiceAbstraction; userNotificationSettingsService: UserNotificationSettingsServiceAbstraction; autofillSettingsService: AutofillSettingsServiceAbstraction; @@ -1078,6 +1086,17 @@ export default class MainBackground { this.webPushConnectionService = new UnsupportedWebPushConnectionService(); } + this.systemNotificationService = new ChromeExtensionSystemNotificationService( + this.logService, + this.platformUtilsService, + ); + + this.deviceManagementApprovalService = new DeviceManagementApprovalService( + this.platformUtilsService, + this.logService, + this.systemNotificationService, + ); + this.notificationsService = new DefaultNotificationsService( this.logService, this.syncService, @@ -1089,6 +1108,7 @@ export default class MainBackground { new SignalRConnectionService(this.apiService, this.logService), this.authService, this.webPushConnectionService, + this.deviceManagementApprovalService, ); this.fido2UserInterfaceService = new BrowserFido2UserInterfaceService(this.authService); diff --git a/apps/browser/src/manifest.json b/apps/browser/src/manifest.json index 07aa3d2e4a9..69a62f92c6d 100644 --- a/apps/browser/src/manifest.json +++ b/apps/browser/src/manifest.json @@ -56,7 +56,8 @@ "unlimitedStorage", "webNavigation", "webRequest", - "webRequestBlocking" + "webRequestBlocking", + "notifications" ], "__safari__permissions": [ "", diff --git a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts index c9200ecc1a4..c3fa013fb9c 100644 --- a/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts +++ b/apps/browser/src/platform/services/platform-utils/browser-platform-utils.service.ts @@ -361,4 +361,8 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic return ""; } + + async openPopupToPath() { + await chrome.action.openPopup(); + } } diff --git a/apps/web/src/app/core/web-platform-utils.service.ts b/apps/web/src/app/core/web-platform-utils.service.ts index 3df2a7d895b..eb945187797 100644 --- a/apps/web/src/app/core/web-platform-utils.service.ts +++ b/apps/web/src/app/core/web-platform-utils.service.ts @@ -195,4 +195,8 @@ export class WebPlatformUtilsService implements PlatformUtilsService { getAutofillKeyboardShortcut(): Promise { return null; } + + openPopupToPath(url: string): Promise { + return Promise.resolve(undefined); + } } diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 3cce9b5357e..f416544d1d8 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -105,6 +105,10 @@ import { AccountServiceImplementation } from "@bitwarden/common/auth/services/ac import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; +import { + DeviceManagementApprovalService, + DevicesManagementApprovalAbstraction, +} from "@bitwarden/common/auth/services/devices/device-management-approval.service"; import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation"; import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation"; import { MasterPasswordApiService } from "@bitwarden/common/auth/services/master-password/master-password-api.service.implementation"; @@ -182,6 +186,7 @@ import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sd import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service"; import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service"; +import { SystemNotificationServiceAbstraction } from "@bitwarden/common/platform/abstractions/system-notification-service.abstraction"; import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging"; @@ -205,6 +210,7 @@ import { TaskSchedulerService, } from "@bitwarden/common/platform/scheduling"; import { AppIdService } from "@bitwarden/common/platform/services/app-id.service"; +import { ChromeExtensionSystemNotificationService } from "@bitwarden/common/platform/services/chrome-extension-system-notification.service"; import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service"; import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service"; import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; @@ -892,6 +898,16 @@ const safeProviders: SafeProvider[] = [ useClass: UnsupportedWebPushConnectionService, deps: [], }), + safeProvider({ + provide: SystemNotificationServiceAbstraction, + useClass: ChromeExtensionSystemNotificationService, + deps: [LogService, PlatformUtilsServiceAbstraction], + }), + safeProvider({ + provide: DevicesManagementApprovalAbstraction, + useClass: DeviceManagementApprovalService, + deps: [PlatformUtilsServiceAbstraction, LogService, SystemNotificationServiceAbstraction], + }), safeProvider({ provide: NotificationsService, useClass: devFlagEnabled("noopNotifications") @@ -908,6 +924,7 @@ const safeProviders: SafeProvider[] = [ SignalRConnectionService, AuthServiceAbstraction, WebPushConnectionService, + DevicesManagementApprovalAbstraction, ], }), safeProvider({ diff --git a/libs/common/src/auth/services/devices/device-management-approval.service.ts b/libs/common/src/auth/services/devices/device-management-approval.service.ts new file mode 100644 index 00000000000..0aaa0ffa0d8 --- /dev/null +++ b/libs/common/src/auth/services/devices/device-management-approval.service.ts @@ -0,0 +1,88 @@ +import { filter, mergeMap } from "rxjs/operators"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ButtonActions, + SystemNotificationServiceAbstraction, + SystemNotificationEvent, + ButtonLocation, +} from "@bitwarden/common/platform/abstractions/system-notification-service.abstraction"; + +export abstract class DevicesManagementApprovalAbstraction { + abstract receivedPendingAuthRequest(notificationId: string): Promise; + abstract checkForPendingAuthRequestsToApprove(notificationId: string): Promise; +} + +export class DeviceManagementApprovalService implements DevicesManagementApprovalAbstraction { + constructor( + private platformUtilsService: PlatformUtilsService, + private logService: LogService, + private systemNotificationService: SystemNotificationServiceAbstraction, + ) { + this.systemNotificationService.systemNotificationClicked$ + .pipe( + filter( + (event: SystemNotificationEvent) => event.type === ButtonActions.AuthRequestNotification, + ), + mergeMap((event: SystemNotificationEvent) => + this.handleAuthRequestNotificationClicked(event), + ), + ) + .subscribe(); + } + + async receivedPendingAuthRequest(notificationId: string): Promise { + // if popup is open, open dialog if logged in + if (await this.platformUtilsService.isViewOpen()) { + this.logService.info("Open dialog to user to approve request."); + } else { + // if not open, create a notification + + // Short circuit because we don't support notifications on this client + if (!this.systemNotificationService.isSupported()) { + return; + } + + await this.systemNotificationService.createOSNotification({ + title: "Pending Device Request", + body: "Please view pending auth request", + type: ButtonActions.AuthRequestNotification, + id: notificationId, + buttons: [{ title: "Approve Request" }, { title: "Deny Request" }], + }); + } + } + + /** + * This will be used probably in a guard to route the user + * This is a function because it could cache auth requests coming + * in the background, or it could query the server for any outstanding + * requests. + */ + async checkForPendingAuthRequestsToApprove(): Promise { + // STUB + } + + /** + * TODO: Requests are doubling, figure out if a subscription is duplicating or something. + * @param event + * @private + */ + private async handleAuthRequestNotificationClicked( + event: SystemNotificationEvent, + ): Promise { + // This is the approval event. WE WILL NOT BE USING 0 or 1! + if (event.buttonIdentifier === ButtonLocation.FirstOptionalButton) { + this.logService.info("Approve the request"); + } else if (event.buttonIdentifier === ButtonLocation.SecondOptionalButton) { + // This is the deny event. + this.logService.info("Deny the request"); + } else if (event.buttonIdentifier === ButtonLocation.NotificationButton) { + this.logService.info("Main button clicked, open popup"); + // This is unstable, to be figured out later. + await this.platformUtilsService.openPopupToPath("/device-management"); + this.systemNotificationService.clearOSNotification({ id: event.id }); + } + } +} diff --git a/libs/common/src/platform/abstractions/platform-utils.service.ts b/libs/common/src/platform/abstractions/platform-utils.service.ts index fa0fc8f2501..77cce6a7601 100644 --- a/libs/common/src/platform/abstractions/platform-utils.service.ts +++ b/libs/common/src/platform/abstractions/platform-utils.service.ts @@ -22,6 +22,10 @@ export abstract class PlatformUtilsService { abstract isVivaldi(): boolean; abstract isSafari(): boolean; abstract isMacAppStore(): boolean; + + /** + * Can only be called from the background service. + */ abstract isViewOpen(): Promise; abstract launchUri(uri: string, options?: any): void; abstract getApplicationVersion(): Promise; @@ -45,4 +49,5 @@ export abstract class PlatformUtilsService { abstract readFromClipboard(): Promise; abstract supportsSecureStorage(): boolean; abstract getAutofillKeyboardShortcut(): Promise; + abstract openPopupToPath(url: string): Promise; } diff --git a/libs/common/src/platform/abstractions/system-notification-service.abstraction.ts b/libs/common/src/platform/abstractions/system-notification-service.abstraction.ts new file mode 100644 index 00000000000..deb811930e2 --- /dev/null +++ b/libs/common/src/platform/abstractions/system-notification-service.abstraction.ts @@ -0,0 +1,51 @@ +import { Observable } from "rxjs"; + +export const ButtonActions = { + AuthRequestNotification: "authRequestNotification", +}; + +export type ButtonActionsKeys = (typeof ButtonActions)[keyof typeof ButtonActions]; + +// This is currently tailored for chrome extension's api, if safari works +// differently where clicking a notification button produces a different +// identifier we need to reconcile that here. +export const ButtonLocation = { + FirstOptionalButton: 0, // this is the first optional button we can set + SecondOptionalButton: 1, // this is the second optional button we can set + NotificationButton: 2, // this is when you click the notification as a whole +}; + +export type ButtonLocationKeys = (typeof ButtonLocation)[keyof typeof ButtonLocation]; + +export type SystemNotificationsButton = { + title: string; +}; + +export type SystemNotificationCreateInfo = { + id: string; + type: ButtonActionsKeys; + title: string; + body: string; + buttons: SystemNotificationsButton[]; +}; + +export type SystemNotificationClearInfo = { + id: string; +}; + +export type SystemNotificationEvent = { + id: string; + type: string; + buttonIdentifier: number; +}; + +export abstract class SystemNotificationServiceAbstraction { + abstract systemNotificationClicked$: Observable; + abstract createOSNotification(createInfo: SystemNotificationCreateInfo): Promise; + abstract clearOSNotification(clearInfo: SystemNotificationClearInfo): undefined; + + /** + * Used to know if a given platform supports notifications. + */ + abstract isSupported(): boolean; +} diff --git a/libs/common/src/platform/notifications/internal/default-notifications.service.ts b/libs/common/src/platform/notifications/internal/default-notifications.service.ts index 423b3370455..00334a74ad6 100644 --- a/libs/common/src/platform/notifications/internal/default-notifications.service.ts +++ b/libs/common/src/platform/notifications/internal/default-notifications.service.ts @@ -12,6 +12,7 @@ import { } from "rxjs"; import { LogoutReason } from "@bitwarden/auth/common"; +import { DevicesManagementApprovalAbstraction } from "@bitwarden/common/auth/services/devices/device-management-approval.service"; import { AccountService } from "../../../auth/abstractions/account.service"; import { AuthService } from "../../../auth/abstractions/auth.service"; @@ -41,6 +42,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract notifications$: Observable; private activitySubject = new BehaviorSubject<"active" | "inactive">("active"); + private destroy$ = new BehaviorSubject(undefined); constructor( private readonly logService: LogService, @@ -53,6 +55,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract private readonly signalRConnectionService: SignalRConnectionService, private readonly authService: AuthService, private readonly webPushConnectionService: WebPushConnectionService, + private deviceManagementApprovalService: DevicesManagementApprovalAbstraction, ) { this.notifications$ = this.accountService.activeAccount$.pipe( map((account) => account?.id), @@ -211,6 +214,9 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract this.messagingService.send("openLoginApproval", { notificationId: notification.payload.id, }); + await this.deviceManagementApprovalService.receivedPendingAuthRequest( + notification.payload.id, + ); } break; case NotificationType.SyncOrganizationStatusChanged: diff --git a/libs/common/src/platform/services/chrome-extension-system-notification.service.ts b/libs/common/src/platform/services/chrome-extension-system-notification.service.ts new file mode 100644 index 00000000000..0d2c9ad9c9a --- /dev/null +++ b/libs/common/src/platform/services/chrome-extension-system-notification.service.ts @@ -0,0 +1,67 @@ +import { Observable, Subject } from "rxjs"; + +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ButtonLocation, + SystemNotificationClearInfo, + SystemNotificationCreateInfo, + SystemNotificationEvent, + SystemNotificationServiceAbstraction as SystemNotificationServiceAbstraction, +} from "@bitwarden/common/platform/abstractions/system-notification-service.abstraction"; + +export class ChromeExtensionSystemNotificationService + implements SystemNotificationServiceAbstraction +{ + private systemNotificationClickedSubject = new Subject(); + systemNotificationClicked$: Observable; + + constructor( + private logService: LogService, + private platformUtilsService: PlatformUtilsService, + ) { + this.systemNotificationClicked$ = this.systemNotificationClickedSubject.asObservable(); + } + + async createOSNotification(createInfo: SystemNotificationCreateInfo): Promise { + chrome.notifications.create(createInfo.id, { + iconUrl: "https://avatars.githubusercontent.com/u/15990069?s=200", + message: createInfo.title, + type: "basic", + title: createInfo.title, + buttons: createInfo.buttons.map((value) => { + return { title: value.title }; + }), + }); + + // ESLint: Using addListener in the browser popup produces a memory leak in Safari, use `BrowserApi. addListener` instead (no-restricted-syntax) + // eslint-disable-next-line no-restricted-syntax + chrome.notifications.onButtonClicked.addListener( + (notificationId: string, buttonIndex: number) => { + this.systemNotificationClickedSubject.next({ + id: notificationId, + type: createInfo.type, + buttonIdentifier: buttonIndex, + }); + }, + ); + + // eslint-disable-next-line no-restricted-syntax + chrome.notifications.onClicked.addListener((notificationId: string) => { + this.systemNotificationClickedSubject.next({ + id: notificationId, + type: createInfo.type, + buttonIdentifier: ButtonLocation.NotificationButton, + }); + }); + return; + } + + clearOSNotification(clearInfo: SystemNotificationClearInfo): undefined { + chrome.notifications.clear(clearInfo.id); + } + + isSupported(): boolean { + return true; + } +}