1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-21 03:43:58 +00:00

Merge main

This commit is contained in:
Bernd Schoolmann
2026-01-13 14:03:30 +01:00
605 changed files with 32014 additions and 5329 deletions

View File

@@ -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>;

View File

@@ -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)));
}
/**

View File

@@ -64,6 +64,7 @@ describe("ORGANIZATIONS state", () => {
isAdminInitiated: false,
ssoEnabled: false,
ssoMemberDecryptionType: undefined,
useDisableSMAdsForUsers: false,
usePhishingBlocker: false,
},
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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");

View File

@@ -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.

View File

@@ -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>;
}

View File

@@ -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();
});
});
});

View File

@@ -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");
}
}
}
}

View File

@@ -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",
);
});
});
});

View File

@@ -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");
}
}

View File

@@ -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");
}
}

View File

@@ -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!,
};
}
}
};
}

View 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");
}
}

View 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");
}
}

View 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");
}
}

View File

@@ -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;

View File

@@ -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");

View File

@@ -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();
});

View File

@@ -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",
});
});
});
});
});

View File

@@ -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;

View File

@@ -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();
});
});
});

View File

@@ -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 {

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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";

View File

@@ -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);

View 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];

View File

@@ -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>;
}

View File

@@ -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.

View File

@@ -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>;

View File

@@ -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;
}

View File

@@ -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();
});
});

View File

@@ -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") {

View File

@@ -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));

View File

@@ -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];
}
}

View File

@@ -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({

View File

@@ -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,

View File

@@ -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", () => {

View File

@@ -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(