1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-26 17:43:22 +00:00

Merge remote-tracking branch 'origin/main' into playwright

This commit is contained in:
Matt Gibson
2026-01-26 12:57:05 -08:00
1790 changed files with 150488 additions and 32025 deletions

View File

@@ -3,35 +3,24 @@ import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
/**
* Holds information about an account for use in the AccountService
* if more information is added, be sure to update the equality method.
* Holds state that represents a user's account with Bitwarden.
* Any additions here should be added to the equality check in the AccountService
* to ensure that emissions are done on every change.
*
* @property email - User's email address.
* @property emailVerified - Whether the email has been verified.
* @property name - User's display name (optional).
* @property creationDate - Date when the account was created.
* Will be undefined immediately after login until the first sync completes.
*/
export type AccountInfo = {
email: string;
emailVerified: boolean;
name: string | undefined;
creationDate: Date | undefined;
};
export type Account = { id: UserId } & AccountInfo;
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
for (const key of keys) {
if (a[key] !== b[key]) {
return false;
}
}
return true;
}
export abstract class AccountService {
abstract accounts$: Observable<Record<UserId, AccountInfo>>;
@@ -47,6 +36,8 @@ export abstract class AccountService {
abstract sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
abstract nextUpAccount$: Observable<Account>;
/** Observable to display the header */
abstract showHeader$: Observable<boolean>;
/**
* Updates the `accounts$` observable with the new account data.
*
@@ -73,6 +64,12 @@ export abstract class AccountService {
* @param emailVerified
*/
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
/**
* updates the `accounts$` observable with the creation date for the account.
* @param userId
* @param creationDate
*/
abstract setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void>;
/**
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
* @param userId
@@ -100,6 +97,11 @@ export abstract class AccountService {
* @param lastActivity
*/
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
/**
* Show the account switcher.
* @param value
*/
abstract setShowHeader(visible: boolean): Promise<void>;
}
export abstract class InternalAccountService extends AccountService {

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,25 +1,27 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "../../../types/guid";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
export class AuthResult {
userId: UserId;
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
/**
* @deprecated
* Replace with using UserDecryptionOptions to determine if the user does
* not have a master password and is not using Key Connector.
* */
resetMasterPassword = false;
twoFactorProviders: Partial<Record<TwoFactorProviderType, Record<string, string>>> = null;
ssoEmail2FaSessionToken?: string;
email: string;
requiresEncryptionKeyMigration: boolean;
requiresDeviceVerification: boolean;
ssoOrganizationIdentifier?: string | null;
// The master-password used in the authentication process
masterPassword: string | null;
get requiresTwoFactor() {
return this.twoFactorProviders != null;
}
// This is not as extensible as an object-based approach. In the future we may need to adjust to an object based approach.
get requiresSso() {
return !Utils.isNullOrWhitespace(this.ssoOrganizationIdentifier);
}
}

View File

@@ -0,0 +1,10 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class IdentitySsoRequiredResponse extends BaseResponse {
ssoOrganizationIdentifier: string | null;
constructor(response: any) {
super(response);
this.ssoOrganizationIdentifier = this.getResponseProperty("SsoOrganizationIdentifier");
}
}

View File

@@ -116,4 +116,36 @@ describe("IdentityTokenResponse", () => {
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.userDecryptionOptions).toBeDefined();
});
it("should create response with accountKeys not present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
AccountKeys: null as unknown,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accountKeysResponseModel).toBeNull();
});
it("should create response with accountKeys present", () => {
const accountKeysData = {
publicKeyEncryptionKeyPair: {
publicKey: "testPublicKey",
wrappedPrivateKey: "testPrivateKey",
},
};
const response = {
access_token: accessToken,
token_type: tokenType,
AccountKeys: accountKeysData,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accountKeysResponseModel).toBeDefined();
expect(
identityTokenResponse.accountKeysResponseModel?.publicKeyEncryptionKeyPair,
).toBeDefined();
});
});

View File

