mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 07:43:35 +00:00
Auth/pm 23620/auth request answering service (#15760)
* feat(notification-processing): [PM-19877] System Notification Implementation - Implemented auth request answering service. * test(notification-processing): [PM-19877] System Notification Implementation - Added tests.
This commit is contained in:
committed by
GitHub
parent
3b5342dfb3
commit
c828b3c4f4
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<T extends (...args: any[]) => any> = {
|
||||
addListener: (callback: T) => void;
|
||||
removeListener: (callback: T) => void;
|
||||
// test-only helper
|
||||
emit: (...args: Parameters<T>) => void;
|
||||
};
|
||||
|
||||
function createTestChromeEvent<T extends (...args: any[]) => any>(): TestChromeEvent<T> {
|
||||
const listeners = new Set<T>();
|
||||
return {
|
||||
addListener: jest.fn((cb: T) => listeners.add(cb)),
|
||||
removeListener: jest.fn((cb: T) => listeners.delete(cb)),
|
||||
emit: (...args: Parameters<T>) => listeners.forEach((cb) => cb(...args)),
|
||||
} as TestChromeEvent<T>;
|
||||
}
|
||||
|
||||
describe("BrowserSystemNotificationService", () => {
|
||||
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SystemNotificationEvent>;
|
||||
|
||||
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<string> {
|
||||
return new Promise<string>((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<true> = {
|
||||
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));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user