1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 07:43:35 +00:00

[PM -20329] browser auth approval client api service (#15161)

* feat: Create methods for calling GET auth-request/pending endpoint.

* feat: update banner service on web, and desktop vault

* test: updated banner test to use auth request services

* fix: DI fixes

* feat: add RequestDeviceId to AuthRequestResponse

* fix: add Browser Approvals feature flags to desktop vault and web vault banner service

* test: fix tests for feature flag
This commit is contained in:
Ike
2025-06-26 11:13:06 -04:00
committed by GitHub
parent 4d0ad3310e
commit 7c9e95271d
16 changed files with 157 additions and 62 deletions

View File

@@ -6,8 +6,10 @@ import { filter, firstValueFrom, map, merge, Subject, timeout } from "rxjs";
import { CollectionService, DefaultCollectionService } from "@bitwarden/admin-console/common";
import {
AuthRequestApiServiceAbstraction,
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLockService,
InternalUserDecryptionOptionsServiceAbstraction,
LoginEmailServiceAbstraction,
@@ -375,6 +377,7 @@ export default class MainBackground {
devicesService: DevicesServiceAbstraction;
deviceTrustService: DeviceTrustServiceAbstraction;
authRequestService: AuthRequestServiceAbstraction;
authRequestApiService: AuthRequestApiServiceAbstraction;
accountService: AccountServiceAbstraction;
globalStateProvider: GlobalStateProvider;
pinService: PinServiceAbstraction;
@@ -813,14 +816,16 @@ export default class MainBackground {
this.appIdService,
);
this.authRequestApiService = new DefaultAuthRequestApiService(this.apiService, this.logService);
this.authRequestService = new AuthRequestService(
this.appIdService,
this.accountService,
this.masterPasswordService,
this.keyService,
this.encryptService,
this.apiService,
this.stateProvider,
this.authRequestApiService,
);
this.authService = new AuthService(

View File

@@ -20,6 +20,8 @@ import {
PinServiceAbstraction,
UserDecryptionOptionsService,
SsoUrlService,
AuthRequestApiServiceAbstraction,
DefaultAuthRequestApiService,
} from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
@@ -265,6 +267,7 @@ export class ServiceContainer {
devicesApiService: DevicesApiServiceAbstraction;
deviceTrustService: DeviceTrustServiceAbstraction;
authRequestService: AuthRequestService;
authRequestApiService: AuthRequestApiServiceAbstraction;
configApiService: ConfigApiServiceAbstraction;
configService: ConfigService;
accountService: AccountService;
@@ -616,14 +619,16 @@ export class ServiceContainer {
this.stateProvider,
);
this.authRequestApiService = new DefaultAuthRequestApiService(this.apiService, this.logService);
this.authRequestService = new AuthRequestService(
this.appIdService,
this.accountService,
this.masterPasswordService,
this.keyService,
this.encryptService,
this.apiService,
this.stateProvider,
this.authRequestApiService,
);
this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService(

View File

@@ -16,13 +16,16 @@ import { filter, first, map, take } from "rxjs/operators";
import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
import { ModalService } from "@bitwarden/angular/services/modal.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 { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
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 { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
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";
@@ -120,6 +123,8 @@ export class VaultComponent implements OnInit, OnDestroy {
private accountService: AccountService,
private cipherService: CipherService,
private folderService: FolderService,
private authRequestService: AuthRequestServiceAbstraction,
private configService: ConfigService,
) {}
async ngOnInit() {
@@ -237,11 +242,30 @@ export class VaultComponent implements OnInit, OnDestroy {
this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault"));
const authRequest = await this.apiService.getLastAuthRequest();
if (authRequest != null) {
this.messagingService.send("openLoginApproval", {
notificationId: authRequest.id,
});
const browserLoginApprovalFeatureFlag = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval),
);
if (browserLoginApprovalFeatureFlag === true) {
const authRequests = await firstValueFrom(this.authRequestService.getPendingAuthRequests$());
// There is a chance that there is more than one auth request in the response we only show the most recent one
if (authRequests.length > 0) {
const mostRecentAuthRequest = authRequests.reduce((latest, current) => {
const latestDate = new Date(latest.creationDate).getTime();
const currentDate = new Date(current.creationDate).getTime();
return currentDate > latestDate ? current : latest;
});
this.messagingService.send("openLoginApproval", {
notificationId: mostRecentAuthRequest.id,
});
}
} else {
const authRequest = await this.apiService.getLastAuthRequest();
if (authRequest != null) {
this.messagingService.send("openLoginApproval", {
notificationId: authRequest.id,
});
}
}
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));

View File

@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterTestingModule } from "@angular/router/testing";
import { of, Subject } from "rxjs";
import { AuthRequestApiService } from "@bitwarden/auth/common";
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
import { DeviceType } from "@bitwarden/common/enums";
@@ -79,7 +79,7 @@ describe("DeviceManagementComponent", () => {
},
},
{
provide: AuthRequestApiService,
provide: AuthRequestApiServiceAbstraction,
useValue: {
getAuthRequest: jest.fn().mockResolvedValue(mockDeviceResponse),
},

View File

@@ -4,7 +4,7 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { firstValueFrom } from "rxjs";
import { LoginApprovalComponent } from "@bitwarden/auth/angular";
import { AuthRequestApiService } from "@bitwarden/auth/common";
import { AuthRequestApiServiceAbstraction } from "@bitwarden/auth/common";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import {
DevicePendingAuthRequest,
@@ -61,7 +61,7 @@ export class DeviceManagementComponent {
private toastService: ToastService,
private validationService: ValidationService,
private messageListener: MessageListener,
private authRequestApiService: AuthRequestApiService,
private authRequestApiService: AuthRequestApiServiceAbstraction,
private destroyRef: DestroyRef,
) {
void this.initializeDevices();

View File

@@ -1,16 +1,19 @@
import { TestBed } from "@angular/core/testing";
import { BehaviorSubject, firstValueFrom, take, timeout } from "rxjs";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of, take, timeout } from "rxjs";
import {
AuthRequestServiceAbstraction,
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { DeviceResponse } from "@bitwarden/common/auth/abstractions/devices/responses/device.response";
import { DeviceView } from "@bitwarden/common/auth/abstractions/devices/views/device.view";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { DeviceType } from "@bitwarden/common/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
@@ -41,11 +44,15 @@ describe("VaultBannersService", () => {
[userId]: { email: "test@bitwarden.com", emailVerified: true, name: "name" } as AccountInfo,
});
const devices$ = new BehaviorSubject<DeviceView[]>([]);
const pendingAuthRequests$ = new BehaviorSubject<Array<AuthRequestResponse>>([]);
let configService: MockProxy<ConfigService>;
beforeEach(() => {
lastSync$.next(new Date("2024-05-14"));
isSelfHost.mockClear();
getEmailVerified.mockClear().mockResolvedValue(true);
configService = mock<ConfigService>();
configService.getFeatureFlag$.mockImplementation(() => of(true));
TestBed.configureTestingModule({
providers: [
@@ -88,6 +95,14 @@ describe("VaultBannersService", () => {
provide: DevicesServiceAbstraction,
useValue: { getDevices$: () => devices$ },
},
{
provide: AuthRequestServiceAbstraction,
useValue: { getPendingAuthRequests$: () => pendingAuthRequests$ },
},
{
provide: ConfigService,
useValue: configService,
},
],
});
});
@@ -286,31 +301,25 @@ describe("VaultBannersService", () => {
describe("PendingAuthRequest", () => {
const now = new Date();
let deviceResponse: DeviceResponse;
let authRequestResponse: AuthRequestResponse;
beforeEach(() => {
deviceResponse = new DeviceResponse({
Id: "device1",
UserId: userId,
Name: "Test Device",
Identifier: "test-device",
Type: DeviceType.Android,
CreationDate: now.toISOString(),
RevisionDate: now.toISOString(),
IsTrusted: false,
authRequestResponse = new AuthRequestResponse({
id: "authRequest1",
deviceId: "device1",
deviceName: "Test Device",
deviceType: DeviceType.Android,
creationDate: now.toISOString(),
requestApproved: null,
});
// Reset devices list, single user state, and active user state before each test
devices$.next([]);
pendingAuthRequests$.next([]);
fakeStateProvider.singleUser.states.clear();
fakeStateProvider.activeUser.states.clear();
});
it("shows pending auth request banner when there is a pending request", async () => {
deviceResponse.devicePendingAuthRequest = {
id: "123",
creationDate: now.toISOString(),
};
devices$.next([new DeviceView(deviceResponse)]);
pendingAuthRequests$.next([new AuthRequestResponse(authRequestResponse)]);
service = TestBed.inject(VaultBannersService);
@@ -318,8 +327,7 @@ describe("VaultBannersService", () => {
});
it("does not show pending auth request banner when there are no pending requests", async () => {
deviceResponse.devicePendingAuthRequest = null;
devices$.next([new DeviceView(deviceResponse)]);
pendingAuthRequests$.next([]);
service = TestBed.inject(VaultBannersService);
@@ -327,11 +335,7 @@ describe("VaultBannersService", () => {
});
it("dismisses pending auth request banner", async () => {
deviceResponse.devicePendingAuthRequest = {
id: "123",
creationDate: now.toISOString(),
};
devices$.next([new DeviceView(deviceResponse)]);
pendingAuthRequests$.next([new AuthRequestResponse(authRequestResponse)]);
service = TestBed.inject(VaultBannersService);

View File

@@ -1,10 +1,15 @@
import { Injectable } from "@angular/core";
import { Observable, combineLatest, firstValueFrom, map, filter, mergeMap, take } from "rxjs";
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
import {
AuthRequestServiceAbstraction,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
StateProvider,
@@ -66,20 +71,33 @@ export class VaultBannersService {
private syncService: SyncService,
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private devicesService: DevicesServiceAbstraction,
private authRequestService: AuthRequestServiceAbstraction,
private configService: ConfigService,
) {}
/** Returns true when the pending auth request banner should be shown */
async shouldShowPendingAuthRequestBanner(userId: UserId): Promise<boolean> {
const devices = await firstValueFrom(this.devicesService.getDevices$());
const hasPendingRequest = devices.some(
(device) => device.response?.devicePendingAuthRequest != null,
);
const alreadyDismissed = (await this.getBannerDismissedState(userId)).includes(
VisibleVaultBanner.PendingAuthRequest,
);
// TODO: PM-20439 remove feature flag
const browserLoginApprovalFeatureFlag = await firstValueFrom(
this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval),
);
if (browserLoginApprovalFeatureFlag === true) {
const pendingAuthRequests = await firstValueFrom(
this.authRequestService.getPendingAuthRequests$(),
);
return hasPendingRequest && !alreadyDismissed;
return pendingAuthRequests.length > 0 && !alreadyDismissed;
} else {
const devices = await firstValueFrom(this.devicesService.getDevices$());
const hasPendingRequest = devices.some(
(device) => device.response?.devicePendingAuthRequest != null,
);
return hasPendingRequest && !alreadyDismissed;
}
}
shouldShowPremiumBanner$(userId: UserId): Observable<boolean> {