@@ -5,6 +5,7 @@
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { PrivateKeysResponseModel } from "../../../key-management/keys/response/private-keys.response";
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
@@ -18,15 +19,26 @@ export class IdentityTokenResponse extends BaseResponse {
tokenType: string;
// Decryption Information
resetMasterPassword: boolean;
privateKey: string; // userKeyEncryptedPrivateKey
key?: EncString; // masterKeyEncryptedUserKey
/**
* privateKey is actually userKeyEncryptedPrivateKey
* @deprecated Use {@link accountKeysResponseModel} instead
*/
privateKey: string;
// TODO: https://bitwarden.atlassian.net/browse/PM-30124 - Rename to just accountKeys
accountKeysResponseModel: PrivateKeysResponseModel | null = null;
/**
* key is actually masterKeyEncryptedUserKey
* @deprecated Use {@link userDecryptionOptions.masterPasswordUnlock.masterKeyWrappedUserKey} instead
*/
key?: EncString;
twoFactorToken: string;
kdfConfig: KdfConfig;
forcePasswordReset: boolean;
masterPasswordPolicy: MasterPasswordPolicyResponse;
apiUseKeyConnector: boolean;
keyConnectorUrl: string;
userDecryptionOptions?: UserDecryptionOptionsResponse;
@@ -53,8 +65,12 @@ export class IdentityTokenResponse extends BaseResponse {
this.refreshToken = refreshToken;
}
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
this.privateKey = this.getResponseProperty("PrivateKey");
if (this.getResponseProperty("AccountKeys") != null) {
this.accountKeysResponseModel = new PrivateKeysResponseModel(
this.getResponseProperty("AccountKeys"),
);
}
const key = this.getResponseProperty("Key");
if (key) {
this.key = new EncString(key);
@@ -70,7 +86,7 @@ export class IdentityTokenResponse extends BaseResponse {
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy"),
);

View File

@@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
/**
* The IdTokenresponse only returns a single WebAuthn PRF option.
* To support immediate unlock after logging in with the same PRF passkey.
*/
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
constructor(response: IUserDecryptionOptionsServerResponse) {

View File

@@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response";
export interface IWebAuthnPrfDecryptionOptionServerResponse {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
}
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
encryptedPrivateKey: EncString;
encryptedUserKey: EncString;
credentialId: string;
transports: string[];
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
super(response);
if (response.EncryptedPrivateKey) {
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
if (encPrivateKey) {
this.encryptedPrivateKey = new EncString(encPrivateKey);
}
if (response.EncryptedUserKey) {
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
const encUserKey = this.getResponseProperty("EncryptedUserKey");
if (encUserKey) {
this.encryptedUserKey = new EncString(encUserKey);
}
this.credentialId = this.getResponseProperty("CredentialId");
this.transports = this.getResponseProperty("Transports") || [];
}
}

View File

@@ -217,7 +217,7 @@ export class DefaultSendTokenService implements SendTokenServiceAbstraction {
}
// Destructure to omit the specific sendId and get new reference for the rest of the dict for an immutable update
const { [sendId]: _, ...rest } = sendAccessTokenDict;
return rest;

View File

@@ -6,6 +6,7 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
import { FakeGlobalState } from "../../../spec/fake-state";
import {
FakeGlobalStateProvider,
@@ -16,7 +17,7 @@ import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
import { AccountInfo } from "../abstractions/account.service";
import {
ACCOUNT_ACCOUNTS,
@@ -26,46 +27,6 @@ import {
AccountServiceImplementation,
} from "./account.service";
describe("accountInfoEqual", () => {
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
it("compares nulls", () => {
expect(accountInfoEqual(null, null)).toBe(true);
expect(accountInfoEqual(null, accountInfo)).toBe(false);
expect(accountInfoEqual(accountInfo, null)).toBe(false);
});
it("compares all keys, not just those defined in AccountInfo", () => {
const different = { ...accountInfo, extra: "extra" };
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares name", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, name: "name2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares email", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, email: "email2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares emailVerified", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, emailVerified: false };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
});
describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
@@ -76,7 +37,10 @@ describe("accountService", () => {
let activeAccountIdState: FakeGlobalState<UserId>;
let accountActivityState: FakeGlobalState<Record<UserId, Date>>;
const userId = Utils.newGuid() as UserId;
const userInfo = { email: "email", name: "name", emailVerified: true };
const userInfo = mockAccountInfoWith({
email: "email",
name: "name",
});
beforeEach(() => {
messagingService = mock();
@@ -100,6 +64,60 @@ describe("accountService", () => {
jest.resetAllMocks();
});
describe("accountInfoEqual", () => {
const accountInfo = mockAccountInfoWith();
it("compares nulls", () => {
expect((sut as any).accountInfoEqual(null, null)).toBe(true);
expect((sut as any).accountInfoEqual(null, accountInfo)).toBe(false);
expect((sut as any).accountInfoEqual(accountInfo, null)).toBe(false);
});
it("compares name", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, name: "name2" };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares email", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, email: "email2" };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares emailVerified", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, emailVerified: false };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares creationDate", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, creationDate: new Date("2024-12-31T00:00:00.000Z") };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares undefined creationDate", () => {
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
const same = { ...accountWithoutCreationDate };
const different = {
...accountWithoutCreationDate,
creationDate: new Date("2024-01-01T00:00:00.000Z"),
};
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
});
});
describe("activeAccount$", () => {
it("should emit null if no account is active", () => {
const emissions = trackEmissions(sut.activeAccount$);
@@ -253,6 +271,79 @@ describe("accountService", () => {
});
});
describe("setCreationDate", () => {
const initialState = { [userId]: userInfo };
beforeEach(() => {
accountsState.stateSubject.next(initialState);
});
it("should update the account with a new creation date", async () => {
const newCreationDate = new Date("2024-12-31T00:00:00.000Z");
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, creationDate: newCreationDate },
});
});
it("should not update if the creation date is the same", async () => {
await sut.setAccountCreationDate(userId, userInfo.creationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual(initialState);
});
it("should not update if the creation date has the same timestamp but different Date object", async () => {
const sameTimestamp = new Date(userInfo.creationDate.getTime());
await sut.setAccountCreationDate(userId, sameTimestamp);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual(initialState);
});
it("should update if the creation date has a different timestamp", async () => {
const differentDate = new Date(userInfo.creationDate.getTime() + 1000);
await sut.setAccountCreationDate(userId, differentDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, creationDate: differentDate },
});
});
it("should update from undefined to a defined creation date", async () => {
const accountWithoutCreationDate = mockAccountInfoWith({
...userInfo,
creationDate: undefined,
});
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
const newCreationDate = new Date("2024-06-15T12:30:00.000Z");
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...accountWithoutCreationDate, creationDate: newCreationDate },
});
});
it("should not update when both creation dates are undefined", async () => {
const accountWithoutCreationDate = mockAccountInfoWith({
...userInfo,
creationDate: undefined,
});
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
// Attempt to set to undefined (shouldn't trigger update)
const currentStateBefore = await firstValueFrom(accountsState.state$);
// We can't directly call setAccountCreationDate with undefined, but we can verify
// the behavior through setAccountInfo which accountInfoEqual uses internally
expect(currentStateBefore[userId].creationDate).toBeUndefined();
});
});
describe("setAccountVerifyNewDeviceLogin", () => {
const initialState = true;
beforeEach(() => {
@@ -294,6 +385,7 @@ describe("accountService", () => {
email: "",
emailVerified: false,
name: undefined,
creationDate: undefined,
},
});
});
@@ -429,6 +521,16 @@ describe("accountService", () => {
},
);
});
describe("setShowHeader", () => {
it("should update _showHeader$ when setShowHeader is called", async () => {
expect(sut["_showHeader$"].value).toBe(true);
await sut.setShowHeader(false);
expect(sut["_showHeader$"].value).toBe(false);
});
});
});
});

