diff --git a/apps/desktop/src/vault/app/vault/vault-v2.component.ts b/apps/desktop/src/vault/app/vault/vault-v2.component.ts index 2142b5e7a4b..6eb6b737899 100644 --- a/apps/desktop/src/vault/app/vault/vault-v2.component.ts +++ b/apps/desktop/src/vault/app/vault/vault-v2.component.ts @@ -15,6 +15,7 @@ import { filter, map, take } from "rxjs/operators"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; 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"; @@ -23,7 +24,9 @@ import { Account, AccountService } from "@bitwarden/common/auth/abstractions/acc 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"; @@ -194,6 +197,8 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { private collectionService: CollectionService, private organizationService: OrganizationService, private folderService: FolderService, + private configService: ConfigService, + private authRequestService: AuthRequestServiceAbstraction, ) {} async ngOnInit() { @@ -303,11 +308,26 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener { this.searchBarService.setEnabled(true); this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); - const authRequest = await this.apiService.getLastAuthRequest().catch(() => null); - if (authRequest != null) { - this.messagingService.send("openLoginApproval", { - notificationId: authRequest.id, - }); + if ( + (await firstValueFrom( + this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval), + )) === true + ) { + const authRequests = await firstValueFrom( + this.authRequestService.getLatestPendingAuthRequest$(), + ); + if (authRequests != null) { + this.messagingService.send("openLoginApproval", { + notificationId: authRequests.id, + }); + } + } else { + const authRequest = await this.apiService.getLastAuthRequest(); + if (authRequest != null) { + this.messagingService.send("openLoginApproval", { + notificationId: authRequest.id, + }); + } } this.activeUserId = await firstValueFrom( diff --git a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts index 956fd771039..7e480c3a69c 100644 --- a/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts +++ b/libs/auth/src/common/abstractions/auth-request.service.abstraction.ts @@ -47,6 +47,12 @@ export abstract class AuthRequestServiceAbstraction { * The array will be empty if there are no pending auth requests. */ abstract getPendingAuthRequests$(): Observable>; + /** + * Get the most recent AuthRequest for the logged in user + * @returns An observable of an auth request. If there are no auth requests + * the result will be null. + */ + abstract getLatestPendingAuthRequest$(): Observable | null; /** * Approve or deny an auth request. * @param approve True to approve, false to deny. diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts index ab09e17f11f..6f4e3512a6a 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.spec.ts @@ -1,9 +1,11 @@ import { mock } from "jest-mock-extended"; +import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -120,6 +122,7 @@ describe("AuthRequestService", () => { ); }); }); + describe("setUserKeyAfterDecryptingSharedUserKey", () => { it("decrypts and sets user key when given valid auth request response and private key", async () => { // Arrange @@ -237,4 +240,99 @@ describe("AuthRequestService", () => { expect(phrase).toEqual(phraseUpperCase); }); }); + + describe("getLatestAuthRequest", () => { + it("returns newest authRequest from list of authRequests", async () => { + const now = minutesAgo(0); + const fiveMinutesAgo = minutesAgo(5); + const tenMinutesAgo = minutesAgo(10); + + const newerAuthRequest = createMockAuthRequest( + "now-request", + false, + false, + now.toISOString(), // newer request + "1fda13f4-5134-4157-90e3-b4e3fb2d855z", + ); + const olderAuthRequest = createMockAuthRequest( + "5-minute-old-request", + false, + false, + fiveMinutesAgo.toISOString(), // older request + "1fda13f4-5134-4157-90e3-b4e3fb2d855c", + ); + const oldestAuthRequest = createMockAuthRequest( + "10-minute-old-request", + false, + false, + tenMinutesAgo.toISOString(), // oldest request + "1fda13f4-5134-4157-90e3-b4e3fb2d855a", + ); + + const listResponse = new ListResponse( + { Data: [oldestAuthRequest, olderAuthRequest, newerAuthRequest] }, + AuthRequestResponse, + ); + + // Ensure the mock is properly set up to return the list response + authRequestApiService.getPendingAuthRequests.mockResolvedValue(listResponse); + + // Act + const sutReturnValue = await firstValueFrom(sut.getLatestPendingAuthRequest$()); + + // Assert + // Verify the mock was called + expect(authRequestApiService.getPendingAuthRequests).toHaveBeenCalledTimes(1); + expect(sutReturnValue.creationDate).toEqual(newerAuthRequest.creationDate); + expect(sutReturnValue.id).toEqual(newerAuthRequest.id); + }); + }); + + it("returns null from empty list of authRequests", async () => { + const listResponse = new ListResponse({ Data: [] }, AuthRequestResponse); + + // Ensure the mock is properly set up to return the list response + authRequestApiService.getPendingAuthRequests.mockResolvedValue(listResponse); + + // Act + const sutReturnValue = await firstValueFrom(sut.getLatestPendingAuthRequest$()); + + // Assert + // Verify the mock was called + expect(authRequestApiService.getPendingAuthRequests).toHaveBeenCalledTimes(1); + expect(sutReturnValue).toBeNull(); + }); }); + +function createMockAuthRequest( + id: string, + isAnswered: boolean, + isExpired: boolean, + creationDate: string, + deviceId?: string, +): AuthRequestResponse { + const authRequestResponse = new AuthRequestResponse({ + id: id, + publicKey: + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA+AIKUBDf4exqE9JDzGJegDzIoaZcNkUeewovgwSJuKuya0mP4CPP00ajmi9GEu6z3VWfB+yzx1O4gxHV/T5s620wnMYm6nAv2gDS+kEaXou4MOt7QMidq4kVhM7aixN2klKivH/E8GFPiMUzNQv0lMQthsVLLWFuMRxYfChe9Cxn9EWp7TYy4rAmi+jSTxzIGj+RC7f2qu2qdPSsKHLXtW7NA0SWhIntWbmc9QxD2nQ4qHgk/qUwvHoUhwKGNCcIDkXqMJ7ChN3v5tX1sFpwhQQrmlwiVC4+sBScfAgyYylfTPnuBd6b3UrC3D34GvHMgDvLjz7LwlBrkSXoF7xWZwIDAQAB", + requestDeviceIdentifier: "1fda13f4-5134-4157-90e3-b4e3fb2d855c", + requestDeviceTypeValue: 10, + requestDeviceType: "Firefox", + requestIpAddress: "2a04:4e40:9400:0:bb4:3591:d601:f5cc", + requestCountryName: "united states", + key: null, + masterPasswordHash: null, + creationDate: creationDate, // ISO 8601 date string : "2025-07-11T19:11:17.9866667Z" + responseDate: null, + requestApproved: false, + isAnswered: isAnswered, + isExpired: isExpired, + deviceId: deviceId, + }); + + return authRequestResponse; +} + +function minutesAgo(minutes: number): Date { + return new Date(Date.now() - minutes * 60_000); +} diff --git a/libs/auth/src/common/services/auth-request/auth-request.service.ts b/libs/auth/src/common/services/auth-request/auth-request.service.ts index 93a6ba12ffb..70d505ed6ff 100644 --- a/libs/auth/src/common/services/auth-request/auth-request.service.ts +++ b/libs/auth/src/common/services/auth-request/auth-request.service.ts @@ -105,6 +105,21 @@ export class AuthRequestService implements AuthRequestServiceAbstraction { ); } + getLatestPendingAuthRequest$(): Observable { + return this.getPendingAuthRequests$().pipe( + map((authRequests: Array) => { + if (authRequests.length === 0) { + return null; + } + return authRequests.sort((a, b) => { + const dateA = new Date(a.creationDate).getTime(); + const dateB = new Date(b.creationDate).getTime(); + return dateB - dateA; // Sort in descending order + })[0]; + }), + ); + } + async approveOrDenyAuthRequest( approve: boolean, authRequest: AuthRequestResponse,