mirror of
https://github.com/bitwarden/browser
synced 2026-02-18 18:33:50 +00:00
feat(auth-request-answering): [Auth / PM-26209] Use AuthRequestAnsweringService on Desktop (#16906)
Update Desktop to use the AuthRequestAnsweringService, bringing it into feature parity with the Extension.
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
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 { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { ExtensionAuthRequestAnsweringService } from "./extension-auth-request-answering.service";
|
||||
|
||||
describe("ExtensionAuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
let actionService: MockProxy<ActionsService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let systemNotificationsService: MockProxy<SystemNotificationsService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
const userAccountInfo = mockAccountInfoWith({
|
||||
name: "User",
|
||||
email: "user@example.com",
|
||||
});
|
||||
const userAccount: Account = {
|
||||
id: userId,
|
||||
...userAccountInfo,
|
||||
};
|
||||
|
||||
const authRequestId = "auth-request-id-123";
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
authService = mock<AuthService>();
|
||||
masterPasswordService = {
|
||||
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
|
||||
};
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
actionService = mock<ActionsService>();
|
||||
i18nService = mock<I18nService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
systemNotificationsService = mock<SystemNotificationsService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
accountService.activeAccount$ = of(userAccount);
|
||||
accountService.accounts$ = of({
|
||||
[userId]: userAccountInfo,
|
||||
});
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
i18nService.t.mockImplementation(
|
||||
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
|
||||
);
|
||||
systemNotificationsService.create.mockResolvedValue("notif-id");
|
||||
|
||||
sut = new ExtensionAuthRequestAnsweringService(
|
||||
accountService,
|
||||
authService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
actionService,
|
||||
i18nService,
|
||||
platformUtilsService,
|
||||
systemNotificationsService,
|
||||
logService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("receivedPendingAuthRequest()", () => {
|
||||
it("should throw if authRequestUserId not given", async () => {
|
||||
// Act
|
||||
const promise = sut.receivedPendingAuthRequest(undefined, authRequestId);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("authRequestUserId required");
|
||||
});
|
||||
|
||||
it("should throw if authRequestId not given", async () => {
|
||||
// Act
|
||||
const promise = sut.receivedPendingAuthRequest(userId, undefined);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("authRequestId required");
|
||||
});
|
||||
|
||||
it("should add a pending marker for the user to state", async () => {
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(pendingAuthRequestsState.add).toHaveBeenCalledTimes(1);
|
||||
expect(pendingAuthRequestsState.add).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
describe("given the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", () => {
|
||||
describe("given the popup is open", () => {
|
||||
it("should send an 'openLoginApproval' message", async () => {
|
||||
// Arrange
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(1);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
|
||||
notificationId: authRequestId,
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create a system notification", async () => {
|
||||
// Arrange
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(systemNotificationsService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the popup is closed", () => {
|
||||
it("should not send an 'openLoginApproval' message", async () => {
|
||||
// Arrange
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create a system notification", async () => {
|
||||
// Arrange
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeUserMeetsConditionsToShowApprovalDialog()", () => {
|
||||
describe("given the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", () => {
|
||||
it("should return true if popup is open", async () => {
|
||||
// Arrange
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if popup is closed", async () => {
|
||||
// Arrange
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAuthRequestNotificationClicked()", () => {
|
||||
it("should clear notification and open popup when notification body is clicked", async () => {
|
||||
// Arrange
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.NotificationButton,
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
// Assert
|
||||
expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" });
|
||||
expect(actionService.openPopup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should do nothing when an optional notification button is clicked", async () => {
|
||||
// Arrange
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.FirstOptionalButton,
|
||||
};
|
||||
|
||||
// Act
|
||||
await sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
// Assert
|
||||
expect(systemNotificationsService.clear).not.toHaveBeenCalled();
|
||||
expect(actionService.openPopup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
|
||||
import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export class ExtensionAuthRequestAnsweringService
|
||||
extends DefaultAuthRequestAnsweringService
|
||||
implements AuthRequestAnsweringService
|
||||
{
|
||||
constructor(
|
||||
protected readonly accountService: AccountService,
|
||||
protected readonly authService: AuthService,
|
||||
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
protected readonly messagingService: MessagingService,
|
||||
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
private readonly actionService: ActionsService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly systemNotificationsService: SystemNotificationsService,
|
||||
private readonly logService: LogService,
|
||||
) {
|
||||
super(
|
||||
accountService,
|
||||
authService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
);
|
||||
}
|
||||
|
||||
async receivedPendingAuthRequest(
|
||||
authRequestUserId: UserId,
|
||||
authRequestId: string,
|
||||
): Promise<void> {
|
||||
if (!authRequestUserId) {
|
||||
throw new Error("authRequestUserId required");
|
||||
}
|
||||
if (!authRequestId) {
|
||||
throw new Error("authRequestId required");
|
||||
}
|
||||
|
||||
// Always persist the pending marker for this user to global state.
|
||||
await this.pendingAuthRequestsState.add(authRequestUserId);
|
||||
|
||||
const activeUserMeetsConditionsToShowApprovalDialog =
|
||||
await this.activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId);
|
||||
|
||||
if (activeUserMeetsConditionsToShowApprovalDialog) {
|
||||
// Send message to open dialog immediately for this request
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
// Include the authRequestId so the DeviceManagementComponent can upsert the correct device.
|
||||
// This will only matter if the user is on the /device-management screen when the auth request is received.
|
||||
notificationId: authRequestId,
|
||||
});
|
||||
} else {
|
||||
// Create a system notification
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const accountInfo = accounts[authRequestUserId];
|
||||
|
||||
if (!accountInfo) {
|
||||
this.logService.error("Account not found for authRequestUserId");
|
||||
return;
|
||||
}
|
||||
|
||||
const emailForUser = accountInfo.email;
|
||||
await this.systemNotificationsService.create({
|
||||
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter.
|
||||
title: this.i18nService.t("accountAccessRequested"),
|
||||
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
|
||||
buttons: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
|
||||
const meetsBasicConditions = await super.activeUserMeetsConditionsToShowApprovalDialog(
|
||||
authRequestUserId,
|
||||
);
|
||||
|
||||
// To show an approval dialog immediately on Extension, the popup must be open.
|
||||
const isPopupOpen = await this.platformUtilsService.isPopupOpen();
|
||||
const meetsExtensionConditions = meetsBasicConditions && isPopupOpen;
|
||||
|
||||
return meetsExtensionConditions;
|
||||
}
|
||||
|
||||
/**
|
||||
* When a system notification is clicked, this function is used to process that event.
|
||||
*
|
||||
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
|
||||
*/
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
|
||||
await this.systemNotificationsService.clear({
|
||||
id: `${event.id}`,
|
||||
});
|
||||
await this.actionService.openPopup();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,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 { AuthRequestAnsweringService } 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";
|
||||
@@ -52,7 +52,6 @@ import { UserVerificationService as UserVerificationServiceAbstraction } from "@
|
||||
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 { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
|
||||
@@ -275,6 +274,7 @@ import {
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { ExtensionAuthRequestAnsweringService } from "../auth/services/auth-request-answering/extension-auth-request-answering.service";
|
||||
import { AuthStatusBadgeUpdaterService } from "../auth/services/auth-status-badge-updater.service";
|
||||
import { ExtensionLockService } from "../auth/services/extension-lock.service";
|
||||
import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface } from "../autofill/background/abstractions/overlay-notifications.background";
|
||||
@@ -392,7 +392,7 @@ export default class MainBackground {
|
||||
serverNotificationsService: ServerNotificationsService;
|
||||
systemNotificationService: SystemNotificationsService;
|
||||
actionsService: ActionsService;
|
||||
authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction;
|
||||
authRequestAnsweringService: AuthRequestAnsweringService;
|
||||
stateService: StateServiceAbstraction;
|
||||
userNotificationSettingsService: UserNotificationSettingsServiceAbstraction;
|
||||
autofillSettingsService: AutofillSettingsServiceAbstraction;
|
||||
@@ -1208,16 +1208,17 @@ export default class MainBackground {
|
||||
|
||||
this.pendingAuthRequestStateService = new PendingAuthRequestsStateService(this.stateProvider);
|
||||
|
||||
this.authRequestAnsweringService = new AuthRequestAnsweringService(
|
||||
this.authRequestAnsweringService = new ExtensionAuthRequestAnsweringService(
|
||||
this.accountService,
|
||||
this.actionsService,
|
||||
this.authService,
|
||||
this.i18nService,
|
||||
this.masterPasswordService,
|
||||
this.messagingService,
|
||||
this.pendingAuthRequestStateService,
|
||||
this.actionsService,
|
||||
this.i18nService,
|
||||
this.platformUtilsService,
|
||||
this.systemNotificationService,
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.serverNotificationsService = new DefaultServerNotificationsService(
|
||||
|
||||
@@ -14,16 +14,11 @@ import { NavigationEnd, Router, RouterOutlet } from "@angular/router";
|
||||
import {
|
||||
catchError,
|
||||
concatMap,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
@@ -38,7 +33,7 @@ import {
|
||||
} from "@bitwarden/auth/common";
|
||||
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
@@ -83,7 +78,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private lastActivity: Date;
|
||||
private activeUserId: UserId;
|
||||
private routerAnimations = false;
|
||||
private processingPendingAuth = false;
|
||||
private processingPendingAuthRequests = false;
|
||||
private shouldRerunAuthRequestProcessing = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -118,7 +114,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private logService: LogService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
private authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction,
|
||||
private authRequestAnsweringService: AuthRequestAnsweringService,
|
||||
) {
|
||||
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
|
||||
|
||||
@@ -136,22 +132,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.activeUserId = account?.id;
|
||||
});
|
||||
|
||||
// Trigger processing auth requests when the active user is in an unlocked state. Runs once when
|
||||
// the popup is open.
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
map((a) => a?.id), // Extract active userId
|
||||
distinctUntilChanged(), // Only when userId actually changes
|
||||
filter((userId) => userId != null), // Require a valid userId
|
||||
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
|
||||
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
|
||||
tap(() => {
|
||||
// Trigger processing when switching users while popup is open
|
||||
void this.authRequestAnsweringService.processPendingAuthRequests();
|
||||
}),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$);
|
||||
|
||||
this.authService.activeAccountStatus$
|
||||
.pipe(
|
||||
@@ -163,23 +144,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// When the popup is already open and the active account transitions to Unlocked,
|
||||
// process any pending auth requests for the active user. The above subscription does not handle
|
||||
// this case.
|
||||
this.authService.activeAccountStatus$
|
||||
.pipe(
|
||||
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
|
||||
pairwise(), // Compare previous and current statuses
|
||||
filter(
|
||||
([prev, curr]) =>
|
||||
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void this.authRequestAnsweringService.processPendingAuthRequests();
|
||||
});
|
||||
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
window.onmousedown = () => this.recordActivity();
|
||||
window.ontouchstart = () => this.recordActivity();
|
||||
@@ -234,38 +198,31 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
await this.router.navigate(["lock"]);
|
||||
} else if (msg.command === "openLoginApproval") {
|
||||
if (this.processingPendingAuth) {
|
||||
if (this.processingPendingAuthRequests) {
|
||||
// If an "openLoginApproval" message is received while we are currently processing other
|
||||
// auth requests, then set a flag so we remember to process that new auth request
|
||||
this.shouldRerunAuthRequestProcessing = true;
|
||||
return;
|
||||
}
|
||||
this.processingPendingAuth = true;
|
||||
try {
|
||||
// Always query server for all pending requests and open a dialog for each
|
||||
const pendingList = await firstValueFrom(
|
||||
this.authRequestService.getPendingAuthRequests$(),
|
||||
);
|
||||
if (Array.isArray(pendingList) && pendingList.length > 0) {
|
||||
const respondedIds = new Set<string>();
|
||||
for (const req of pendingList) {
|
||||
if (req?.id == null) {
|
||||
continue;
|
||||
}
|
||||
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
|
||||
notificationId: req.id,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
/**
|
||||
* This do/while loop allows us to:
|
||||
* - a) call processPendingAuthRequests() once on "openLoginApproval"
|
||||
* - b) remember to re-call processPendingAuthRequests() if another "openLoginApproval" was
|
||||
* received while we were processing the original auth requests
|
||||
*/
|
||||
do {
|
||||
this.shouldRerunAuthRequestProcessing = false;
|
||||
|
||||
if (result !== undefined && typeof result === "boolean") {
|
||||
respondedIds.add(req.id);
|
||||
if (respondedIds.size === pendingList.length && this.activeUserId != null) {
|
||||
await this.pendingAuthRequestsState.clear(this.activeUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await this.processPendingAuthRequests();
|
||||
} catch (error) {
|
||||
this.logService.error(`Error processing pending auth requests: ${error}`);
|
||||
this.shouldRerunAuthRequestProcessing = false; // Reset flag to prevent infinite loop on persistent errors
|
||||
}
|
||||
} finally {
|
||||
this.processingPendingAuth = false;
|
||||
}
|
||||
// If an "openLoginApproval" message was received while processPendingAuthRequests() was running, then
|
||||
// shouldRerunAuthRequestProcessing will have been set to true
|
||||
} while (this.shouldRerunAuthRequestProcessing);
|
||||
} else if (msg.command === "showDialog") {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
@@ -403,4 +360,39 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
this.toastService.showToast(toastOptions);
|
||||
}
|
||||
|
||||
private async processPendingAuthRequests() {
|
||||
this.processingPendingAuthRequests = true;
|
||||
|
||||
try {
|
||||
// Always query server for all pending requests and open a dialog for each
|
||||
const pendingList = await firstValueFrom(this.authRequestService.getPendingAuthRequests$());
|
||||
|
||||
if (Array.isArray(pendingList) && pendingList.length > 0) {
|
||||
const respondedIds = new Set<string>();
|
||||
|
||||
for (const req of pendingList) {
|
||||
if (req?.id == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
|
||||
notificationId: req.id,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result !== undefined && typeof result === "boolean") {
|
||||
respondedIds.add(req.id);
|
||||
|
||||
if (respondedIds.size === pendingList.length && this.activeUserId != null) {
|
||||
await this.pendingAuthRequestsState.clear(this.activeUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.processingPendingAuthRequests = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
LogoutService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ExtensionAuthRequestAnsweringService } from "@bitwarden/browser/auth/services/auth-request-answering/extension-auth-request-answering.service";
|
||||
import { ExtensionNewDeviceVerificationComponentService } from "@bitwarden/browser/auth/services/new-device-verification/extension-new-device-verification-component.service";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
@@ -49,13 +50,12 @@ import {
|
||||
AccountService,
|
||||
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 { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import {
|
||||
AutofillSettingsService,
|
||||
@@ -494,18 +494,19 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestAnsweringServiceAbstraction,
|
||||
useClass: AuthRequestAnsweringService,
|
||||
provide: AuthRequestAnsweringService,
|
||||
useClass: ExtensionAuthRequestAnsweringService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
ActionsService,
|
||||
AuthService,
|
||||
I18nServiceAbstraction,
|
||||
MasterPasswordServiceAbstraction,
|
||||
MessagingService,
|
||||
PendingAuthRequestsStateService,
|
||||
ActionsService,
|
||||
I18nServiceAbstraction,
|
||||
PlatformUtilsService,
|
||||
SystemNotificationsService,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
|
||||
@@ -31,6 +31,7 @@ import { DocumentLangSetter } from "@bitwarden/angular/platform/i18n";
|
||||
import { ModalService } from "@bitwarden/angular/services/modal.service";
|
||||
import { FingerprintDialogComponent } from "@bitwarden/auth/angular";
|
||||
import {
|
||||
AuthRequestServiceAbstraction,
|
||||
DESKTOP_SSO_CALLBACK,
|
||||
LockService,
|
||||
LogoutReason,
|
||||
@@ -40,11 +41,13 @@ import { EventUploadService } from "@bitwarden/common/abstractions/event/event-u
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
@@ -151,6 +154,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private isIdle = false;
|
||||
private activeUserId: UserId = null;
|
||||
private activeSimpleDialog: DialogRef<boolean> = null;
|
||||
private processingPendingAuthRequests = false;
|
||||
private shouldRerunAuthRequestProcessing = false;
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@@ -200,6 +205,9 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private desktopAutotypeDefaultSettingPolicy: DesktopAutotypeDefaultSettingPolicy,
|
||||
private readonly lockService: LockService,
|
||||
private premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
private pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private authRequestAnsweringService: AuthRequestAnsweringService,
|
||||
) {
|
||||
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
|
||||
|
||||
@@ -212,6 +220,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.activeUserId = account?.id;
|
||||
});
|
||||
|
||||
this.authRequestAnsweringService.setupUnlockListenersForProcessingAuthRequests(this.destroy$);
|
||||
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
setTimeout(async () => {
|
||||
await this.updateAppMenu();
|
||||
@@ -499,13 +509,31 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
await this.checkForSystemTimeout(VaultTimeoutStringType.OnIdle);
|
||||
break;
|
||||
case "openLoginApproval":
|
||||
if (message.notificationId != null) {
|
||||
this.dialogService.closeAll();
|
||||
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
|
||||
notificationId: message.notificationId,
|
||||
});
|
||||
await firstValueFrom(dialogRef.closed);
|
||||
if (this.processingPendingAuthRequests) {
|
||||
// If an "openLoginApproval" message is received while we are currently processing other
|
||||
// auth requests, then set a flag so we remember to process that new auth request
|
||||
this.shouldRerunAuthRequestProcessing = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* This do/while loop allows us to:
|
||||
* - a) call processPendingAuthRequests() once on "openLoginApproval"
|
||||
* - b) remember to re-call processPendingAuthRequests() if another "openLoginApproval" was
|
||||
* received while we were processing the original auth requests
|
||||
*/
|
||||
do {
|
||||
this.shouldRerunAuthRequestProcessing = false;
|
||||
|
||||
try {
|
||||
await this.processPendingAuthRequests();
|
||||
} catch (error) {
|
||||
this.logService.error(`Error processing pending auth requests: ${error}`);
|
||||
this.shouldRerunAuthRequestProcessing = false; // Reset flag to prevent infinite loop on persistent errors
|
||||
}
|
||||
// If an "openLoginApproval" message was received while processPendingAuthRequests() was running, then
|
||||
// shouldRerunAuthRequestProcessing will have been set to true
|
||||
} while (this.shouldRerunAuthRequestProcessing);
|
||||
break;
|
||||
case "redrawMenu":
|
||||
await this.updateAppMenu();
|
||||
@@ -887,4 +915,39 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
DeleteAccountComponent.open(this.dialogService);
|
||||
}
|
||||
|
||||
private async processPendingAuthRequests() {
|
||||
this.processingPendingAuthRequests = true;
|
||||
|
||||
try {
|
||||
// Always query server for all pending requests and open a dialog for each
|
||||
const pendingList = await firstValueFrom(this.authRequestService.getPendingAuthRequests$());
|
||||
|
||||
if (Array.isArray(pendingList) && pendingList.length > 0) {
|
||||
const respondedIds = new Set<string>();
|
||||
|
||||
for (const req of pendingList) {
|
||||
if (req?.id == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dialogRef = LoginApprovalDialogComponent.open(this.dialogService, {
|
||||
notificationId: req.id,
|
||||
});
|
||||
|
||||
const result = await firstValueFrom(dialogRef.closed);
|
||||
|
||||
if (result !== undefined && typeof result === "boolean") {
|
||||
respondedIds.add(req.id);
|
||||
|
||||
if (respondedIds.size === pendingList.length && this.activeUserId != null) {
|
||||
await this.pendingAuthRequestsState.clear(this.activeUserId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.processingPendingAuthRequests = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Router } from "@angular/router";
|
||||
import { Subject, merge } from "rxjs";
|
||||
|
||||
import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "@bitwarden/angular/auth/login-approval";
|
||||
import { SetInitialPasswordService } from "@bitwarden/angular/auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { SafeProvider, safeProvider } from "@bitwarden/angular/platform/utils/safe-provider";
|
||||
import {
|
||||
@@ -45,6 +44,7 @@ import {
|
||||
AccountService,
|
||||
AccountService as AccountServiceAbstraction,
|
||||
} from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import {
|
||||
AuthService,
|
||||
AuthService as AuthServiceAbstraction,
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
@@ -61,7 +62,10 @@ import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
MasterPasswordServiceAbstraction,
|
||||
} from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
|
||||
import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service";
|
||||
import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout";
|
||||
@@ -120,8 +124,8 @@ import {
|
||||
import { SerializedMemoryStorageService } from "@bitwarden/storage-core";
|
||||
import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault";
|
||||
|
||||
import { DesktopLoginApprovalDialogComponentService } from "../../auth/login/desktop-login-approval-dialog-component.service";
|
||||
import { DesktopLoginComponentService } from "../../auth/login/desktop-login-component.service";
|
||||
import { DesktopAuthRequestAnsweringService } from "../../auth/services/auth-request-answering/desktop-auth-request-answering.service";
|
||||
import { DesktopTwoFactorAuthDuoComponentService } from "../../auth/services/desktop-two-factor-auth-duo-component.service";
|
||||
import { DesktopAutofillSettingsService } from "../../autofill/services/desktop-autofill-settings.service";
|
||||
import { DesktopAutofillService } from "../../autofill/services/desktop-autofill.service";
|
||||
@@ -469,11 +473,6 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultSsoComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginApprovalDialogComponentServiceAbstraction,
|
||||
useClass: DesktopLoginApprovalDialogComponentService,
|
||||
deps: [I18nServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SshImportPromptService,
|
||||
useClass: DefaultSshImportPromptService,
|
||||
@@ -509,6 +508,19 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: SessionTimeoutSettingsComponentService,
|
||||
deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestAnsweringService,
|
||||
useClass: DesktopAuthRequestAnsweringService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
AuthService,
|
||||
MasterPasswordServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
PendingAuthRequestsStateService,
|
||||
I18nServiceAbstraction,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
import { LoginApprovalDialogComponent } from "@bitwarden/angular/auth/login-approval";
|
||||
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DesktopLoginApprovalDialogComponentService } from "./desktop-login-approval-dialog-component.service";
|
||||
|
||||
describe("DesktopLoginApprovalDialogComponentService", () => {
|
||||
let service: DesktopLoginApprovalDialogComponentService;
|
||||
let i18nService: MockProxy<I18nServiceAbstraction>;
|
||||
let originalIpc: any;
|
||||
|
||||
beforeEach(() => {
|
||||
originalIpc = (global as any).ipc;
|
||||
(global as any).ipc = {
|
||||
auth: {
|
||||
loginRequest: jest.fn(),
|
||||
},
|
||||
platform: {
|
||||
isWindowVisible: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
i18nService = mock<I18nServiceAbstraction>({
|
||||
t: jest.fn(),
|
||||
userSetLocale$: new Subject<string>(),
|
||||
locale$: new Subject<string>(),
|
||||
});
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DesktopLoginApprovalDialogComponentService,
|
||||
{ provide: I18nServiceAbstraction, useValue: i18nService },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(DesktopLoginApprovalDialogComponentService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global as any).ipc = originalIpc;
|
||||
});
|
||||
|
||||
it("is created successfully", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls ipc.auth.loginRequest with correct parameters when window is not visible", async () => {
|
||||
const title = "Log in requested";
|
||||
const email = "test@bitwarden.com";
|
||||
const message = `Confirm access attempt for ${email}`;
|
||||
const closeText = "Close";
|
||||
|
||||
const loginApprovalDialogComponent = { email } as LoginApprovalDialogComponent;
|
||||
i18nService.t.mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
case "accountAccessRequested":
|
||||
return title;
|
||||
case "confirmAccessAttempt":
|
||||
return message;
|
||||
case "close":
|
||||
return closeText;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(false);
|
||||
jest.spyOn(ipc.auth, "loginRequest").mockResolvedValue();
|
||||
|
||||
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalDialogComponent.email);
|
||||
|
||||
expect(ipc.auth.loginRequest).toHaveBeenCalledWith(title, message, closeText);
|
||||
});
|
||||
|
||||
it("does not call ipc.auth.loginRequest when window is visible", async () => {
|
||||
const loginApprovalDialogComponent = {
|
||||
email: "test@bitwarden.com",
|
||||
} as LoginApprovalDialogComponent;
|
||||
|
||||
jest.spyOn(ipc.platform, "isWindowVisible").mockResolvedValue(true);
|
||||
jest.spyOn(ipc.auth, "loginRequest");
|
||||
|
||||
await service.showLoginRequestedAlertIfWindowNotVisible(loginApprovalDialogComponent.email);
|
||||
|
||||
expect(ipc.auth.loginRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import {
|
||||
DefaultLoginApprovalDialogComponentService,
|
||||
LoginApprovalDialogComponentServiceAbstraction,
|
||||
} from "@bitwarden/angular/auth/login-approval";
|
||||
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
@Injectable()
|
||||
export class DesktopLoginApprovalDialogComponentService
|
||||
extends DefaultLoginApprovalDialogComponentService
|
||||
implements LoginApprovalDialogComponentServiceAbstraction
|
||||
{
|
||||
constructor(private i18nService: I18nServiceAbstraction) {
|
||||
super();
|
||||
}
|
||||
|
||||
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
|
||||
const isVisible = await ipc.platform.isWindowVisible();
|
||||
if (!isVisible) {
|
||||
await ipc.auth.loginRequest(
|
||||
this.i18nService.t("accountAccessRequested"),
|
||||
this.i18nService.t("confirmAccessAttempt", email),
|
||||
this.i18nService.t("close"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { DesktopAuthRequestAnsweringService } from "./desktop-auth-request-answering.service";
|
||||
|
||||
describe("DesktopAuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
const userAccountInfo = mockAccountInfoWith({
|
||||
name: "User",
|
||||
email: "user@example.com",
|
||||
});
|
||||
const userAccount: Account = {
|
||||
id: userId,
|
||||
...userAccountInfo,
|
||||
};
|
||||
|
||||
const authRequestId = "auth-request-id-123";
|
||||
|
||||
beforeEach(() => {
|
||||
(global as any).ipc = {
|
||||
platform: {
|
||||
isWindowVisible: jest.fn(),
|
||||
},
|
||||
auth: {
|
||||
loginRequest: jest.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
accountService = mock<AccountService>();
|
||||
authService = mock<AuthService>();
|
||||
masterPasswordService = {
|
||||
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
|
||||
};
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
i18nService = mock<I18nService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
accountService.activeAccount$ = of(userAccount);
|
||||
accountService.accounts$ = of({
|
||||
[userId]: userAccountInfo,
|
||||
});
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(false);
|
||||
i18nService.t.mockImplementation(
|
||||
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
|
||||
);
|
||||
|
||||
sut = new DesktopAuthRequestAnsweringService(
|
||||
accountService,
|
||||
authService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
i18nService,
|
||||
logService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("receivedPendingAuthRequest()", () => {
|
||||
it("should throw if authRequestUserId not given", async () => {
|
||||
// Act
|
||||
const promise = sut.receivedPendingAuthRequest(undefined, undefined);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow("authRequestUserId required");
|
||||
});
|
||||
|
||||
it("should add a pending marker for the user to state", async () => {
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(pendingAuthRequestsState.add).toHaveBeenCalledTimes(1);
|
||||
expect(pendingAuthRequestsState.add).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
|
||||
describe("given the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", () => {
|
||||
describe("given the Desktop window is visible", () => {
|
||||
it("should send an 'openLoginApproval' message", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(1);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT create a system notification", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect((global as any).ipc.auth.loginRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the Desktop window is NOT visible", () => {
|
||||
it("should STILL send an 'openLoginApproval' message", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(false);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(1);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should create a system notification", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(false);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("close");
|
||||
|
||||
expect((global as any).ipc.auth.loginRequest).toHaveBeenCalledWith(
|
||||
"accountAccessRequested",
|
||||
"confirmAccessAttempt:user@example.com",
|
||||
"close",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the active user is Locked", () => {
|
||||
it("should NOT send an 'openLoginApproval' message", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create a system notification", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect((global as any).ipc.auth.loginRequest).toHaveBeenCalledWith(
|
||||
"accountAccessRequested",
|
||||
"confirmAccessAttempt:user@example.com",
|
||||
"close",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the active user is not the intended recipient of the auth request", () => {
|
||||
beforeEach(() => {
|
||||
// Different active user for these tests
|
||||
const differentUserId = "different-user-id" as UserId;
|
||||
accountService.activeAccount$ = of({
|
||||
id: differentUserId,
|
||||
...mockAccountInfoWith({
|
||||
name: "Different User",
|
||||
email: "different@example.com",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT send an 'openLoginApproval' message", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
// Pass in userId, not differentUserId (the active user), to mimic an auth
|
||||
// request coming in for a user who is not the active user
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId); // pass in userId, not differentUserId
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create a system notification", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
// Pass in userId, not differentUserId (the active user), to mimic an auth
|
||||
// request coming in for a user who is not the active user
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect((global as any).ipc.auth.loginRequest).toHaveBeenCalledWith(
|
||||
"accountAccessRequested",
|
||||
"confirmAccessAttempt:user@example.com",
|
||||
"close",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("given the active user is required to set/change their master password", () => {
|
||||
it("should NOT send an 'openLoginApproval' message", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
masterPasswordService.forceSetPasswordReason$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(of(ForceSetPasswordReason.WeakMasterPassword));
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create a system notification", async () => {
|
||||
// Arrange
|
||||
(global as any).ipc.platform.isWindowVisible.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
masterPasswordService.forceSetPasswordReason$ = jest
|
||||
.fn()
|
||||
.mockReturnValue(of(ForceSetPasswordReason.WeakMasterPassword));
|
||||
|
||||
// Act
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
// Assert
|
||||
expect((global as any).ipc.auth.loginRequest).toHaveBeenCalledWith(
|
||||
"accountAccessRequested",
|
||||
"confirmAccessAttempt:user@example.com",
|
||||
"close",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export class DesktopAuthRequestAnsweringService
|
||||
extends DefaultAuthRequestAnsweringService
|
||||
implements AuthRequestAnsweringService
|
||||
{
|
||||
constructor(
|
||||
protected readonly accountService: AccountService,
|
||||
protected readonly authService: AuthService,
|
||||
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
protected readonly messagingService: MessagingService,
|
||||
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly logService: LogService,
|
||||
) {
|
||||
super(
|
||||
accountService,
|
||||
authService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param authRequestUserId The UserId that the auth request is for.
|
||||
* @param authRequestId The authRequestId param is not used on Desktop because clicks on a
|
||||
* Desktop notification do not run any auth-request-specific actions.
|
||||
* All clicks simply open the Desktop window. See electron-main-messaging.service.ts.
|
||||
*/
|
||||
async receivedPendingAuthRequest(
|
||||
authRequestUserId: UserId,
|
||||
authRequestId: string,
|
||||
): Promise<void> {
|
||||
if (!authRequestUserId) {
|
||||
throw new Error("authRequestUserId required");
|
||||
}
|
||||
|
||||
// Always persist the pending marker for this user to global state.
|
||||
await this.pendingAuthRequestsState.add(authRequestUserId);
|
||||
|
||||
const activeUserMeetsConditionsToShowApprovalDialog =
|
||||
await this.activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId);
|
||||
|
||||
if (activeUserMeetsConditionsToShowApprovalDialog) {
|
||||
// Send message to open dialog immediately for this request
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
|
||||
const isWindowVisible = await ipc.platform.isWindowVisible();
|
||||
|
||||
// Create a system notification if either of the following are true:
|
||||
// - User does NOT meet conditions to show dialog
|
||||
// - User does meet conditions, but the Desktop window is not visible
|
||||
// - In this second case, we both send the "openLoginApproval" message (above) AND
|
||||
// also create the system notification to notify the user that the dialog is there.
|
||||
if (!activeUserMeetsConditionsToShowApprovalDialog || !isWindowVisible) {
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const accountInfo = accounts[authRequestUserId];
|
||||
|
||||
if (!accountInfo) {
|
||||
this.logService.error("Account not found for authRequestUserId");
|
||||
return;
|
||||
}
|
||||
|
||||
const emailForUser = accountInfo.email;
|
||||
await ipc.auth.loginRequest(
|
||||
this.i18nService.t("accountAccessRequested"),
|
||||
this.i18nService.t("confirmAccessAttempt", emailForUser),
|
||||
this.i18nService.t("close"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import { CollectionService, CollectionView } from "@bitwarden/admin-console/comm
|
||||
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
|
||||
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
|
||||
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
|
||||
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
@@ -35,11 +34,12 @@ import { PolicyService } from "@bitwarden/common/admin-console/abstractions/poli
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -249,11 +249,10 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
private collectionService: CollectionService,
|
||||
private organizationService: OrganizationService,
|
||||
private folderService: FolderService,
|
||||
private configService: ConfigService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private cipherArchiveService: CipherArchiveService,
|
||||
private policyService: PolicyService,
|
||||
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -374,19 +373,12 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
this.searchBarService.setEnabled(true);
|
||||
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault"));
|
||||
|
||||
const authRequests = await firstValueFrom(
|
||||
this.authRequestService.getLatestPendingAuthRequest$()!,
|
||||
);
|
||||
if (authRequests != null) {
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
notificationId: authRequests.id,
|
||||
});
|
||||
}
|
||||
|
||||
this.activeUserId = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getUserId),
|
||||
).catch((): any => null);
|
||||
|
||||
await this.sendOpenLoginApprovalMessage(this.activeUserId);
|
||||
|
||||
if (this.activeUserId) {
|
||||
this.cipherService
|
||||
.failedToDecryptCiphers$(this.activeUserId)
|
||||
@@ -1061,4 +1053,27 @@ export class VaultV2Component<C extends CipherViewLike>
|
||||
}
|
||||
return repromptResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message that will retrieve any pending auth requests from the server. If there are
|
||||
* pending auth requests for this user, a LoginApprovalDialogComponent will open. If there
|
||||
* are no pending auth requests, nothing happens (see AppComponent: "openLoginApproval").
|
||||
*/
|
||||
private async sendOpenLoginApprovalMessage(activeUserId: UserId) {
|
||||
// This is a defensive check against a race condition where a user may have successfully logged
|
||||
// in with no forceSetPasswordReason, but while the vault component is loading, a sync sets
|
||||
// forceSetPasswordReason.TdeUserWithoutPasswordHasPasswordResetPermission (see DefaultSyncService).
|
||||
// This could potentially happen if an Admin upgrades the user's permissions as the user is logging
|
||||
// in. In this rare case we do not want to send an "openLoginApproval" message.
|
||||
//
|
||||
// This also keeps parity with other usages of the "openLoginApproval" message. That is: don't send
|
||||
// an "openLoginApproval" message if the user is required to set/change their password.
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
|
||||
);
|
||||
if (forceSetPasswordReason === ForceSetPasswordReason.None) {
|
||||
// If there are pending auth requests for this user, a LoginApprovalDialogComponent will open
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,9 +59,11 @@ import {
|
||||
} from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountApiService as AccountApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/account-api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/master-password-api.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
@@ -483,6 +485,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: SessionTimeoutSettingsComponentService,
|
||||
deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestAnsweringService,
|
||||
useClass: NoopAuthRequestAnsweringService,
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
|
||||
import { DefaultLoginApprovalDialogComponentService } from "./default-login-approval-dialog-component.service";
|
||||
import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
|
||||
|
||||
describe("DefaultLoginApprovalDialogComponentService", () => {
|
||||
let service: DefaultLoginApprovalDialogComponentService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [DefaultLoginApprovalDialogComponentService],
|
||||
});
|
||||
|
||||
service = TestBed.inject(DefaultLoginApprovalDialogComponentService);
|
||||
});
|
||||
|
||||
it("is created successfully", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => {
|
||||
const loginApprovalDialogComponent = {} as LoginApprovalDialogComponent;
|
||||
|
||||
const result = await service.showLoginRequestedAlertIfWindowNotVisible(
|
||||
loginApprovalDialogComponent.email,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
|
||||
|
||||
/**
|
||||
* Default implementation of the LoginApprovalDialogComponentServiceAbstraction.
|
||||
*/
|
||||
export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction {
|
||||
/**
|
||||
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
|
||||
* @returns
|
||||
*/
|
||||
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
export * from "./login-approval-dialog.component";
|
||||
export * from "./login-approval-dialog-component.service.abstraction";
|
||||
export * from "./default-login-approval-dialog-component.service";
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Abstraction for the LoginApprovalDialogComponent service.
|
||||
*/
|
||||
export abstract class LoginApprovalDialogComponentServiceAbstraction {
|
||||
/**
|
||||
* Shows a login requested alert if the window is not visible.
|
||||
*/
|
||||
abstract showLoginRequestedAlertIfWindowNotVisible: (email?: string) => Promise<void>;
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
|
||||
import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
|
||||
|
||||
describe("LoginApprovalDialogComponent", () => {
|
||||
@@ -69,10 +68,6 @@ describe("LoginApprovalDialogComponent", () => {
|
||||
{ provide: LogService, useValue: logService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: ValidationService, useValue: validationService },
|
||||
{
|
||||
provide: LoginApprovalDialogComponentServiceAbstraction,
|
||||
useValue: mock<LoginApprovalDialogComponentServiceAbstraction>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
|
||||
|
||||
const RequestTimeOut = 60000 * 15; // 15 Minutes
|
||||
const RequestTimeUpdate = 60000 * 5; // 5 Minutes
|
||||
|
||||
@@ -57,7 +55,6 @@ export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
|
||||
private devicesService: DevicesServiceAbstraction,
|
||||
private dialogRef: DialogRef,
|
||||
private i18nService: I18nService,
|
||||
private loginApprovalDialogComponentService: LoginApprovalDialogComponentServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private toastService: ToastService,
|
||||
private validationService: ValidationService,
|
||||
@@ -113,10 +110,6 @@ export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
|
||||
this.updateTimeText();
|
||||
}, RequestTimeUpdate);
|
||||
|
||||
await this.loginApprovalDialogComponentService.showLoginRequestedAlertIfWindowNotVisible(
|
||||
this.email,
|
||||
);
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,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 { AuthRequestAnsweringService } 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";
|
||||
@@ -112,7 +112,7 @@ import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/aut
|
||||
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 { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
|
||||
import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
|
||||
@@ -397,8 +397,6 @@ import {
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { DefaultLoginApprovalDialogComponentService } from "../auth/login-approval/default-login-approval-dialog-component.service";
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "../auth/login-approval/login-approval-dialog-component.service.abstraction";
|
||||
import { DefaultSetInitialPasswordService } from "../auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
|
||||
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
|
||||
@@ -1040,9 +1038,15 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestAnsweringServiceAbstraction,
|
||||
useClass: NoopAuthRequestAnsweringService,
|
||||
deps: [],
|
||||
provide: AuthRequestAnsweringService,
|
||||
useClass: DefaultAuthRequestAnsweringService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
MasterPasswordServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
PendingAuthRequestsStateService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ServerNotificationsService,
|
||||
@@ -1060,7 +1064,7 @@ const safeProviders: SafeProvider[] = [
|
||||
SignalRConnectionService,
|
||||
AuthServiceAbstraction,
|
||||
WebPushConnectionService,
|
||||
AuthRequestAnsweringServiceAbstraction,
|
||||
AuthRequestAnsweringService,
|
||||
ConfigService,
|
||||
InternalPolicyService,
|
||||
],
|
||||
@@ -1666,11 +1670,6 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultSendPasswordService,
|
||||
deps: [CryptoFunctionServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginApprovalDialogComponentServiceAbstraction,
|
||||
useClass: DefaultLoginApprovalDialogComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginDecryptionOptionsService,
|
||||
useClass: DefaultLoginDecryptionOptionsService,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Auth Request Answering Service
|
||||
|
||||
This feature is to allow for the taking of auth requests that are received via websockets by the background service to
|
||||
be acted on when the user loads up a client. Currently only implemented with the browser client.
|
||||
This feature is to allow for the taking of auth requests that are received via websockets to be acted on when the user loads up a client.
|
||||
|
||||
See diagram for the high level picture of how this is wired up.
|
||||
|
||||
|
||||
@@ -1,30 +1,50 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export abstract class AuthRequestAnsweringServiceAbstraction {
|
||||
export abstract class AuthRequestAnsweringService {
|
||||
/**
|
||||
* Tries to either display the dialog for the user or will preserve its data and show it at a
|
||||
* later time. Even in the event the dialog is shown immediately, this will write to global state
|
||||
* so that even if someone closes a window or a popup and comes back, it could be processed later.
|
||||
* Only way to clear out the global state is to respond to the auth request.
|
||||
* - Implemented on Extension and Desktop.
|
||||
*
|
||||
* Currently, this is only implemented for browser extension.
|
||||
*
|
||||
* @param userId The UserId that the auth request is for.
|
||||
* @param authRequestUserId The UserId that the auth request is for.
|
||||
* @param authRequestId The id of the auth request that is to be processed.
|
||||
*/
|
||||
abstract receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void>;
|
||||
abstract receivedPendingAuthRequest?(
|
||||
authRequestUserId: UserId,
|
||||
authRequestId: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* When a system notification is clicked, this function is used to process that event.
|
||||
* Confirms whether or not the user meets the conditions required to show them an
|
||||
* approval dialog immediately.
|
||||
*
|
||||
* @param authRequestUserId the UserId that the auth request is for.
|
||||
* @returns boolean stating whether or not the user meets conditions
|
||||
*/
|
||||
abstract activeUserMeetsConditionsToShowApprovalDialog(
|
||||
authRequestUserId: UserId,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Sets up listeners for scenarios where the user unlocks and we want to process
|
||||
* any pending auth requests in state.
|
||||
*
|
||||
* @param destroy$ The destroy$ observable from the caller
|
||||
*/
|
||||
abstract setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void;
|
||||
|
||||
/**
|
||||
* When a system notification is clicked, this method is used to process that event.
|
||||
* - Implemented on Extension only.
|
||||
* - Desktop does not implement this method because click handling is already setup in
|
||||
* electron-main-messaging.service.ts.
|
||||
*
|
||||
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
|
||||
*/
|
||||
abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Process notifications that have been received but didn't meet the conditions to display the
|
||||
* approval dialog.
|
||||
*/
|
||||
abstract processPendingAuthRequests(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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 { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "./auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "./pending-auth-requests.state";
|
||||
|
||||
describe("AuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let actionService: MockProxy<ActionsService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let systemNotificationsService: MockProxy<SystemNotificationsService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
actionService = mock<ActionsService>();
|
||||
authService = mock<AuthService>();
|
||||
i18nService = mock<I18nService>();
|
||||
masterPasswordService = { forceSetPasswordReason$: jest.fn() };
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
systemNotificationsService = mock<SystemNotificationsService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
const accountInfo = mockAccountInfoWith({
|
||||
email: "user@example.com",
|
||||
name: "User",
|
||||
});
|
||||
accountService.activeAccount$ = of({
|
||||
id: userId,
|
||||
...accountInfo,
|
||||
});
|
||||
accountService.accounts$ = of({
|
||||
[userId]: accountInfo,
|
||||
});
|
||||
(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,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
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 { getOptionalUserId, 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 { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.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";
|
||||
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
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 messagingService: MessagingService,
|
||||
private readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly systemNotificationsService: SystemNotificationsService,
|
||||
) {}
|
||||
|
||||
async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void> {
|
||||
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
const activeUserId: UserId | null = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(userId),
|
||||
);
|
||||
const popupOpen = await this.platformUtilsService.isPopupOpen();
|
||||
|
||||
// Always persist the pending marker for this user to global state.
|
||||
await this.pendingAuthRequestsState.add(userId);
|
||||
|
||||
// These are the conditions we are looking for to know if the extension is in a state to show
|
||||
// the approval dialog.
|
||||
const userIsAvailableToReceiveAuthRequest =
|
||||
popupOpen &&
|
||||
authStatus === AuthenticationStatus.Unlocked &&
|
||||
activeUserId === userId &&
|
||||
forceSetPasswordReason === ForceSetPasswordReason.None;
|
||||
|
||||
if (!userIsAvailableToReceiveAuthRequest) {
|
||||
// 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}`, // the underscore is an important delimiter.
|
||||
title: this.i18nService.t("accountAccessRequested"),
|
||||
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
|
||||
buttons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Popup is open and conditions are met; open dialog immediately for this request
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
|
||||
await this.systemNotificationsService.clear({
|
||||
id: `${event.id}`,
|
||||
});
|
||||
await this.actionService.openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
async processPendingAuthRequests(): Promise<void> {
|
||||
// Prune any stale pending requests (older than 15 minutes)
|
||||
// This comes from GlobalSettings.cs
|
||||
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
const fifteenMinutesMs = 15 * 60 * 1000;
|
||||
|
||||
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
|
||||
|
||||
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
|
||||
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
|
||||
|
||||
if (pendingAuthRequestsInState.length > 0) {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
|
||||
(e) => e.userId === activeUserId,
|
||||
);
|
||||
|
||||
if (pendingAuthRequestsForActiveUser) {
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import { DefaultAuthRequestAnsweringService } from "./default-auth-request-answering.service";
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
describe("DefaultAuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
const userAccountInfo = mockAccountInfoWith({
|
||||
name: "User",
|
||||
email: "user@example.com",
|
||||
});
|
||||
const userAccount: Account = {
|
||||
id: userId,
|
||||
...userAccountInfo,
|
||||
};
|
||||
|
||||
const otherUserId = "554c3112-9a75-23af-ab80-8dk3e9bl5i8e" as UserId;
|
||||
const otherUserAccountInfo = mockAccountInfoWith({
|
||||
name: "Other",
|
||||
email: "other@example.com",
|
||||
});
|
||||
const otherUserAccount: Account = {
|
||||
id: otherUserId,
|
||||
...otherUserAccountInfo,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
authService = mock<AuthService>();
|
||||
masterPasswordService = {
|
||||
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
|
||||
};
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
accountService.activeAccount$ = of(userAccount);
|
||||
accountService.accounts$ = of({
|
||||
[userId]: userAccountInfo,
|
||||
[otherUserId]: otherUserAccountInfo,
|
||||
});
|
||||
|
||||
sut = new DefaultAuthRequestAnsweringService(
|
||||
accountService,
|
||||
authService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
);
|
||||
});
|
||||
|
||||
describe("activeUserMeetsConditionsToShowApprovalDialog()", () => {
|
||||
it("should return false if there is no active user", async () => {
|
||||
// Arrange
|
||||
accountService.activeAccount$ = of(null);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is not the intended recipient of the auth request", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(otherUserId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is not unlocked", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupUnlockListenersForProcessingAuthRequests()", () => {
|
||||
let destroy$: Subject<void>;
|
||||
let activeAccount$: BehaviorSubject<Account>;
|
||||
let activeAccountStatus$: BehaviorSubject<AuthenticationStatus>;
|
||||
let authStatusForSubjects: Map<UserId, BehaviorSubject<AuthenticationStatus>>;
|
||||
let pendingRequestMarkers: PendingAuthUserMarker[];
|
||||
|
||||
beforeEach(() => {
|
||||
destroy$ = new Subject<void>();
|
||||
activeAccount$ = new BehaviorSubject(userAccount);
|
||||
activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Locked);
|
||||
authStatusForSubjects = new Map();
|
||||
pendingRequestMarkers = [];
|
||||
|
||||
accountService.activeAccount$ = activeAccount$;
|
||||
authService.activeAccountStatus$ = activeAccountStatus$;
|
||||
authService.authStatusFor$.mockImplementation((id: UserId) => {
|
||||
if (!authStatusForSubjects.has(id)) {
|
||||
authStatusForSubjects.set(id, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
}
|
||||
return authStatusForSubjects.get(id)!;
|
||||
});
|
||||
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of([]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroy$.next();
|
||||
destroy$.complete();
|
||||
});
|
||||
|
||||
describe("active account switching", () => {
|
||||
it("should process pending auth requests when switching to an unlocked user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Simulate account switching to an Unlocked account
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // Allows observable chain to complete before assertion
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to a locked user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to a logged out user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.LoggedOut));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when active account becomes null", async () => {
|
||||
// Arrange
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(null);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple user switches correctly", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(userId, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Switch to unlocked user (should trigger)
|
||||
activeAccount$.next(otherUserAccount);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Switch to locked user (should NOT trigger)
|
||||
activeAccount$.next(userAccount);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to an Unlocked user who is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication status transitions", () => {
|
||||
it("should process pending auth requests when active account transitions to Unlocked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should process pending auth requests when transitioning from LoggedOut to Unlocked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when transitioning from Unlocked to Locked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Clear any calls from the initial trigger (from null -> Unlocked)
|
||||
messagingService.send.mockClear();
|
||||
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when transitioning from Locked to LoggedOut", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when staying in Unlocked status", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Clear any calls from the initial trigger (from null -> Unlocked)
|
||||
messagingService.send.mockClear();
|
||||
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple status transitions correctly", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Transition to Unlocked (should trigger)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Transition to Locked (should NOT trigger)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Transition back to Unlocked (should trigger again)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(2);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when active account transitions to Unlocked but is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscription cleanup", () => {
|
||||
it("should stop processing when destroy$ emits", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Emit destroy signal
|
||||
destroy$.next();
|
||||
|
||||
// Try to trigger processing after cleanup
|
||||
activeAccount$.next(otherUserAccount);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAuthRequestNotificationClicked()", () => {
|
||||
it("should throw an error", async () => {
|
||||
// Arrange
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.NotificationButton,
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"handleAuthRequestNotificationClicked() not implemented for this client",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
export class DefaultAuthRequestAnsweringService implements AuthRequestAnsweringService {
|
||||
constructor(
|
||||
protected readonly accountService: AccountService,
|
||||
protected readonly authService: AuthService,
|
||||
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
protected readonly messagingService: MessagingService,
|
||||
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
) {}
|
||||
|
||||
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
|
||||
// If the active user is not the intended recipient of the auth request, return false
|
||||
const activeUserId: UserId | null = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (activeUserId !== authRequestUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the active user is not unlocked, return false
|
||||
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
if (authStatus !== AuthenticationStatus.Unlocked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the active user is required to set/change their master password, return false
|
||||
// Note that by this point we know that the authRequestUserId is the active UserId (see check above)
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(authRequestUserId),
|
||||
);
|
||||
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User meets conditions: they are the intended recipient, unlocked, and not required to set/change their master password
|
||||
return true;
|
||||
}
|
||||
|
||||
setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void {
|
||||
// When account switching to a user who is Unlocked, process any pending auth requests.
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
map((a) => a?.id), // Extract active userId
|
||||
distinctUntilChanged(), // Only when userId actually changes
|
||||
filter((userId) => userId != null), // Require a valid userId
|
||||
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
|
||||
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
|
||||
tap(() => {
|
||||
void this.processPendingAuthRequests();
|
||||
}),
|
||||
takeUntil(destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// When the active account transitions TO Unlocked, process any pending auth requests.
|
||||
this.authService.activeAccountStatus$
|
||||
.pipe(
|
||||
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
|
||||
pairwise(), // Compare previous and current statuses
|
||||
filter(
|
||||
([prev, curr]) =>
|
||||
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
|
||||
),
|
||||
takeUntil(destroy$),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void this.processPendingAuthRequests();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process notifications that have been received but didn't meet the conditions to display the
|
||||
* approval dialog.
|
||||
*/
|
||||
private async processPendingAuthRequests(): Promise<void> {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
// Only continue if the active user is not required to set/change their master password
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
|
||||
);
|
||||
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prune any stale pending requests (older than 15 minutes)
|
||||
// This comes from GlobalSettings.cs
|
||||
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
const fifteenMinutesMs = 15 * 60 * 1000;
|
||||
|
||||
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
|
||||
|
||||
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
|
||||
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
|
||||
|
||||
if (pendingAuthRequestsInState.length > 0) {
|
||||
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
|
||||
(e) => e.userId === activeUserId,
|
||||
);
|
||||
|
||||
if (pendingAuthRequestsForActiveUser) {
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
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";
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
|
||||
constructor() {}
|
||||
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringService {
|
||||
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
|
||||
throw new Error(
|
||||
"activeUserMeetsConditionsToShowApprovalDialog() not implemented for this client",
|
||||
);
|
||||
}
|
||||
|
||||
async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void> {}
|
||||
setupUnlockListenersForProcessingAuthRequests(): void {
|
||||
throw new Error(
|
||||
"setupUnlockListenersForProcessingAuthRequests() not implemented for this client",
|
||||
);
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {}
|
||||
|
||||
async processPendingAuthRequests(): Promise<void> {}
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import { mockAccountInfoWith } from "../../../../spec";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
@@ -33,7 +33,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringServiceAbstraction>;
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let policyService: MockProxy<InternalPolicyService>;
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
return webPushSupportStatusByUser.get(userId)!.asObservable();
|
||||
});
|
||||
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
|
||||
|
||||
policyService = mock<InternalPolicyService>();
|
||||
|
||||
@@ -270,13 +270,13 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
// allow async queue to drain
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
|
||||
notificationId: "auth-id-2",
|
||||
});
|
||||
// When authRequestAnsweringService.receivedPendingAuthRequest exists (Extension/Desktop),
|
||||
// only that method is called. messagingService.send is only called for Web (NoopAuthRequestAnsweringService).
|
||||
expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith(
|
||||
mockUserId2,
|
||||
"auth-id-2",
|
||||
);
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subj
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import { awaitAsync, mockAccountInfoWith } from "../../../../spec";
|
||||
import { Matrix } from "../../../../spec/matrix";
|
||||
@@ -42,7 +42,7 @@ describe("NotificationsService", () => {
|
||||
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringServiceAbstraction>;
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let policyService: MockProxy<InternalPolicyService>;
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("NotificationsService", () => {
|
||||
signalRNotificationConnectionService = mock<SignalRConnectionService>();
|
||||
authService = mock<AuthService>();
|
||||
webPushNotificationConnectionService = mock<WorkerWebPushConnectionService>();
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
|
||||
configService = mock<ConfigService>();
|
||||
policyService = mock<InternalPolicyService>();
|
||||
|
||||
@@ -471,5 +471,41 @@ describe("NotificationsService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationType.AuthRequest", () => {
|
||||
it("should call receivedPendingAuthRequest when it exists (Extension/Desktop)", async () => {
|
||||
authRequestAnsweringService.receivedPendingAuthRequest!.mockResolvedValue(undefined as any);
|
||||
|
||||
const notification = new NotificationResponse({
|
||||
type: NotificationType.AuthRequest,
|
||||
payload: { userId: mockUser1, id: "auth-request-123" },
|
||||
contextId: "different-app-id",
|
||||
});
|
||||
|
||||
await sut["processNotification"](notification, mockUser1);
|
||||
|
||||
expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith(
|
||||
mockUser1,
|
||||
"auth-request-123",
|
||||
);
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call messagingService.send when receivedPendingAuthRequest does not exist (Web)", async () => {
|
||||
authRequestAnsweringService.receivedPendingAuthRequest = undefined as any;
|
||||
|
||||
const notification = new NotificationResponse({
|
||||
type: NotificationType.AuthRequest,
|
||||
payload: { userId: mockUser1, id: "auth-request-456" },
|
||||
contextId: "different-app-id",
|
||||
});
|
||||
|
||||
await sut["processNotification"](notification, mockUser1);
|
||||
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
|
||||
notificationId: "auth-request-456",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { trackedMerge } from "@bitwarden/common/platform/misc";
|
||||
|
||||
@@ -67,7 +67,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
private readonly signalRConnectionService: SignalRConnectionService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly webPushConnectionService: WebPushConnectionService,
|
||||
private readonly authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction,
|
||||
private readonly authRequestAnsweringService: AuthRequestAnsweringService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly policyService: InternalPolicyService,
|
||||
) {
|
||||
@@ -250,26 +250,28 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
case NotificationType.SyncSendDelete:
|
||||
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
|
||||
break;
|
||||
case NotificationType.AuthRequest:
|
||||
await this.authRequestAnsweringService.receivedPendingAuthRequest(
|
||||
notification.payload.userId,
|
||||
notification.payload.id,
|
||||
);
|
||||
|
||||
/**
|
||||
* This call is necessary for Desktop, which for the time being uses a noop for the
|
||||
* authRequestAnsweringService.receivedPendingAuthRequest() call just above. Desktop
|
||||
* will eventually use the new AuthRequestAnsweringService, at which point we can remove
|
||||
* this second call.
|
||||
*
|
||||
* The Extension AppComponent has logic (see processingPendingAuth) that only allows one
|
||||
* pending auth request to process at a time, so this second call will not cause any
|
||||
* duplicate processing conflicts on Extension.
|
||||
*/
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
notificationId: notification.payload.id,
|
||||
});
|
||||
case NotificationType.AuthRequest: {
|
||||
// Only Extension and Desktop implement the AuthRequestAnsweringService
|
||||
if (this.authRequestAnsweringService.receivedPendingAuthRequest) {
|
||||
try {
|
||||
await this.authRequestAnsweringService.receivedPendingAuthRequest(
|
||||
notification.payload.userId,
|
||||
notification.payload.id,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logService.error(`Failed to process auth request notification: ${error}`);
|
||||
}
|
||||
} else {
|
||||
// This call is necessary for Web, which uses a NoopAuthRequestAnsweringService
|
||||
// that does not have a receivedPendingAuthRequest() method
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
// Include the authRequestId so the DeviceManagementComponent can upsert the correct device.
|
||||
// This will only matter if the user is on the /device-management screen when the auth request is received.
|
||||
notificationId: notification.payload.id,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case NotificationType.SyncOrganizationStatusChanged:
|
||||
await this.syncService.fullSync(true);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user