View File

@@ -6,6 +6,7 @@ import {
distinctUntilChanged,
shareReplay,
combineLatest,
BehaviorSubject,
Observable,
switchMap,
filter,
@@ -17,7 +18,6 @@ import {
Account,
AccountInfo,
InternalAccountService,
accountInfoEqual,
} from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -36,7 +36,10 @@ export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
ACCOUNT_DISK,
"accounts",
{
deserializer: (accountInfo) => accountInfo,
deserializer: (accountInfo) => ({
...accountInfo,
creationDate: accountInfo.creationDate ? new Date(accountInfo.creationDate) : undefined,
}),
},
);
@@ -61,6 +64,7 @@ const LOGGED_OUT_INFO: AccountInfo = {
email: "",
emailVerified: false,
name: undefined,
creationDate: undefined,
};
/**
@@ -84,6 +88,7 @@ export const getOptionalUserId = map<Account | null, UserId | null>(
export class AccountServiceImplementation implements InternalAccountService {
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
private _showHeader$ = new BehaviorSubject<boolean>(true);
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>;
@@ -91,6 +96,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>;
showHeader$ = this._showHeader$.asObservable();
constructor(
private messagingService: MessagingService,
@@ -107,7 +113,7 @@ export class AccountServiceImplementation implements InternalAccountService {
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)),
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
distinctUntilChanged((a, b) => a?.id === b?.id && this.accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }),
);
this.accountActivity$ = this.globalStateProvider
@@ -164,6 +170,10 @@ export class AccountServiceImplementation implements InternalAccountService {
await this.setAccountInfo(userId, { emailVerified });
}
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
await this.setAccountInfo(userId, { creationDate });
}
async clean(userId: UserId) {
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
await this.removeAccountActivity(userId);
@@ -262,6 +272,27 @@ export class AccountServiceImplementation implements InternalAccountService {
}
}
async setShowHeader(visible: boolean): Promise<void> {
this._showHeader$.next(visible);
}
private accountInfoEqual(a: AccountInfo, b: AccountInfo) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
return (
a.email === b.email &&
a.emailVerified === b.emailVerified &&
a.name === b.name &&
a.creationDate?.getTime() === b.creationDate?.getTime()
);
}
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update };
@@ -279,7 +310,7 @@ export class AccountServiceImplementation implements InternalAccountService {
throw new Error("Account does not exist");
}
return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
return !this.accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
},
},
);

View File

@@ -1,139 +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 { 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);
accountService.activeAccount$ = of({
id: userId,
email: "user@example.com",
emailVerified: true,
name: "User",
});
accountService.accounts$ = of({
[userId]: { email: "user@example.com", emailVerified: true, name: "User" },
});
(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

@@ -10,6 +10,7 @@ import {
makeStaticByteArray,
mockAccountServiceWith,
trackEmissions,
mockAccountInfoWith,
} from "../../../spec";
import { ApiService } from "../../abstractions/api.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -58,9 +59,10 @@ describe("AuthService", () => {
const accountInfo = {
status: AuthenticationStatus.Unlocked,
id: userId,
email: "email",
emailVerified: false,
name: "name",
...mockAccountInfoWith({
email: "email",
name: "name",
}),
};
beforeEach(() => {
@@ -112,9 +114,10 @@ describe("AuthService", () => {
const accountInfo2 = {
status: AuthenticationStatus.Unlocked,
id: Utils.newGuid() as UserId,
email: "email2",
emailVerified: false,
name: "name2",
...mockAccountInfoWith({
email: "email2",
name: "name2",
}),
};
const emissions = trackEmissions(sut.activeAccountStatus$);
@@ -131,11 +134,13 @@ describe("AuthService", () => {
it("requests auth status for all known users", async () => {
const userId2 = Utils.newGuid() as UserId;
await accountService.addAccount(userId2, {
email: "email2",
emailVerified: false,
name: "name2",
});
await accountService.addAccount(
userId2,
mockAccountInfoWith({
email: "email2",
name: "name2",
}),
);
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
sut.authStatusFor$ = mockFn;

View File

@@ -8,12 +8,13 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { UserId } from "../../types/guid";
import { Account, AccountInfo, AccountService } from "../abstractions/account.service";
import { Account, AccountService } from "../abstractions/account.service";
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";
@@ -96,11 +97,10 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
const encryptedKey = { encryptedString: "encryptedString" };
organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any);
const user1AccountInfo: AccountInfo = {
const user1AccountInfo = mockAccountInfoWith({
name: "Test User 1",
email: "test1@email.com",
emailVerified: true,
};
});
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
keyService.userKey$.mockReturnValue(of({ key: "key" } as any));

View File

@@ -22,9 +22,7 @@ import { UserKey } from "../../types/key";
import { AccountService } from "../abstractions/account.service";
import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction";
export class PasswordResetEnrollmentServiceImplementation
implements PasswordResetEnrollmentServiceAbstraction
{
export class PasswordResetEnrollmentServiceImplementation implements PasswordResetEnrollmentServiceAbstraction {
constructor(
protected organizationApiService: OrganizationApiServiceAbstraction,
protected accountService: AccountService,

View File

@@ -445,13 +445,15 @@ export class TokenService implements TokenServiceAbstraction {
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear all locations to avoid the need to require those parameters.
// When secure storage is supported, clear the encryption key from secure storage.
// When not supported (e.g., portable builds), tokens are stored on disk and this step is skipped.
if (this.platformSupportsSecureStorage) {
// Always clear the access token key when clearing the access token
// The next set of the access token will create a new access token key
// Always clear the access token key when clearing the access token.
// The next set of the access token will create a new access token key.
await this.clearAccessTokenKey(userId);
}
// Platform doesn't support secure storage, so use state provider implementation
// Clear tokens from disk storage (all platforms)
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, {
shouldUpdate: (previousValue) => previousValue !== null,
});
@@ -478,6 +480,9 @@ export class TokenService implements TokenServiceAbstraction {
return null;
}
// When platformSupportsSecureStorage=true, tokens on disk are encrypted and require
// decryption keys from secure storage. When false (e.g., portable builds), tokens are
// stored on disk.
if (this.platformSupportsSecureStorage) {
let accessTokenKey: AccessTokenKey;
try {
@@ -1118,6 +1123,9 @@ export class TokenService implements TokenServiceAbstraction {
) {
return TokenStorageLocation.Memory;
} else {
// Secure storage (e.g., OS credential manager) is preferred when available.
// Desktop portable builds set platformSupportsSecureStorage=false to store tokens
// on disk for portability across machines.
if (useSecureStorage && this.platformSupportsSecureStorage) {
return TokenStorageLocation.SecureStorage;
}