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:
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user