diff --git a/apps/browser/package.json b/apps/browser/package.json index 7f38125472..3cfc437722 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -14,6 +14,8 @@ "build:watch:firefox": "npm run build:firefox -- --watch", "build:watch:opera": "npm run build:opera -- --watch", "build:watch:safari": "npm run build:safari -- --watch", + "build:watch:firefox:mv3": "cross-env MANIFEST_VERSION=3 npm run build:firefox -- --watch", + "build:watch:safari:mv3": "cross-env MANIFEST_VERSION=3 npm run build:safari -- --watch", "build:prod:chrome": "cross-env NODE_ENV=production npm run build:chrome", "build:prod:edge": "cross-env NODE_ENV=production npm run build:edge", "build:prod:firefox": "cross-env NODE_ENV=production npm run build:firefox", diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 00e8300401..bb2483daf3 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5107,6 +5107,18 @@ "showNumberOfAutofillSuggestions": { "message": "Show number of login autofill suggestions on extension icon" }, + "accountAccessRequested": { + "message": "Account access requested" + }, + "confirmAccessAttempt": { + "message": "Confirm access attempt for $EMAIL$", + "placeholders": { + "email": { + "content": "$1", + "example": "name@example.com" + } + } + }, "showQuickCopyActions": { "message": "Show quick copy actions on Vault" }, diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 096bbe76e4..df29502ede 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -2,7 +2,7 @@ // @ts-strict-ignore import "core-js/proposals/explicit-resource-management"; -import { filter, firstValueFrom, map, merge, Subject, timeout } from "rxjs"; +import { filter, firstValueFrom, map, merge, Subject, switchMap, timeout } from "rxjs"; import { CollectionService, DefaultCollectionService } from "@bitwarden/admin-console/common"; import { @@ -29,6 +29,7 @@ import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/p import { PolicyApiService } from "@bitwarden/common/admin-console/services/policy/policy-api.service"; import { ProviderService } from "@bitwarden/common/admin-console/services/provider.service"; import { AccountService as AccountServiceAbstraction } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; @@ -37,8 +38,10 @@ import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/ import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service"; import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction"; import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction"; +import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor"; @@ -345,6 +348,7 @@ export default class MainBackground { serverNotificationsService: ServerNotificationsService; systemNotificationService: SystemNotificationsService; actionsService: ActionsService; + authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction; stateService: StateServiceAbstraction; userNotificationSettingsService: UserNotificationSettingsServiceAbstraction; autofillSettingsService: AutofillSettingsServiceAbstraction; @@ -1107,13 +1111,22 @@ export default class MainBackground { if ("notifications" in chrome) { this.systemNotificationService = new BrowserSystemNotificationService( - this.logService, this.platformUtilsService, ); } else { this.systemNotificationService = new UnsupportedSystemNotificationsService(); } + this.authRequestAnsweringService = new AuthRequestAnsweringService( + this.accountService, + this.actionsService, + this.authService, + this.i18nService, + this.masterPasswordService, + this.platformUtilsService, + this.systemNotificationService, + ); + this.serverNotificationsService = new DefaultServerNotificationsService( this.logService, this.syncService, @@ -1125,6 +1138,7 @@ export default class MainBackground { new SignalRConnectionService(this.apiService, this.logService), this.authService, this.webPushConnectionService, + this.authRequestAnsweringService, this.configService, ); @@ -1376,6 +1390,10 @@ export default class MainBackground { if (this.webPushConnectionService instanceof WorkerWebPushConnectionService) { this.webPushConnectionService.start(); } + + // Putting this here so that all other services are initialized prior to trying to hook up + // subscriptions to the notification chrome events. + this.initNotificationSubscriptions(); } async bootstrap() { @@ -1761,6 +1779,23 @@ export default class MainBackground { } } + /** + * This function is for creating any subscriptions for the background service worker. We do this + * here because it's important to run this during the evaluation period of the browser extension + * service worker. + */ + initNotificationSubscriptions() { + this.systemNotificationService.notificationClicked$ + .pipe( + filter((n) => n.id.startsWith(AuthServerNotificationTags.AuthRequest + "_")), + map((n) => ({ event: n, authRequestId: n.id.split("_")[1] })), + switchMap(({ event }) => + this.authRequestAnsweringService.handleAuthRequestNotificationClicked(event), + ), + ) + .subscribe(); + } + /** * Temporary solution to handle initialization of the overlay background behind a feature flag. * Will be reverted to instantiation within the constructor once the feature flag is removed. diff --git a/apps/browser/src/platform/actions/browser-actions.service.ts b/apps/browser/src/platform/actions/browser-actions.service.ts index 112a76cbe3..9d28240622 100644 --- a/apps/browser/src/platform/actions/browser-actions.service.ts +++ b/apps/browser/src/platform/actions/browser-actions.service.ts @@ -26,7 +26,7 @@ export class BrowserActionsService implements ActionsService { return; } else { this.logService.warning( - `No openPopup function found on browser actions. On browser: ${deviceType} and manifest version: ${BrowserApi.manifestVersion}`, + `No openPopup function found on browser actions. On browser: ${DeviceType[deviceType]} and manifest version: ${BrowserApi.manifestVersion}`, ); } break; @@ -36,7 +36,7 @@ export class BrowserActionsService implements ActionsService { return; default: this.logService.warning( - `Tried to open the popup from an unsupported device type: ${deviceType}`, + `Tried to open the popup from an unsupported device type: ${DeviceType[deviceType]}`, ); } } catch (e) { diff --git a/apps/browser/src/platform/system-notifications/browser-system-notification.service.spec.ts b/apps/browser/src/platform/system-notifications/browser-system-notification.service.spec.ts new file mode 100644 index 0000000000..a54b288aaf --- /dev/null +++ b/apps/browser/src/platform/system-notifications/browser-system-notification.service.spec.ts @@ -0,0 +1,196 @@ +import { DeviceType } from "@bitwarden/common/enums"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { + ButtonLocation, + SystemNotificationCreateInfo, +} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; + +import { BrowserSystemNotificationService } from "./browser-system-notification.service"; + +type TestChromeEvent any> = { + addListener: (callback: T) => void; + removeListener: (callback: T) => void; + // test-only helper + emit: (...args: Parameters) => void; +}; + +function createTestChromeEvent any>(): TestChromeEvent { + const listeners = new Set(); + return { + addListener: jest.fn((cb: T) => listeners.add(cb)), + removeListener: jest.fn((cb: T) => listeners.delete(cb)), + emit: (...args: Parameters) => listeners.forEach((cb) => cb(...args)), + } as TestChromeEvent; +} + +describe("BrowserSystemNotificationService", () => { + let platformUtilsService: jest.Mocked; + let service: BrowserSystemNotificationService; + + let onButtonClicked: TestChromeEvent<(notificationId: string, buttonIndex: number) => void>; + let onClicked: TestChromeEvent<(notificationId: string) => void>; + + beforeEach(() => { + onButtonClicked = createTestChromeEvent(); + onClicked = createTestChromeEvent(); + + (global as any).chrome.notifications = { + onButtonClicked, + onClicked, + create: jest.fn((idOrOptions: any, optionsOrCallback: any, callback?: any) => { + if (typeof idOrOptions === "string") { + const cb = callback as (id: string) => void; + if (cb) { + cb(idOrOptions); + } + return; + } + const cb = optionsOrCallback as (id: string) => void; + if (cb) { + cb("generated-id"); + } + }), + clear: jest.fn(), + } as any; + + platformUtilsService = { + getDevice: jest.fn().mockReturnValue(DeviceType.ChromeExtension), + } as any; + + service = new BrowserSystemNotificationService(platformUtilsService); + }); + + describe("isSupported", () => { + it("returns true when chrome.notifications exists", () => { + expect(service.isSupported()).toBe(true); + }); + + it("returns false when chrome.notifications is missing", () => { + const original = (global as any).chrome.notifications; + delete (global as any).chrome.notifications; + expect(service.isSupported()).toBe(false); + (global as any).chrome.notifications = original; + }); + }); + + describe("create", () => { + it("passes id and options with buttons on non-Firefox", async () => { + const createInfo = { + id: "notif-1", + title: "Test Title", + body: "Body", + buttons: [{ title: "A" }, { title: "B" }], + }; + + let capturedId: string | undefined; + let capturedOptions: any; + (chrome.notifications.create as jest.Mock).mockImplementationOnce( + (id: string, options: any, cb: (id: string) => void) => { + capturedId = id; + capturedOptions = options; + cb(id); + }, + ); + + const id = await service.create(createInfo); + + expect(id).toBe("notif-1"); + expect(capturedId).toBe("notif-1"); + expect(capturedOptions.title).toBe("Test Title"); + expect(capturedOptions.message).toBe("Body"); + expect(capturedOptions.type).toBe("basic"); + expect(capturedOptions.iconUrl).toContain("images/icon128.png"); + expect(capturedOptions.buttons).toEqual([{ title: "A" }, { title: "B" }]); + }); + + it("omits buttons on Firefox", async () => { + platformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); + + const createInfo = { + id: "notif-2", + title: "Title", + body: "Body", + buttons: [{ title: "X" }], + }; + + let capturedOptions: any; + (chrome.notifications.create as jest.Mock).mockImplementationOnce( + (_id: string, options: any, cb: (id: string) => void) => { + capturedOptions = options; + cb(_id); + }, + ); + + await service.create(createInfo); + + expect("buttons" in capturedOptions).toBe(false); + expect(capturedOptions.title).toBe("Title"); + expect(capturedOptions.message).toBe("Body"); + }); + + it("supports creating without an id", async () => { + const createInfo: SystemNotificationCreateInfo = { + title: "No Id", + body: "Body", + buttons: [], + }; + + let calledWithOptionsOnly = false; + (chrome.notifications.create as jest.Mock).mockImplementationOnce( + (options: any, cb: (id: string) => void) => { + calledWithOptionsOnly = typeof options === "object" && cb != null; + cb("generated-id"); + }, + ); + + const id = await service.create(createInfo); + expect(id).toBe("generated-id"); + expect(calledWithOptionsOnly).toBe(true); + }); + }); + + describe("clear", () => { + it("invokes chrome.notifications.clear with the id", async () => { + await service.clear({ id: "to-clear" }); + expect(chrome.notifications.clear).toHaveBeenCalledWith("to-clear"); + }); + }); + + describe("notificationClicked$", () => { + it("emits when a button is clicked", (done) => { + const expectEvent = { + id: "nid-1", + buttonIdentifier: 1, + }; + + service.notificationClicked$.subscribe((evt) => { + try { + expect(evt).toEqual(expectEvent); + done(); + } catch (e) { + done(e); + } + }); + + onButtonClicked.emit("nid-1", 1); + }); + + it("emits when the notification itself is clicked", (done) => { + const expectEvent = { + id: "nid-2", + buttonIdentifier: ButtonLocation.NotificationButton, + }; + + service.notificationClicked$.subscribe((evt) => { + try { + expect(evt).toEqual(expectEvent); + done(); + } catch (e) { + done(e); + } + }); + + onClicked.emit("nid-2"); + }); + }); +}); diff --git a/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts b/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts index 26b49515b8..e0b2716a19 100644 --- a/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts +++ b/apps/browser/src/platform/system-notifications/browser-system-notification.service.ts @@ -1,6 +1,6 @@ import { map, merge, Observable } from "rxjs"; -import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { DeviceType } from "@bitwarden/common/enums"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { ButtonLocation, @@ -15,10 +15,7 @@ import { fromChromeEvent } from "../browser/from-chrome-event"; export class BrowserSystemNotificationService implements SystemNotificationsService { notificationClicked$: Observable; - constructor( - private logService: LogService, - private platformUtilsService: PlatformUtilsService, - ) { + constructor(private readonly platformUtilsService: PlatformUtilsService) { this.notificationClicked$ = merge( fromChromeEvent(chrome.notifications.onButtonClicked).pipe( map(([notificationId, buttonIndex]) => ({ @@ -37,16 +34,28 @@ export class BrowserSystemNotificationService implements SystemNotificationsServ async create(createInfo: SystemNotificationCreateInfo): Promise { return new Promise((resolve) => { - chrome.notifications.create( - { - iconUrl: chrome.runtime.getURL("images/icon128.png"), - message: createInfo.body, - type: "basic", - title: createInfo.title, - buttons: createInfo.buttons.map((value) => ({ title: value.title })), - }, - (notificationId) => resolve(notificationId), - ); + const deviceType: DeviceType = this.platformUtilsService.getDevice(); + + const options: chrome.notifications.NotificationOptions = { + iconUrl: chrome.runtime.getURL("images/icon128.png"), + message: createInfo.body, + type: "basic", + title: createInfo.title, + buttons: createInfo.buttons.map((value) => ({ title: value.title })), + }; + + // Firefox notification api does not support buttons. + if (deviceType === DeviceType.FirefoxExtension) { + delete options.buttons; + } + + if (createInfo.id != null) { + chrome.notifications.create(createInfo.id, options, (notificationId) => + resolve(notificationId), + ); + } else { + chrome.notifications.create(options, (notificationId) => resolve(notificationId)); + } }); } diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index aaf85389f9..1372059d86 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -556,6 +556,16 @@ const safeProviders: SafeProvider[] = [ PlatformUtilsService, ], }), + safeProvider({ + provide: ActionsService, + useClass: BrowserActionsService, + deps: [LogService, PlatformUtilsService], + }), + safeProvider({ + provide: SystemNotificationsService, + useClass: BrowserSystemNotificationService, + deps: [PlatformUtilsService], + }), safeProvider({ provide: Fido2UserVerificationService, useClass: Fido2UserVerificationService, @@ -588,7 +598,7 @@ const safeProviders: SafeProvider[] = [ safeProvider({ provide: SystemNotificationsService, useClass: BrowserSystemNotificationService, - deps: [LogService, PlatformUtilsService], + deps: [PlatformUtilsService], }), safeProvider({ provide: LoginComponentService, diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index f62407eeaf..c6f8d4a3ae 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -87,6 +87,7 @@ import { InternalAccountService, } from "@bitwarden/common/auth/abstractions/account.service"; import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/auth/abstractions/anonymous-hub.service"; +import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service"; import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service"; import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction"; @@ -104,6 +105,7 @@ import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstract import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service"; import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service"; import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service"; +import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service"; import { AuthService } from "@bitwarden/common/auth/services/auth.service"; import { AvatarService } from "@bitwarden/common/auth/services/avatar.service"; import { DefaultActiveUserAccessor } from "@bitwarden/common/auth/services/default-active-user.accessor"; @@ -233,6 +235,8 @@ import { ValidationService } from "@bitwarden/common/platform/services/validatio import { SyncService } from "@bitwarden/common/platform/sync"; // eslint-disable-next-line no-restricted-imports -- Needed for DI import { DefaultSyncService } from "@bitwarden/common/platform/sync/internal"; +import { SystemNotificationsService } from "@bitwarden/common/platform/system-notifications"; +import { UnsupportedSystemNotificationsService } from "@bitwarden/common/platform/system-notifications/unsupported-system-notifications.service"; import { DefaultThemeStateService, ThemeStateService, @@ -927,6 +931,29 @@ const safeProviders: SafeProvider[] = [ useClass: UnsupportedActionsService, deps: [], }), + safeProvider({ + provide: ActionsService, + useClass: UnsupportedActionsService, + deps: [], + }), + safeProvider({ + provide: SystemNotificationsService, + useClass: UnsupportedSystemNotificationsService, + deps: [], + }), + safeProvider({ + provide: AuthRequestAnsweringServiceAbstraction, + useClass: AuthRequestAnsweringService, + deps: [ + AccountServiceAbstraction, + ActionsService, + AuthServiceAbstraction, + I18nServiceAbstraction, + MasterPasswordServiceAbstraction, + PlatformUtilsServiceAbstraction, + SystemNotificationsService, + ], + }), safeProvider({ provide: ServerNotificationsService, useClass: devFlagEnabled("noopNotifications") @@ -943,6 +970,7 @@ const safeProviders: SafeProvider[] = [ SignalRConnectionService, AuthServiceAbstraction, WebPushConnectionService, + AuthRequestAnsweringServiceAbstraction, ConfigService, ], }), diff --git a/libs/common/src/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction.ts b/libs/common/src/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction.ts new file mode 100644 index 0000000000..8331ff88d3 --- /dev/null +++ b/libs/common/src/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction.ts @@ -0,0 +1,8 @@ +import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { UserId } from "@bitwarden/user-core"; + +export abstract class AuthRequestAnsweringServiceAbstraction { + abstract receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise; + + abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise; +} diff --git a/libs/common/src/auth/enums/auth-server-notification-tags.ts b/libs/common/src/auth/enums/auth-server-notification-tags.ts new file mode 100644 index 0000000000..230d0d9d9d --- /dev/null +++ b/libs/common/src/auth/enums/auth-server-notification-tags.ts @@ -0,0 +1,3 @@ +export const AuthServerNotificationTags = Object.freeze({ + AuthRequest: "authRequest", +}); diff --git a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts new file mode 100644 index 0000000000..b0ed63d7cf --- /dev/null +++ b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.spec.ts @@ -0,0 +1,131 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ActionsService } from "@bitwarden/common/platform/actions"; +import { + ButtonLocation, + SystemNotificationEvent, + SystemNotificationsService, +} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { UserId } from "@bitwarden/user-core"; + +import { AuthRequestAnsweringService } from "./auth-request-answering.service"; + +describe("AuthRequestAnsweringService", () => { + let accountService: MockProxy; + let actionService: MockProxy; + let authService: MockProxy; + let i18nService: MockProxy; + let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$ + let platformUtilsService: MockProxy; + let systemNotificationsService: MockProxy; + + let sut: AuthRequestAnsweringService; + + const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId; + + beforeEach(() => { + accountService = mock(); + actionService = mock(); + authService = mock(); + i18nService = mock(); + masterPasswordService = { forceSetPasswordReason$: jest.fn() }; + platformUtilsService = mock(); + systemNotificationsService = mock(); + + // Common defaults + authService.activeAccountStatus$ = of(AuthenticationStatus.Locked); + accountService.activeAccount$ = of({ + id: userId, + email: "user@example.com", + emailVerified: true, + name: "User", + }); + accountService.accounts$ = of({ + [userId]: { email: "user@example.com", emailVerified: true, name: "User" }, + }); + (masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue( + of(ForceSetPasswordReason.None), + ); + platformUtilsService.isPopupOpen.mockResolvedValue(false); + i18nService.t.mockImplementation( + (key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`, + ); + systemNotificationsService.create.mockResolvedValue("notif-id"); + + sut = new AuthRequestAnsweringService( + accountService, + actionService, + authService, + i18nService, + masterPasswordService, + platformUtilsService, + systemNotificationsService, + ); + }); + + describe("handleAuthRequestNotificationClicked", () => { + it("clears notification and opens popup when notification body is clicked", async () => { + const event: SystemNotificationEvent = { + id: "123", + buttonIdentifier: ButtonLocation.NotificationButton, + }; + + await sut.handleAuthRequestNotificationClicked(event); + + expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" }); + expect(actionService.openPopup).toHaveBeenCalledTimes(1); + }); + + it("does nothing when an optional button is clicked", async () => { + const event: SystemNotificationEvent = { + id: "123", + buttonIdentifier: ButtonLocation.FirstOptionalButton, + }; + + await sut.handleAuthRequestNotificationClicked(event); + + expect(systemNotificationsService.clear).not.toHaveBeenCalled(); + expect(actionService.openPopup).not.toHaveBeenCalled(); + }); + }); + + describe("receivedPendingAuthRequest", () => { + const authRequestId = "req-abc"; + + it("creates a system notification when popup is not open", async () => { + platformUtilsService.isPopupOpen.mockResolvedValue(false); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + + await sut.receivedPendingAuthRequest(userId, authRequestId); + + expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested"); + expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com"); + expect(systemNotificationsService.create).toHaveBeenCalledWith({ + id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, + title: "accountAccessRequested", + body: "confirmAccessAttempt:user@example.com", + buttons: [], + }); + }); + + it("does not create a notification when popup is open, user is active, unlocked, and no force set password", async () => { + platformUtilsService.isPopupOpen.mockResolvedValue(true); + authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked); + (masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue( + of(ForceSetPasswordReason.None), + ); + + await sut.receivedPendingAuthRequest(userId, authRequestId); + + expect(systemNotificationsService.create).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.ts b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.ts new file mode 100644 index 0000000000..a562125d1f --- /dev/null +++ b/libs/common/src/auth/services/auth-request-answering/auth-request-answering.service.ts @@ -0,0 +1,70 @@ +import { firstValueFrom } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; +import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { ActionsService } from "@bitwarden/common/platform/actions"; +import { + ButtonLocation, + SystemNotificationEvent, + SystemNotificationsService, +} from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { UserId } from "@bitwarden/user-core"; + +import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; + +export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction { + constructor( + private readonly accountService: AccountService, + private readonly actionService: ActionsService, + private readonly authService: AuthService, + private readonly i18nService: I18nService, + private readonly masterPasswordService: MasterPasswordServiceAbstraction, + private readonly platformUtilsService: PlatformUtilsService, + private readonly systemNotificationsService: SystemNotificationsService, + ) {} + + async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise { + if (event.buttonIdentifier === ButtonLocation.NotificationButton) { + await this.systemNotificationsService.clear({ + id: `${event.id}`, + }); + await this.actionService.openPopup(); + } + } + + async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise { + const authStatus = await firstValueFrom(this.authService.activeAccountStatus$); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const forceSetPasswordReason = await firstValueFrom( + this.masterPasswordService.forceSetPasswordReason$(userId), + ); + + // Is the popup already open? + if ( + (await this.platformUtilsService.isPopupOpen()) && + authStatus === AuthenticationStatus.Unlocked && + activeUserId === userId && + forceSetPasswordReason === ForceSetPasswordReason.None + ) { + // TODO: Handled in 14934 + } else { + // Get the user's email to include in the system notification + const accounts = await firstValueFrom(this.accountService.accounts$); + const emailForUser = accounts[userId].email; + + await this.systemNotificationsService.create({ + id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, + title: this.i18nService.t("accountAccessRequested"), + body: this.i18nService.t("confirmAccessAttempt", emailForUser), + buttons: [], + }); + } + } +} diff --git a/libs/common/src/auth/services/auth-request-answering/unsupported-auth-request-answering.service.ts b/libs/common/src/auth/services/auth-request-answering/unsupported-auth-request-answering.service.ts new file mode 100644 index 0000000000..c4f503bd39 --- /dev/null +++ b/libs/common/src/auth/services/auth-request-answering/unsupported-auth-request-answering.service.ts @@ -0,0 +1,17 @@ +import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service"; +import { UserId } from "@bitwarden/user-core"; + +import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction"; + +export class UnsupportedAuthRequestAnsweringService + implements AuthRequestAnsweringServiceAbstraction +{ + constructor() {} + async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise { + throw new Error("Received pending auth request not supported."); + } + + async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise { + throw new Error("Received pending auth request not supported."); + } +} diff --git a/libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts b/libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts index 9f42328d57..2d12027e19 100644 --- a/libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts +++ b/libs/common/src/platform/server-notifications/internal/default-notifications.service.spec.ts @@ -1,9 +1,11 @@ import { mock, MockProxy } from "jest-mock-extended"; -import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subject } from "rxjs"; +import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, Subject } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; +import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { awaitAsync } from "../../../../spec"; import { Matrix } from "../../../../spec/matrix"; @@ -39,6 +41,7 @@ describe("NotificationsService", () => { let signalRNotificationConnectionService: MockProxy; let authService: MockProxy; let webPushNotificationConnectionService: MockProxy; + let authRequestAnsweringService: MockProxy; let configService: MockProxy; let activeAccount: BehaviorSubject>; @@ -66,9 +69,16 @@ describe("NotificationsService", () => { signalRNotificationConnectionService = mock(); authService = mock(); webPushNotificationConnectionService = mock(); + authRequestAnsweringService = mock(); configService = mock(); - configService.getFeatureFlag$.mockReturnValue(of(true)); + // For these tests, use the active-user implementation (feature flag disabled) + configService.getFeatureFlag$.mockImplementation((flag: FeatureFlag) => { + const flagValueByFlag: Partial> = { + [FeatureFlag.PushNotificationsWhenLocked]: true, + }; + return new BehaviorSubject(flagValueByFlag[flag] ?? false) as any; + }); activeAccount = new BehaviorSubject>(null); accountService.activeAccount$ = activeAccount.asObservable(); @@ -109,6 +119,7 @@ describe("NotificationsService", () => { signalRNotificationConnectionService, authService, webPushNotificationConnectionService, + authRequestAnsweringService, configService, ); }); @@ -116,7 +127,7 @@ describe("NotificationsService", () => { const mockUser1 = "user1" as UserId; const mockUser2 = "user2" as UserId; - function emitActiveUser(userId: UserId) { + function emitActiveUser(userId: UserId | null) { if (userId == null) { activeAccount.next(null); } else { diff --git a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts index d21074f5bb..89e88d645c 100644 --- a/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts +++ b/libs/common/src/platform/server-notifications/internal/default-server-notifications.service.ts @@ -4,6 +4,7 @@ import { distinctUntilChanged, EMPTY, filter, + firstValueFrom, map, mergeMap, Observable, @@ -14,6 +15,7 @@ import { // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { LogoutReason } from "@bitwarden/auth/common"; +import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { AccountService } from "../../../auth/abstractions/account.service"; @@ -57,6 +59,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer private readonly signalRConnectionService: SignalRConnectionService, private readonly authService: AuthService, private readonly webPushConnectionService: WebPushConnectionService, + private readonly authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction, private readonly configService: ConfigService, ) { this.notifications$ = this.accountService.activeAccount$.pipe( @@ -227,8 +230,16 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification); break; case NotificationType.AuthRequest: - // create notification - + if ( + await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval), + ) + ) { + await this.authRequestAnsweringService.receivedPendingAuthRequest( + notification.payload.userId, + notification.payload.id, + ); + } this.messagingService.send("openLoginApproval", { notificationId: notification.payload.id, }); diff --git a/libs/common/src/platform/system-notifications/system-notifications.service.ts b/libs/common/src/platform/system-notifications/system-notifications.service.ts index 5436923196..f36e7da96a 100644 --- a/libs/common/src/platform/system-notifications/system-notifications.service.ts +++ b/libs/common/src/platform/system-notifications/system-notifications.service.ts @@ -1,6 +1,6 @@ import { Observable } from "rxjs"; -// This is currently tailored for chrome extension's api, if safari works +// 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 = Object.freeze({