mirror of
https://github.com/bitwarden/browser
synced 2026-02-21 03:43:58 +00:00
Merge main
This commit is contained in:
@@ -446,6 +446,13 @@ export abstract class ApiService {
|
||||
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
|
||||
abstract postSetupPayment(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Retrieves the bearer access token for the user.
|
||||
* If the access token is expired or within 5 minutes of expiration, attempts to refresh the token
|
||||
* and persists the refresh token to state before returning it.
|
||||
* @param userId The user for whom we're retrieving the access token
|
||||
* @returns The access token, or an Error if no access token exists.
|
||||
*/
|
||||
abstract getActiveBearerToken(userId: UserId): Promise<string>;
|
||||
abstract fetch(request: Request): Promise<Response>;
|
||||
abstract nativeFetch(request: Request): Promise<Response>;
|
||||
|
||||
@@ -75,8 +75,8 @@ export function canAccessEmergencyAccess(
|
||||
) {
|
||||
return combineLatest([
|
||||
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
|
||||
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
|
||||
policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policyAppliesToUser]) => !(enabled && policyAppliesToUser)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
isAdminInitiated: false,
|
||||
ssoEnabled: false,
|
||||
ssoMemberDecryptionType: undefined,
|
||||
useDisableSMAdsForUsers: false,
|
||||
usePhishingBlocker: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export class OrganizationData {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
@@ -133,6 +134,7 @@ export class OrganizationData {
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useAccessIntelligence = response.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
this.useDisableSMAdsForUsers = response.useDisableSMAdsForUsers ?? false;
|
||||
this.isAdminInitiated = response.isAdminInitiated;
|
||||
this.ssoEnabled = response.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = response.ssoMemberDecryptionType;
|
||||
|
||||
@@ -95,6 +95,7 @@ export class Organization {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
@@ -160,6 +161,7 @@ export class Organization {
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useAccessIntelligence = obj.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
this.useDisableSMAdsForUsers = obj.useDisableSMAdsForUsers ?? false;
|
||||
this.isAdminInitiated = obj.isAdminInitiated;
|
||||
this.ssoEnabled = obj.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType;
|
||||
@@ -381,6 +383,13 @@ export class Organization {
|
||||
return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not call this function to perform business logic, use the function in @link AutomaticUserConfirmationService instead.
|
||||
**/
|
||||
get canManageAutoConfirm() {
|
||||
return this.isMember && this.canManageUsers && this.useAutomaticUserConfirmation;
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<Organization>) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
|
||||
@@ -38,6 +38,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
@@ -81,6 +82,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false;
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
|
||||
|
||||
@@ -61,6 +61,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
@@ -135,6 +136,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false;
|
||||
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
|
||||
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
|
||||
this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response";
|
||||
import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart } from "@bitwarden/pricing";
|
||||
import {
|
||||
BitwardenSubscription,
|
||||
Storage,
|
||||
SubscriptionStatus,
|
||||
SubscriptionStatuses,
|
||||
} from "@bitwarden/subscription";
|
||||
|
||||
export class BitwardenSubscriptionResponse extends BaseResponse {
|
||||
status: SubscriptionStatus;
|
||||
cart: Cart;
|
||||
storage: Storage;
|
||||
cancelAt?: Date;
|
||||
canceled?: Date;
|
||||
nextCharge?: Date;
|
||||
suspension?: Date;
|
||||
gracePeriod?: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const status = this.getResponseProperty("Status");
|
||||
if (
|
||||
status !== SubscriptionStatuses.Incomplete &&
|
||||
status !== SubscriptionStatuses.IncompleteExpired &&
|
||||
status !== SubscriptionStatuses.Trialing &&
|
||||
status !== SubscriptionStatuses.Active &&
|
||||
status !== SubscriptionStatuses.PastDue &&
|
||||
status !== SubscriptionStatuses.Canceled &&
|
||||
status !== SubscriptionStatuses.Unpaid
|
||||
) {
|
||||
throw new Error(`Failed to parse invalid subscription status: ${status}`);
|
||||
}
|
||||
this.status = status;
|
||||
|
||||
this.cart = new CartResponse(this.getResponseProperty("Cart"));
|
||||
this.storage = new StorageResponse(this.getResponseProperty("Storage"));
|
||||
|
||||
const suspension = this.getResponseProperty("Suspension");
|
||||
if (suspension) {
|
||||
this.suspension = new Date(suspension);
|
||||
}
|
||||
|
||||
const gracePeriod = this.getResponseProperty("GracePeriod");
|
||||
if (gracePeriod) {
|
||||
this.gracePeriod = gracePeriod;
|
||||
}
|
||||
|
||||
const nextCharge = this.getResponseProperty("NextCharge");
|
||||
if (nextCharge) {
|
||||
this.nextCharge = new Date(nextCharge);
|
||||
}
|
||||
|
||||
const cancelAt = this.getResponseProperty("CancelAt");
|
||||
if (cancelAt) {
|
||||
this.cancelAt = new Date(cancelAt);
|
||||
}
|
||||
|
||||
const canceled = this.getResponseProperty("Canceled");
|
||||
if (canceled) {
|
||||
this.canceled = new Date(canceled);
|
||||
}
|
||||
}
|
||||
|
||||
toDomain = (): BitwardenSubscription => {
|
||||
switch (this.status) {
|
||||
case SubscriptionStatuses.Incomplete:
|
||||
case SubscriptionStatuses.IncompleteExpired:
|
||||
case SubscriptionStatuses.PastDue:
|
||||
case SubscriptionStatuses.Unpaid: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
suspension: this.suspension!,
|
||||
gracePeriod: this.gracePeriod!,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Trialing:
|
||||
case SubscriptionStatuses.Active: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
nextCharge: this.nextCharge!,
|
||||
cancelAt: this.cancelAt,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Canceled: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
canceled: this.canceled!,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart, CartItem, Discount } from "@bitwarden/pricing";
|
||||
|
||||
import { DiscountResponse } from "./discount.response";
|
||||
|
||||
export class CartItemResponse extends BaseResponse implements CartItem {
|
||||
translationKey: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.translationKey = this.getResponseProperty("TranslationKey");
|
||||
this.quantity = this.getResponseProperty("Quantity");
|
||||
this.cost = this.getResponseProperty("Cost");
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = discount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalStorage = this.getResponseProperty("AdditionalStorage");
|
||||
if (additionalStorage) {
|
||||
this.additionalStorage = new CartItemResponse(additionalStorage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SecretsManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts");
|
||||
if (additionalServiceAccounts) {
|
||||
this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CartResponse extends BaseResponse implements Cart {
|
||||
passwordManager: {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
};
|
||||
secretsManager?: {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
};
|
||||
cadence: SubscriptionCadence;
|
||||
discount?: Discount;
|
||||
estimatedTax: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.passwordManager = new PasswordManagerCartItemResponse(
|
||||
this.getResponseProperty("PasswordManager"),
|
||||
);
|
||||
|
||||
const secretsManager = this.getResponseProperty("SecretsManager");
|
||||
if (secretsManager) {
|
||||
this.secretsManager = new SecretsManagerCartItemResponse(secretsManager);
|
||||
}
|
||||
|
||||
const cadence = this.getResponseProperty("Cadence");
|
||||
if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) {
|
||||
throw new Error(`Failed to parse invalid cadence: ${cadence}`);
|
||||
}
|
||||
this.cadence = cadence;
|
||||
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = new DiscountResponse(discount);
|
||||
}
|
||||
|
||||
this.estimatedTax = this.getResponseProperty("EstimatedTax");
|
||||
}
|
||||
}
|
||||
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing";
|
||||
|
||||
export class DiscountResponse extends BaseResponse implements Discount {
|
||||
type: DiscountType;
|
||||
value: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const type = this.getResponseProperty("Type");
|
||||
if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) {
|
||||
throw new Error(`Failed to parse invalid discount type: ${type}`);
|
||||
}
|
||||
this.type = type;
|
||||
this.value = this.getResponseProperty("Value");
|
||||
}
|
||||
}
|
||||
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Storage } from "@bitwarden/subscription";
|
||||
|
||||
export class StorageResponse extends BaseResponse implements Storage {
|
||||
available: number;
|
||||
used: number;
|
||||
readableUsed: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.available = this.getResponseProperty("Available");
|
||||
this.used = this.getResponseProperty("Used");
|
||||
this.readableUsed = this.getResponseProperty("ReadableUsed");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
CreateDefaultLocation = "pm-19467-create-default-location",
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
|
||||
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
|
||||
@@ -28,11 +27,12 @@ export enum FeatureFlag {
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
|
||||
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
|
||||
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
|
||||
PM26462_Milestone_3 = "pm-26462-milestone-3",
|
||||
PM23341_Milestone_2 = "pm-23341-milestone-2",
|
||||
PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page",
|
||||
PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -40,7 +40,6 @@ export enum FeatureFlag {
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
@@ -57,7 +56,6 @@ export enum FeatureFlag {
|
||||
/* DIRT */
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
PhishingDetection = "phishing-detection",
|
||||
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
|
||||
|
||||
/* Vault */
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
@@ -80,6 +78,9 @@ export enum FeatureFlag {
|
||||
|
||||
/* UIF */
|
||||
RouterFocusManagement = "router-focus-management",
|
||||
|
||||
/* Secrets Manager */
|
||||
SM1719_RemoveSecretsManagerAds = "sm-1719-remove-secrets-manager-ads",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -97,7 +98,6 @@ const FALSE = false as boolean;
|
||||
*/
|
||||
export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.CreateDefaultLocation]: FALSE,
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
|
||||
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
|
||||
@@ -117,7 +117,6 @@ export const DefaultFeatureFlagValue = {
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
@@ -136,11 +135,12 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
|
||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
[FeatureFlag.PM23341_Milestone_2]: FALSE,
|
||||
[FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE,
|
||||
[FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
@@ -148,7 +148,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
@@ -166,6 +165,9 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* UIF */
|
||||
[FeatureFlag.RouterFocusManagement]: FALSE,
|
||||
|
||||
/* Secrets Manager */
|
||||
[FeatureFlag.SM1719_RemoveSecretsManagerAds]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
|
||||
if (salt == null) {
|
||||
const bytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
salt = Utils.fromBufferToUtf8(bytes);
|
||||
salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer);
|
||||
}
|
||||
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
|
||||
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -449,4 +449,800 @@ describe("ApiService", () => {
|
||||
).rejects.toThrow(InsecureUrlNotAllowedError);
|
||||
expect(nativeFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("When a 401 Unauthorized status is received", () => {
|
||||
it("retries request with refreshed token when initial request with access token returns 401", async () => {
|
||||
// This test verifies the 401 retry flow:
|
||||
// 1. Initial request with valid token returns 401 (token expired server-side)
|
||||
// 2. After 401, buildRequest is called again, which checks tokenNeedsRefresh
|
||||
// 3. tokenNeedsRefresh returns true, triggering refreshToken via getActiveBearerToken
|
||||
// 4. refreshToken makes an HTTP call to /connect/token to get new tokens
|
||||
// 5. setTokens is called to store the new tokens, returning the refreshed access token
|
||||
// 6. Request is retried with the refreshed token and succeeds
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
|
||||
// First call (initial request): token doesn't need refresh yet
|
||||
// Subsequent calls (after 401): token needs refresh, triggering the refresh flow
|
||||
tokenService.tokenNeedsRefresh
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith("new_access_token")
|
||||
.mockResolvedValue({ sub: testActiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
"new_access_token",
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
"new_refresh_token",
|
||||
)
|
||||
.mockResolvedValue({ accessToken: "new_access_token" });
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
let callCount = 0;
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
callCount++;
|
||||
|
||||
// First call: initial request with expired token returns 401
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Second call: token refresh request
|
||||
if (callCount === 2 && request.url.includes("identity")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new_access_token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "new_refresh_token",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Third call: retry with refreshed token succeeds
|
||||
if (callCount === 3) {
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: "success" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
const response = await sut.send("GET", "/something", null, true, true, null, null);
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(3);
|
||||
expect(response).toEqual({ data: "success" });
|
||||
});
|
||||
|
||||
it("does not retry when request has no access token and returns 401", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, false, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Unauthorized" });
|
||||
|
||||
// Should only be called once (no retry)
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry when request returns non-401 error", async () => {
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
|
||||
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ message: "Bad Request" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Bad Request" });
|
||||
|
||||
// Should only be called once (no retry for non-401 errors)
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not attempt to log out unauthenticated user", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, false, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Unauthorized" });
|
||||
|
||||
expect(logoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry when hasResponse is false", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
|
||||
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
// When hasResponse is false, the method should throw even though no retry happens
|
||||
await expect(
|
||||
async () => await sut.send("POST", "/something", null, true, false, null, null),
|
||||
).rejects.toMatchObject({ message: "Unauthorized" });
|
||||
|
||||
// Should only be called once (no retry when hasResponse is false)
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses original user token for retry even if active user changes between requests", async () => {
|
||||
// Setup: Initial request is for testActiveUser, but during the retry, the active user switches
|
||||
// to testInactiveUser. The retry should still use testActiveUser's refreshed token.
|
||||
|
||||
let activeUserId = testActiveUser;
|
||||
|
||||
// Mock accountService to return different active users based on when it's called
|
||||
accountService.activeAccount$ = of({
|
||||
id: activeUserId,
|
||||
...mockAccountInfoWith({
|
||||
email: "user1@example.com",
|
||||
name: "Test Name",
|
||||
}),
|
||||
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://inactive.example.com",
|
||||
getIdentityUrl: () => "https://identity.inactive.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue("active_access_token");
|
||||
tokenService.tokenNeedsRefresh
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
tokenService.getRefreshToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue("active_refresh_token");
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith("active_new_access_token")
|
||||
.mockResolvedValue({ sub: testActiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
"active_new_access_token",
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
"active_new_refresh_token",
|
||||
)
|
||||
.mockResolvedValue({ accessToken: "active_new_access_token" });
|
||||
|
||||
// Mock tokens for inactive user (should NOT be used)
|
||||
tokenService.getAccessToken
|
||||
.calledWith(testInactiveUser)
|
||||
.mockResolvedValue("inactive_access_token");
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
let callCount = 0;
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
callCount++;
|
||||
|
||||
// First call: initial request with active user's token returns 401
|
||||
if (callCount === 1) {
|
||||
expect(request.url).toBe("https://example.com/something");
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer active_access_token");
|
||||
|
||||
// After the 401, simulate active user changing
|
||||
activeUserId = testInactiveUser;
|
||||
accountService.activeAccount$ = of({
|
||||
id: testInactiveUser,
|
||||
...mockAccountInfoWith({
|
||||
email: "user2@example.com",
|
||||
name: "Inactive User",
|
||||
}),
|
||||
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
|
||||
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Second call: token refresh request for ORIGINAL user (testActiveUser)
|
||||
if (callCount === 2 && request.url.includes("identity")) {
|
||||
expect(request.url).toContain("identity.example.com");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "active_new_access_token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "active_new_refresh_token",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Third call: retry with ORIGINAL user's refreshed token, NOT the new active user's token
|
||||
if (callCount === 3) {
|
||||
expect(request.url).toBe("https://example.com/something");
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer active_new_access_token");
|
||||
// Verify we're NOT using the inactive user's endpoint
|
||||
expect(request.url).not.toContain("inactive");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: "success with original user" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
// Explicitly pass testActiveUser to ensure the request is for that specific user
|
||||
const response = await sut.send("GET", "/something", null, testActiveUser, true, null, null);
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(3);
|
||||
expect(response).toEqual({ data: "success with original user" });
|
||||
|
||||
// Verify that inactive user's token was never requested
|
||||
expect(tokenService.getAccessToken.calledWith(testInactiveUser)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws error when retry also returns 401", async () => {
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
|
||||
// First call (initial request): token doesn't need refresh yet
|
||||
// Subsequent calls (after 401): token needs refresh, triggering the refresh flow
|
||||
tokenService.tokenNeedsRefresh
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith("new_access_token")
|
||||
.mockResolvedValue({ sub: testActiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
"new_access_token",
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
"new_refresh_token",
|
||||
)
|
||||
.mockResolvedValue({ accessToken: "new_access_token" });
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
let callCount = 0;
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
callCount++;
|
||||
|
||||
// First call: initial request with expired token returns 401
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Second call: token refresh request
|
||||
if (callCount === 2 && request.url.includes("identity")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new_access_token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "new_refresh_token",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Third call: retry with refreshed token still returns 401 (user no longer has permission)
|
||||
if (callCount === 3) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Still Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
throw new Error("Unexpected call");
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Still Unauthorized" });
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(3);
|
||||
expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
|
||||
});
|
||||
|
||||
it("handles concurrent requests that both receive 401 and share token refresh", async () => {
|
||||
// This test verifies the race condition scenario:
|
||||
// 1. Request A starts with valid token
|
||||
// 2. Request B starts with valid token
|
||||
// 3. Request A gets 401, triggers refresh
|
||||
// 4. Request B gets 401 while A is refreshing
|
||||
// 5. Request B should wait for A's refresh to complete (via refreshTokenPromise cache)
|
||||
// 6. Both requests retry with the new token
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
|
||||
|
||||
// First two calls: token doesn't need refresh yet
|
||||
// Subsequent calls: token needs refresh
|
||||
tokenService.tokenNeedsRefresh
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValueOnce(false) // Request A initial
|
||||
.mockResolvedValueOnce(false) // Request B initial
|
||||
.mockResolvedValue(true); // Both retries after 401
|
||||
|
||||
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith("new_access_token")
|
||||
.mockResolvedValue({ sub: testActiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
"new_access_token",
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
"new_refresh_token",
|
||||
)
|
||||
.mockResolvedValue({ accessToken: "new_access_token" });
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
let apiRequestCount = 0;
|
||||
let refreshRequestCount = 0;
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
if (request.url.includes("identity")) {
|
||||
refreshRequestCount++;
|
||||
// Simulate slow token refresh to expose race condition
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new_access_token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "new_refresh_token",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response),
|
||||
100,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
apiRequestCount++;
|
||||
const currentCall = apiRequestCount;
|
||||
|
||||
// First two calls (Request A and B initial attempts): both return 401
|
||||
if (currentCall === 1 || currentCall === 2) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Third and fourth calls (retries after refresh): both succeed
|
||||
if (currentCall === 3 || currentCall === 4) {
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: `success-${currentCall}` }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected API call #${currentCall}: ${request.method} ${request.url}`);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
// Make two concurrent requests
|
||||
const [responseA, responseB] = await Promise.all([
|
||||
sut.send("GET", "/endpoint-a", null, testActiveUser, true, null, null),
|
||||
sut.send("GET", "/endpoint-b", null, testActiveUser, true, null, null),
|
||||
]);
|
||||
|
||||
// Both requests should succeed
|
||||
expect(responseA).toMatchObject({ data: expect.stringContaining("success") });
|
||||
expect(responseB).toMatchObject({ data: expect.stringContaining("success") });
|
||||
|
||||
// Verify only ONE token refresh was made (they shared the refresh)
|
||||
expect(refreshRequestCount).toBe(1);
|
||||
|
||||
// Verify the total number of API requests: 2 initial + 2 retries = 4
|
||||
expect(apiRequestCount).toBe(4);
|
||||
|
||||
// Verify setTokens was only called once
|
||||
expect(tokenService.setTokens).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("When 403 Forbidden response is received from API request", () => {
|
||||
it("logs out the authenticated user", async () => {
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
|
||||
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ message: "Forbidden" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Forbidden" });
|
||||
|
||||
expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
|
||||
});
|
||||
|
||||
it("does not attempt to log out unauthenticated user", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ message: "Forbidden" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, false, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Forbidden" });
|
||||
|
||||
expect(logoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ import { BillingHistoryResponse } from "../billing/models/response/billing-histo
|
||||
import { PaymentResponse } from "../billing/models/response/payment.response";
|
||||
import { PlanResponse } from "../billing/models/response/plan.response";
|
||||
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
|
||||
import { ClientType, DeviceType } from "../enums";
|
||||
import { ClientType, DeviceType, HttpStatusCode } from "../enums";
|
||||
import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
|
||||
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
|
||||
import { VaultTimeoutSettingsService } from "../key-management/vault-timeout";
|
||||
@@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new PaymentResponse(r);
|
||||
}
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
postReinstatePremium(): Promise<any> {
|
||||
return this.send("POST", "/accounts/reinstate-premium", null, true, false);
|
||||
}
|
||||
@@ -1252,8 +1253,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const error = await this.handleError(response, false, true);
|
||||
if (response.status !== HttpStatusCode.Ok) {
|
||||
const error = await this.handleApiRequestError(response, true);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -1283,8 +1284,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const error = await this.handleError(response, false, true);
|
||||
if (response.status !== HttpStatusCode.Ok) {
|
||||
const error = await this.handleApiRequestError(response, true);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -1301,14 +1302,12 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const error = await this.handleError(response, false, true);
|
||||
if (response.status !== HttpStatusCode.Ok) {
|
||||
const error = await this.handleApiRequestError(response, true);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
async getActiveBearerToken(userId: UserId): Promise<string> {
|
||||
let accessToken = await this.tokenService.getAccessToken(userId);
|
||||
if (await this.tokenService.tokenNeedsRefresh(userId)) {
|
||||
@@ -1370,7 +1369,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const body = await response.json();
|
||||
return new SsoPreValidateResponse(body);
|
||||
} else {
|
||||
const error = await this.handleError(response, false, true);
|
||||
const error = await this.handleApiRequestError(response, false);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -1525,7 +1524,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
);
|
||||
return refreshedTokens.accessToken;
|
||||
} else {
|
||||
const error = await this.handleError(response, true, true);
|
||||
const error = await this.handleTokenRefreshRequestError(response);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -1580,6 +1579,89 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
): Promise<any> {
|
||||
// We assume that if there is a UserId making the request, it is also an authenticated
|
||||
// request and we will attempt to add an access token to the request.
|
||||
const userIdMakingRequest = await this.getUserIdMakingRequest(authedOrUserId);
|
||||
|
||||
const environment = await firstValueFrom(
|
||||
userIdMakingRequest == null
|
||||
? this.environmentService.environment$
|
||||
: this.environmentService.getEnvironment$(userIdMakingRequest),
|
||||
);
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? environment.getApiUrl() : apiUrl;
|
||||
|
||||
const requestUrl = await this.buildSafeApiRequestUrl(apiUrl, path);
|
||||
|
||||
let request = await this.buildRequest(
|
||||
method,
|
||||
userIdMakingRequest,
|
||||
environment,
|
||||
hasResponse,
|
||||
body,
|
||||
alterHeaders,
|
||||
);
|
||||
|
||||
let response = await this.fetch(this.httpOperations.createRequest(requestUrl, request));
|
||||
|
||||
// First, check to see if we were making an authenticated request and received an Unauthorized (401)
|
||||
// response. This could mean that we attempted to make a request with an expired access token.
|
||||
// If so, attempt to refresh the token and try again.
|
||||
if (
|
||||
hasResponse &&
|
||||
userIdMakingRequest != null &&
|
||||
response.status === HttpStatusCode.Unauthorized
|
||||
) {
|
||||
this.logService.warning(
|
||||
"Unauthorized response received for request to " + path + ". Attempting request again.",
|
||||
);
|
||||
request = await this.buildRequest(
|
||||
method,
|
||||
userIdMakingRequest,
|
||||
environment,
|
||||
hasResponse,
|
||||
body,
|
||||
alterHeaders,
|
||||
);
|
||||
response = await this.fetch(this.httpOperations.createRequest(requestUrl, request));
|
||||
}
|
||||
|
||||
// At this point we are processing either the initial response or the response for the retry with the refreshed
|
||||
// access token.
|
||||
const responseType = response.headers.get("content-type");
|
||||
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
|
||||
const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1;
|
||||
if (hasResponse && response.status === HttpStatusCode.Ok && responseIsJson) {
|
||||
const responseJson = await response.json();
|
||||
return responseJson;
|
||||
} else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsCsv) {
|
||||
return await response.text();
|
||||
} else if (
|
||||
response.status !== HttpStatusCode.Ok &&
|
||||
response.status !== HttpStatusCode.NoContent
|
||||
) {
|
||||
const error = await this.handleApiRequestError(response, userIdMakingRequest != null);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSafeApiRequestUrl(apiUrl: string, path: string): string {
|
||||
const pathParts = path.split("?");
|
||||
|
||||
// Check for path traversal patterns from any URL.
|
||||
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
|
||||
if (isInvalidUrl) {
|
||||
throw new Error("The request URL contains dangerous patterns.");
|
||||
}
|
||||
|
||||
const requestUrl =
|
||||
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
return requestUrl;
|
||||
}
|
||||
|
||||
private async getUserIdMakingRequest(authedOrUserId: UserId | boolean): Promise<UserId> {
|
||||
if (authedOrUserId == null) {
|
||||
throw new Error("A user id was given but it was null, cannot complete API request.");
|
||||
}
|
||||
@@ -1591,29 +1673,19 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
} else if (typeof authedOrUserId === "string") {
|
||||
userId = authedOrUserId;
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
const env = await firstValueFrom(
|
||||
userId == null
|
||||
? this.environmentService.environment$
|
||||
: this.environmentService.getEnvironment$(userId),
|
||||
);
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
|
||||
|
||||
const pathParts = path.split("?");
|
||||
// Check for path traversal patterns from any URL.
|
||||
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
|
||||
if (isInvalidUrl) {
|
||||
throw new Error("The request URL contains dangerous patterns.");
|
||||
}
|
||||
|
||||
// Prevent directory traversal from malicious paths
|
||||
const requestUrl =
|
||||
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
private async buildRequest(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
userForAccessToken: UserId | null,
|
||||
environment: Environment,
|
||||
hasResponse: boolean,
|
||||
body: string,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
): Promise<RequestInit> {
|
||||
const [requestHeaders, requestBody] = await this.buildHeadersAndBody(
|
||||
userId,
|
||||
userForAccessToken,
|
||||
hasResponse,
|
||||
body,
|
||||
alterHeaders,
|
||||
@@ -1621,29 +1693,17 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
cache: "no-store",
|
||||
credentials: await this.getCredentials(env),
|
||||
credentials: await this.getCredentials(environment),
|
||||
method: method,
|
||||
};
|
||||
requestInit.headers = requestHeaders;
|
||||
requestInit.body = requestBody;
|
||||
const response = await this.fetch(this.httpOperations.createRequest(requestUrl, requestInit));
|
||||
|
||||
const responseType = response.headers.get("content-type");
|
||||
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
|
||||
const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1;
|
||||
if (hasResponse && response.status === 200 && responseIsJson) {
|
||||
const responseJson = await response.json();
|
||||
return responseJson;
|
||||
} else if (hasResponse && response.status === 200 && responseIsCsv) {
|
||||
return await response.text();
|
||||
} else if (response.status !== 200 && response.status !== 204) {
|
||||
const error = await this.handleError(response, false, userId != null);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return requestInit;
|
||||
}
|
||||
|
||||
private async buildHeadersAndBody(
|
||||
userToAuthenticate: UserId | null,
|
||||
userForAccessToken: UserId | null,
|
||||
hasResponse: boolean,
|
||||
body: any,
|
||||
alterHeaders: (headers: Headers) => void,
|
||||
@@ -1665,8 +1725,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
if (alterHeaders != null) {
|
||||
alterHeaders(headers);
|
||||
}
|
||||
if (userToAuthenticate != null) {
|
||||
const authHeader = await this.getActiveBearerToken(userToAuthenticate);
|
||||
if (userForAccessToken != null) {
|
||||
const authHeader = await this.getActiveBearerToken(userForAccessToken);
|
||||
headers.set("Authorization", "Bearer " + authHeader);
|
||||
} else {
|
||||
// For unauthenticated requests, we need to tell the server what the device is for flag targeting,
|
||||
@@ -1692,32 +1752,59 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return [headers, requestBody];
|
||||
}
|
||||
|
||||
private async handleError(
|
||||
/**
|
||||
* Handle an error response from a request to the Bitwarden API.
|
||||
* If the request is made with an access token (aka the user is authenticated),
|
||||
* and we receive a 401 or 403 response, we will log the user out, as this indicates
|
||||
* that the access token used on the request is either expired or does not have the appropriate permissions.
|
||||
* It is unlikely that it is expired, as we attempt to refresh the token on initial failure.
|
||||
* @param response The response from the API request
|
||||
* @param userIsAuthenticated A boolean indicating whether this is an authenticated request.
|
||||
* @returns An ErrorResponse with a message based on the response status.
|
||||
*/
|
||||
private async handleApiRequestError(
|
||||
response: Response,
|
||||
tokenError: boolean,
|
||||
authed: boolean,
|
||||
userIsAuthenticated: boolean,
|
||||
): Promise<ErrorResponse> {
|
||||
if (
|
||||
userIsAuthenticated &&
|
||||
(response.status === HttpStatusCode.Unauthorized ||
|
||||
response.status === HttpStatusCode.Forbidden)
|
||||
) {
|
||||
await this.logoutCallback("invalidAccessToken");
|
||||
}
|
||||
|
||||
const responseJson = await this.getJsonResponse(response);
|
||||
return new ErrorResponse(responseJson, response.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error response when trying to refresh an access token.
|
||||
* If the error indicates that the user's session has expired, it will log the user out.
|
||||
* @param response The response from the token refresh request.
|
||||
* @returns An ErrorResponse with a message based on the response status.
|
||||
*/
|
||||
private async handleTokenRefreshRequestError(response: Response): Promise<ErrorResponse> {
|
||||
const responseJson = await this.getJsonResponse(response);
|
||||
|
||||
// IdentityServer will return an invalid_grant response if the refresh token has expired.
|
||||
// This means that the user's session has expired, and they need to log out.
|
||||
// We issue the logoutCallback() to log the user out through messaging.
|
||||
if (response.status === HttpStatusCode.BadRequest && responseJson?.error === "invalid_grant") {
|
||||
await this.logoutCallback("sessionExpired");
|
||||
}
|
||||
|
||||
return new ErrorResponse(responseJson, response.status, true);
|
||||
}
|
||||
|
||||
private async getJsonResponse(response: Response): Promise<any> {
|
||||
let responseJson: any = null;
|
||||
if (this.isJsonResponse(response)) {
|
||||
responseJson = await response.json();
|
||||
} else if (this.isTextPlainResponse(response)) {
|
||||
responseJson = { Message: await response.text() };
|
||||
}
|
||||
|
||||
if (authed) {
|
||||
if (
|
||||
response.status === 401 ||
|
||||
response.status === 403 ||
|
||||
(tokenError &&
|
||||
response.status === 400 &&
|
||||
responseJson != null &&
|
||||
responseJson.error === "invalid_grant")
|
||||
) {
|
||||
await this.logoutCallback("invalidGrantError");
|
||||
}
|
||||
}
|
||||
|
||||
return new ErrorResponse(responseJson, response.status, tokenError);
|
||||
return responseJson;
|
||||
}
|
||||
|
||||
private qsStringify(params: any): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendResponse } from "../response/send.response";
|
||||
|
||||
import { SendFileData } from "./send-file.data";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { mockContainerService, mockEnc } from "../../../../../spec";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccessResponse } from "../response/send-access.response";
|
||||
|
||||
import { SendAccess } from "./send-access";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import Domain from "../../../../platform/models/domain/domain-base";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccessResponse } from "../response/send-access.response";
|
||||
import { SendAccessView } from "../view/send-access.view";
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "../../../../platform/services/container.service";
|
||||
import { UserKey } from "../../../../types/key";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
|
||||
import { Send } from "./send";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import Domain from "../../../../platform/models/domain/domain-base";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
import { SendView } from "../view/send.view";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
import { Send } from "../domain/send";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { View } from "../../../../models/view/view";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccess } from "../domain/send-access";
|
||||
|
||||
import { SendFileView } from "./send-file.view";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { View } from "../../../../models/view/view";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { DeepJsonify } from "../../../../types/deep-jsonify";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { Send } from "../domain/send";
|
||||
|
||||
import { SendFileView } from "./send-file.view";
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
FileUploadService,
|
||||
} from "../../../platform/abstractions/file-upload/file-upload.service";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { Send } from "../models/domain/send";
|
||||
import { SendAccessRequest } from "../models/request/send-access.request";
|
||||
@@ -16,6 +15,7 @@ import { SendFileDownloadDataResponse } from "../models/response/send-file-downl
|
||||
import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response";
|
||||
import { SendResponse } from "../models/response/send.response";
|
||||
import { SendAccessView } from "../models/view/send-access.view";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction";
|
||||
import { InternalSendService } from "./send.service.abstraction";
|
||||
|
||||
@@ -24,13 +24,13 @@ import { ContainerService } from "../../../platform/services/container.service";
|
||||
import { SelfHostedEnvironment } from "../../../platform/services/default-environment.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendFileApi } from "../models/api/send-file.api";
|
||||
import { SendTextApi } from "../models/api/send-text.api";
|
||||
import { SendFileData } from "../models/data/send-file.data";
|
||||
import { SendTextData } from "../models/data/send-text.data";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions";
|
||||
import { SendStateProvider } from "./send-state.provider";
|
||||
|
||||
@@ -16,7 +16,6 @@ import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { Send } from "../models/domain/send";
|
||||
import { SendFile } from "../models/domain/send-file";
|
||||
@@ -24,6 +23,7 @@ import { SendText } from "../models/domain/send-text";
|
||||
import { SendWithIdRequest } from "../models/request/send-with-id.request";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
import { SEND_KDF_ITERATIONS } from "../send-kdf";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SendStateProvider } from "./send-state.provider.abstraction";
|
||||
import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendTextApi } from "../../models/api/send-text.api";
|
||||
import { SendTextData } from "../../models/data/send-text.data";
|
||||
import { SendData } from "../../models/data/send.data";
|
||||
import { Send } from "../../models/domain/send";
|
||||
import { SendView } from "../../models/view/send.view";
|
||||
import { SendType } from "../../types/send-type";
|
||||
|
||||
export function testSendViewData(id: string, name: string) {
|
||||
const data = new SendView({} as any);
|
||||
|
||||
7
libs/common/src/tools/send/types/send-filter-type.ts
Normal file
7
libs/common/src/tools/send/types/send-filter-type.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const SendFilterType = Object.freeze({
|
||||
All: "all",
|
||||
Text: "text",
|
||||
File: "file",
|
||||
} as const);
|
||||
|
||||
export type SendFilterType = (typeof SendFilterType)[keyof typeof SendFilterType];
|
||||
@@ -3,12 +3,14 @@ import { Observable } from "rxjs";
|
||||
import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
|
||||
export abstract class CipherArchiveService {
|
||||
abstract hasArchiveFlagEnabled$: Observable<boolean>;
|
||||
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
|
||||
abstract userCanArchive$(userId: UserId): Observable<boolean>;
|
||||
abstract userHasPremium$(userId: UserId): Observable<boolean>;
|
||||
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
|
||||
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
|
||||
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
|
||||
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
|
||||
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ export abstract class CipherEncryptionService {
|
||||
*/
|
||||
abstract encrypt(model: CipherView, userId: UserId): Promise<EncryptionContext | undefined>;
|
||||
|
||||
/**
|
||||
* Encrypts multiple ciphers using the SDK for the given userId.
|
||||
*
|
||||
* @param models The cipher views to encrypt
|
||||
* @param userId The user ID to initialize the SDK client with
|
||||
*
|
||||
* @returns A promise that resolves to an array of encryption contexts
|
||||
*/
|
||||
abstract encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]>;
|
||||
|
||||
/**
|
||||
* Move the cipher to the specified organization by re-encrypting its keys with the organization's key.
|
||||
* The cipher.organizationId will be updated to the new organizationId.
|
||||
|
||||
@@ -50,6 +50,15 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
keyForCipherKeyDecryption?: SymmetricCryptoKey,
|
||||
originalCipher?: Cipher,
|
||||
): Promise<EncryptionContext>;
|
||||
/**
|
||||
* Encrypts multiple ciphers for the given user.
|
||||
*
|
||||
* @param models The cipher views to encrypt
|
||||
* @param userId The user ID to encrypt for
|
||||
*
|
||||
* @returns A promise that resolves to an array of encryption contexts
|
||||
*/
|
||||
abstract encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]>;
|
||||
abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise<Field[]>;
|
||||
abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise<Field>;
|
||||
abstract get(id: string, userId: UserId): Promise<Cipher>;
|
||||
|
||||
@@ -109,6 +109,10 @@ export class CipherView implements View, InitializerMetadata {
|
||||
return this.item?.subTitle;
|
||||
}
|
||||
|
||||
get canBeArchived(): boolean {
|
||||
return !this.isDeleted && !this.isArchived;
|
||||
}
|
||||
|
||||
get hasPasswordHistory(): boolean {
|
||||
return this.passwordHistory && this.passwordHistory.length > 0;
|
||||
}
|
||||
|
||||
@@ -868,7 +868,7 @@ describe("Cipher Service", () => {
|
||||
const result = await firstValueFrom(
|
||||
stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$,
|
||||
);
|
||||
expect(result[cipherId].archivedDate).toBeNull();
|
||||
expect(result[cipherId].archivedDate).toEqual("2024-01-01T12:00:00.000Z");
|
||||
expect(result[cipherId].deletedDate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,6 +340,24 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]> {
|
||||
const sdkEncryptionEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM22136_SdkCipherEncryption,
|
||||
);
|
||||
|
||||
if (sdkEncryptionEnabled) {
|
||||
return await this.cipherEncryptionService.encryptMany(models, userId);
|
||||
}
|
||||
|
||||
// Fallback to sequential encryption if SDK disabled
|
||||
const results: EncryptionContext[] = [];
|
||||
for (const model of models) {
|
||||
const result = await this.encrypt(model, userId);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async encryptAttachments(
|
||||
attachmentsModel: AttachmentView[],
|
||||
key: SymmetricCryptoKey,
|
||||
@@ -1461,7 +1479,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
ciphers[cipherId].deletedDate = new Date().toISOString();
|
||||
ciphers[cipherId].archivedDate = null;
|
||||
};
|
||||
|
||||
if (typeof id === "string") {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* include structuredClone in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom, BehaviorSubject } from "rxjs";
|
||||
|
||||
@@ -165,6 +169,7 @@ describe("DefaultCipherArchiveService", () => {
|
||||
|
||||
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
|
||||
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
featureFlag.next(true);
|
||||
|
||||
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { CipherArchiveService } from "../abstractions/cipher-archive.service";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
|
||||
export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
constructor(
|
||||
@@ -71,21 +72,30 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
|
||||
/** Returns true when the user has previously archived ciphers but lost their premium membership. */
|
||||
showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean> {
|
||||
return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe(
|
||||
map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium),
|
||||
return combineLatest([
|
||||
this.archivedCiphers$(userId),
|
||||
this.userHasPremium$(userId),
|
||||
this.hasArchiveFlagEnabled$,
|
||||
]).pipe(
|
||||
map(
|
||||
([archivedCiphers, hasPremium, flagEnabled]) =>
|
||||
flagEnabled && archivedCiphers.length > 0 && !hasPremium,
|
||||
),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
|
||||
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
|
||||
const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]);
|
||||
const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true);
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
|
||||
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
// prevent mutating ciphers$ state
|
||||
const localCiphers = structuredClone(currentCiphers);
|
||||
|
||||
for (const cipher of response.data) {
|
||||
const localCipher = currentCiphers[cipher.id as CipherId];
|
||||
const localCipher = localCiphers[cipher.id as CipherId];
|
||||
|
||||
if (localCipher == null) {
|
||||
continue;
|
||||
@@ -95,18 +105,21 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.upsert(Object.values(currentCiphers), userId);
|
||||
await this.cipherService.upsert(Object.values(localCiphers), userId);
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
|
||||
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
|
||||
const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]);
|
||||
const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true);
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
|
||||
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
// prevent mutating ciphers$ state
|
||||
const localCiphers = structuredClone(currentCiphers);
|
||||
|
||||
for (const cipher of response.data) {
|
||||
const localCipher = currentCiphers[cipher.id as CipherId];
|
||||
const localCipher = localCiphers[cipher.id as CipherId];
|
||||
|
||||
if (localCipher == null) {
|
||||
continue;
|
||||
@@ -116,6 +129,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.upsert(Object.values(currentCiphers), userId);
|
||||
await this.cipherService.upsert(Object.values(localCiphers), userId);
|
||||
return response.data[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,68 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptMany", () => {
|
||||
it("should encrypt multiple ciphers", async () => {
|
||||
const cipherView2 = new CipherView(cipherObj);
|
||||
cipherView2.name = "test-name-2";
|
||||
const cipherView3 = new CipherView(cipherObj);
|
||||
cipherView3.name = "test-name-3";
|
||||
|
||||
const ciphers = [cipherViewObj, cipherView2, cipherView3];
|
||||
|
||||
const expectedCipher1: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-1",
|
||||
} as unknown as Cipher;
|
||||
|
||||
const expectedCipher2: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-2",
|
||||
} as unknown as Cipher;
|
||||
|
||||
const expectedCipher3: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-3",
|
||||
} as unknown as Cipher;
|
||||
|
||||
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(Cipher, "fromSdkCipher")
|
||||
.mockReturnValueOnce(expectedCipher1)
|
||||
.mockReturnValueOnce(expectedCipher2)
|
||||
.mockReturnValueOnce(expectedCipher3);
|
||||
|
||||
const results = await cipherEncryptionService.encryptMany(ciphers, userId);
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].cipher).toEqual(expectedCipher1);
|
||||
expect(results[1].cipher).toEqual(expectedCipher2);
|
||||
expect(results[2].cipher).toEqual(expectedCipher3);
|
||||
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(results[0].encryptedFor).toBe(userId);
|
||||
expect(results[1].encryptedFor).toBe(userId);
|
||||
expect(results[2].encryptedFor).toBe(userId);
|
||||
});
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
const results = await cipherEncryptionService.encryptMany([], userId);
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(results.length).toBe(0);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptCipherForRotation", () => {
|
||||
it("should call the sdk method to encrypt the cipher with a new key for rotation", async () => {
|
||||
mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation.mockReturnValue({
|
||||
|
||||
@@ -51,6 +51,44 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
);
|
||||
}
|
||||
|
||||
async encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]> {
|
||||
if (!models || models.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
map((sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
|
||||
using ref = sdk.take();
|
||||
|
||||
const results: EncryptionContext[] = [];
|
||||
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-30580
|
||||
// Replace this loop with a native SDK encryptMany method for better performance.
|
||||
for (const model of models) {
|
||||
const sdkCipherView = this.toSdkCipherView(model, ref.value);
|
||||
const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView);
|
||||
|
||||
results.push({
|
||||
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
|
||||
encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to encrypt ciphers in batch: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async moveToOrganization(
|
||||
model: CipherView,
|
||||
organizationId: OrganizationId,
|
||||
|
||||
@@ -250,6 +250,38 @@ describe("DefaultCipherRiskService", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter out deleted Login ciphers", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const activeCipher = new CipherView();
|
||||
activeCipher.id = mockCipherId1;
|
||||
activeCipher.type = CipherType.Login;
|
||||
activeCipher.login = new LoginView();
|
||||
activeCipher.login.password = "password1";
|
||||
activeCipher.deletedDate = undefined;
|
||||
|
||||
const deletedCipher = new CipherView();
|
||||
deletedCipher.id = mockCipherId2;
|
||||
deletedCipher.type = CipherType.Login;
|
||||
deletedCipher.login = new LoginView();
|
||||
deletedCipher.login.password = "password2";
|
||||
deletedCipher.deletedDate = new Date();
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers([activeCipher, deletedCipher], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
password: "password1",
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPasswordReuseMap", () => {
|
||||
@@ -284,6 +316,41 @@ describe("DefaultCipherRiskService", () => {
|
||||
]);
|
||||
expect(result).toEqual(mockReuseMap);
|
||||
});
|
||||
|
||||
it("should exclude deleted ciphers when building password reuse map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockReuseMap = {
|
||||
password1: 1,
|
||||
};
|
||||
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
|
||||
|
||||
const activeCipher = new CipherView();
|
||||
activeCipher.id = mockCipherId1;
|
||||
activeCipher.type = CipherType.Login;
|
||||
activeCipher.login = new LoginView();
|
||||
activeCipher.login.password = "password1";
|
||||
activeCipher.deletedDate = undefined;
|
||||
|
||||
const deletedCipherWithSamePassword = new CipherView();
|
||||
deletedCipherWithSamePassword.id = mockCipherId2;
|
||||
deletedCipherWithSamePassword.type = CipherType.Login;
|
||||
deletedCipherWithSamePassword.login = new LoginView();
|
||||
deletedCipherWithSamePassword.login.password = "password1";
|
||||
deletedCipherWithSamePassword.deletedDate = new Date();
|
||||
|
||||
const result = await cipherRiskService.buildPasswordReuseMap(
|
||||
[activeCipher, deletedCipherWithSamePassword],
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
]);
|
||||
expect(result).toEqual(mockReuseMap);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCipherRiskForUser", () => {
|
||||
|
||||
@@ -71,7 +71,6 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
passwordMap,
|
||||
checkExposed,
|
||||
});
|
||||
|
||||
return results[0];
|
||||
}
|
||||
|
||||
@@ -103,7 +102,8 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
return (
|
||||
cipher.type === CipherType.Login &&
|
||||
cipher.login?.password != null &&
|
||||
cipher.login.password !== ""
|
||||
cipher.login.password !== "" &&
|
||||
!cipher.isDeleted
|
||||
);
|
||||
})
|
||||
.map(
|
||||
|
||||
Reference in New Issue
Block a user