1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 15:23:33 +00:00

[PM-10329] Add auth request api call to desktop component v2 (#15535)

* fix: 
  - add auth request api call to desktop component v2
  - move logic to auth request service
* test: added tests for new auth request service method
This commit is contained in:
Ike
2025-07-14 18:04:22 -04:00
committed by GitHub
parent b2c2bb4c55
commit 1315e7c37c
4 changed files with 144 additions and 5 deletions

View File

@@ -15,6 +15,7 @@ import { filter, map, take } from "rxjs/operators";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service"; import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model"; 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 { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; 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 { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums"; 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 { 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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.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 collectionService: CollectionService,
private organizationService: OrganizationService, private organizationService: OrganizationService,
private folderService: FolderService, private folderService: FolderService,
private configService: ConfigService,
private authRequestService: AuthRequestServiceAbstraction,
) {} ) {}
async ngOnInit() { async ngOnInit() {
@@ -303,11 +308,26 @@ export class VaultV2Component implements OnInit, OnDestroy, CopyClickListener {
this.searchBarService.setEnabled(true); this.searchBarService.setEnabled(true);
this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault")); this.searchBarService.setPlaceholderText(this.i18nService.t("searchVault"));
const authRequest = await this.apiService.getLastAuthRequest().catch(() => null); if (
if (authRequest != null) { (await firstValueFrom(
this.messagingService.send("openLoginApproval", { this.configService.getFeatureFlag$(FeatureFlag.PM14938_BrowserExtensionLoginApproval),
notificationId: authRequest.id, )) === 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( this.activeUserId = await firstValueFrom(

View File

@@ -47,6 +47,12 @@ export abstract class AuthRequestServiceAbstraction {
* The array will be empty if there are no pending auth requests. * The array will be empty if there are no pending auth requests.
*/ */
abstract getPendingAuthRequests$(): Observable<Array<AuthRequestResponse>>; abstract getPendingAuthRequests$(): Observable<Array<AuthRequestResponse>>;
/**
* 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<AuthRequestResponse> | null;
/** /**
* Approve or deny an auth request. * Approve or deny an auth request.
* @param approve True to approve, false to deny. * @param approve True to approve, false to deny.

View File

@@ -1,9 +1,11 @@
import { mock } from "jest-mock-extended"; import { mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response"; import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; 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 { 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 { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service"; import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -120,6 +122,7 @@ describe("AuthRequestService", () => {
); );
}); });
}); });
describe("setUserKeyAfterDecryptingSharedUserKey", () => { describe("setUserKeyAfterDecryptingSharedUserKey", () => {
it("decrypts and sets user key when given valid auth request response and private key", async () => { it("decrypts and sets user key when given valid auth request response and private key", async () => {
// Arrange // Arrange
@@ -237,4 +240,99 @@ describe("AuthRequestService", () => {
expect(phrase).toEqual(phraseUpperCase); 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);
}

View File

@@ -105,6 +105,21 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
); );
} }
getLatestPendingAuthRequest$(): Observable<AuthRequestResponse | null> {
return this.getPendingAuthRequests$().pipe(
map((authRequests: Array<AuthRequestResponse>) => {
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( async approveOrDenyAuthRequest(
approve: boolean, approve: boolean,
authRequest: AuthRequestResponse, authRequest: AuthRequestResponse,