mirror of
https://github.com/bitwarden/browser
synced 2026-02-25 09:03:28 +00:00
Merge main
This commit is contained in:
@@ -1,3 +1,2 @@
|
||||
export * from "./auto-confirm";
|
||||
export * from "./collections";
|
||||
export * from "./organization-user";
|
||||
|
||||
1
libs/angular/src/admin-console/guards/index.ts
Normal file
1
libs/angular/src/admin-console/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./org-policy.guard";
|
||||
70
libs/angular/src/admin-console/guards/org-policy.guard.ts
Normal file
70
libs/angular/src/admin-console/guards/org-policy.guard.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { firstValueFrom, Observable, switchMap, tap } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/**
|
||||
* This guard is intended to prevent members of an organization from accessing
|
||||
* routes based on compliance with organization
|
||||
* policies. e.g Emergency access, which is a non-organization
|
||||
* feature is restricted by the Auto Confirm policy.
|
||||
*/
|
||||
export function organizationPolicyGuard(
|
||||
featureCallback: (
|
||||
userId: UserId,
|
||||
configService: ConfigService,
|
||||
policyService: PolicyService,
|
||||
) => Observable<boolean>,
|
||||
): CanActivateFn {
|
||||
return async () => {
|
||||
const router = inject(Router);
|
||||
const toastService = inject(ToastService);
|
||||
const i18nService = inject(I18nService);
|
||||
const accountService = inject(AccountService);
|
||||
const policyService = inject(PolicyService);
|
||||
const configService = inject(ConfigService);
|
||||
const syncService = inject(SyncService);
|
||||
|
||||
const synced = await firstValueFrom(
|
||||
accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => syncService.lastSync$(userId)),
|
||||
),
|
||||
);
|
||||
|
||||
if (synced == null) {
|
||||
await syncService.fullSync(false);
|
||||
}
|
||||
|
||||
const compliant = await firstValueFrom(
|
||||
accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) => featureCallback(userId, configService, policyService)),
|
||||
tap((compliant) => {
|
||||
if (typeof compliant !== "boolean") {
|
||||
throw new Error("Feature callback must return a boolean.");
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (!compliant) {
|
||||
toastService.showToast({
|
||||
variant: "error",
|
||||
message: i18nService.t("noPageAccess"),
|
||||
});
|
||||
|
||||
return router.createUrlTree(["/"]);
|
||||
}
|
||||
|
||||
return compliant;
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
|
||||
import { DefaultLoginApprovalDialogComponentService } from "./default-login-approval-dialog-component.service";
|
||||
import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
|
||||
|
||||
describe("DefaultLoginApprovalDialogComponentService", () => {
|
||||
let service: DefaultLoginApprovalDialogComponentService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [DefaultLoginApprovalDialogComponentService],
|
||||
});
|
||||
|
||||
service = TestBed.inject(DefaultLoginApprovalDialogComponentService);
|
||||
});
|
||||
|
||||
it("is created successfully", () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has showLoginRequestedAlertIfWindowNotVisible method that is a no-op", async () => {
|
||||
const loginApprovalDialogComponent = {} as LoginApprovalDialogComponent;
|
||||
|
||||
const result = await service.showLoginRequestedAlertIfWindowNotVisible(
|
||||
loginApprovalDialogComponent.email,
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
|
||||
|
||||
/**
|
||||
* Default implementation of the LoginApprovalDialogComponentServiceAbstraction.
|
||||
*/
|
||||
export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction {
|
||||
/**
|
||||
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
|
||||
* @returns
|
||||
*/
|
||||
async showLoginRequestedAlertIfWindowNotVisible(email?: string): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1 @@
|
||||
export * from "./login-approval-dialog.component";
|
||||
export * from "./login-approval-dialog-component.service.abstraction";
|
||||
export * from "./default-login-approval-dialog-component.service";
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/**
|
||||
* Abstraction for the LoginApprovalDialogComponent service.
|
||||
*/
|
||||
export abstract class LoginApprovalDialogComponentServiceAbstraction {
|
||||
/**
|
||||
* Shows a login requested alert if the window is not visible.
|
||||
*/
|
||||
abstract showLoginRequestedAlertIfWindowNotVisible: (email?: string) => Promise<void>;
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
|
||||
import { LoginApprovalDialogComponent } from "./login-approval-dialog.component";
|
||||
|
||||
describe("LoginApprovalDialogComponent", () => {
|
||||
@@ -69,10 +68,6 @@ describe("LoginApprovalDialogComponent", () => {
|
||||
{ provide: LogService, useValue: logService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: ValidationService, useValue: validationService },
|
||||
{
|
||||
provide: LoginApprovalDialogComponentServiceAbstraction,
|
||||
useValue: mock<LoginApprovalDialogComponentServiceAbstraction>(),
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -24,8 +24,6 @@ import {
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval-dialog-component.service.abstraction";
|
||||
|
||||
const RequestTimeOut = 60000 * 15; // 15 Minutes
|
||||
const RequestTimeUpdate = 60000 * 5; // 5 Minutes
|
||||
|
||||
@@ -57,7 +55,6 @@ export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
|
||||
private devicesService: DevicesServiceAbstraction,
|
||||
private dialogRef: DialogRef,
|
||||
private i18nService: I18nService,
|
||||
private loginApprovalDialogComponentService: LoginApprovalDialogComponentServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private toastService: ToastService,
|
||||
private validationService: ValidationService,
|
||||
@@ -113,10 +110,6 @@ export class LoginApprovalDialogComponent implements OnInit, OnDestroy {
|
||||
this.updateTimeText();
|
||||
}, RequestTimeUpdate);
|
||||
|
||||
await this.loginApprovalDialogComponentService.showLoginRequestedAlertIfWindowNotVisible(
|
||||
this.email,
|
||||
);
|
||||
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-my-5 tw-h-12">
|
||||
<button
|
||||
cdkFocusInitial
|
||||
bitButton
|
||||
[buttonType]="cardDetails.button.type"
|
||||
[block]="true"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { filter, firstValueFrom, map } from "rxjs";
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import {
|
||||
LinkModule,
|
||||
AsyncActionsModule,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
@@ -40,6 +42,8 @@ export class PromptMigrationPasswordComponent {
|
||||
private formBuilder = inject(FormBuilder);
|
||||
private masterPasswordUnlockService = inject(MasterPasswordUnlockService);
|
||||
private accountService = inject(AccountService);
|
||||
private toastService = inject(ToastService);
|
||||
private i18nService = inject(I18nService);
|
||||
|
||||
migrationPasswordForm = this.formBuilder.group({
|
||||
masterPassword: ["", [Validators.required]],
|
||||
@@ -73,6 +77,10 @@ export class PromptMigrationPasswordComponent {
|
||||
userId,
|
||||
))
|
||||
) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
message: this.i18nService.t("incorrectPassword"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ import {
|
||||
InternalAccountService,
|
||||
} from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
|
||||
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 { AuthService as AuthServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AvatarService as AvatarServiceAbstraction } from "@bitwarden/common/auth/abstractions/avatar.service";
|
||||
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
|
||||
@@ -112,7 +112,7 @@ import { SendTokenService, DefaultSendTokenService } from "@bitwarden/common/aut
|
||||
import { AccountApiServiceImplementation } from "@bitwarden/common/auth/services/account-api.service";
|
||||
import { AccountServiceImplementation } from "@bitwarden/common/auth/services/account.service";
|
||||
import { AnonymousHubService } from "@bitwarden/common/auth/services/anonymous-hub.service";
|
||||
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
|
||||
import { DefaultAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/default-auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "@bitwarden/common/auth/services/auth-request-answering/pending-auth-requests.state";
|
||||
import { AuthService } from "@bitwarden/common/auth/services/auth.service";
|
||||
import { AvatarService } from "@bitwarden/common/auth/services/avatar.service";
|
||||
@@ -397,8 +397,6 @@ import {
|
||||
VaultExportServiceAbstraction,
|
||||
} from "@bitwarden/vault-export-core";
|
||||
|
||||
import { DefaultLoginApprovalDialogComponentService } from "../auth/login-approval/default-login-approval-dialog-component.service";
|
||||
import { LoginApprovalDialogComponentServiceAbstraction } from "../auth/login-approval/login-approval-dialog-component.service.abstraction";
|
||||
import { DefaultSetInitialPasswordService } from "../auth/password-management/set-initial-password/default-set-initial-password.service.implementation";
|
||||
import { SetInitialPasswordService } from "../auth/password-management/set-initial-password/set-initial-password.service.abstraction";
|
||||
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
|
||||
@@ -1040,9 +1038,15 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AuthRequestAnsweringServiceAbstraction,
|
||||
useClass: NoopAuthRequestAnsweringService,
|
||||
deps: [],
|
||||
provide: AuthRequestAnsweringService,
|
||||
useClass: DefaultAuthRequestAnsweringService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
MasterPasswordServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
PendingAuthRequestsStateService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ServerNotificationsService,
|
||||
@@ -1060,7 +1064,7 @@ const safeProviders: SafeProvider[] = [
|
||||
SignalRConnectionService,
|
||||
AuthServiceAbstraction,
|
||||
WebPushConnectionService,
|
||||
AuthRequestAnsweringServiceAbstraction,
|
||||
AuthRequestAnsweringService,
|
||||
ConfigService,
|
||||
InternalPolicyService,
|
||||
],
|
||||
@@ -1666,11 +1670,6 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultSendPasswordService,
|
||||
deps: [CryptoFunctionServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginApprovalDialogComponentServiceAbstraction,
|
||||
useClass: DefaultLoginApprovalDialogComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginDecryptionOptionsService,
|
||||
useClass: DefaultLoginDecryptionOptionsService,
|
||||
|
||||
@@ -27,13 +27,13 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { Send } from "@bitwarden/common/tools/send/models/domain/send";
|
||||
import { SendFileView } from "@bitwarden/common/tools/send/models/view/send-file.view";
|
||||
import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text.view";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
|
||||
@@ -20,10 +20,10 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
|
||||
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
|
||||
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
|
||||
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { SendType } from "@bitwarden/common/tools/send/types/send-type";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
@@ -78,7 +78,7 @@ export class SendComponent implements OnInit, OnDestroy {
|
||||
protected ngZone: NgZone,
|
||||
protected searchService: SearchService,
|
||||
protected policyService: PolicyService,
|
||||
private logService: LogService,
|
||||
protected logService: LogService,
|
||||
protected sendApiService: SendApiService,
|
||||
protected dialogService: DialogService,
|
||||
protected toastService: ToastService,
|
||||
|
||||
@@ -194,7 +194,12 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
||||
return this.searchService.searchCiphers(
|
||||
userId,
|
||||
searchText,
|
||||
[filter, this.deletedFilter, this.archivedFilter, restrictedTypeFilter],
|
||||
[
|
||||
filter,
|
||||
this.deletedFilter,
|
||||
...(this.deleted ? [] : [this.archivedFilter]),
|
||||
restrictedTypeFilter,
|
||||
],
|
||||
allCiphers,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../../../libs/common/spec";
|
||||
import { NUDGE_DISMISSED_DISK_KEY, NudgeType } from "../nudges.service";
|
||||
|
||||
import { AutoConfirmNudgeService } from "./auto-confirm-nudge.service";
|
||||
|
||||
describe("AutoConfirmNudgeService", () => {
|
||||
let service: AutoConfirmNudgeService;
|
||||
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
const userId = "user-id" as UserId;
|
||||
|
||||
const mockAutoConfirmState = {
|
||||
enabled: true,
|
||||
showSetupDialog: false,
|
||||
showBrowserNotification: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
|
||||
autoConfirmService = mock<AutomaticUserConfirmationService>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AutoConfirmNudgeService,
|
||||
{
|
||||
provide: StateProvider,
|
||||
useValue: fakeStateProvider,
|
||||
},
|
||||
{
|
||||
provide: AutomaticUserConfirmationService,
|
||||
useValue: autoConfirmService,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(AutoConfirmNudgeService);
|
||||
});
|
||||
|
||||
describe("nudgeStatus$", () => {
|
||||
it("should return all dismissed when user cannot manage auto-confirm", async () => {
|
||||
autoConfirmService.configuration$.mockReturnValue(new BehaviorSubject(mockAutoConfirmState));
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return all dismissed when showBrowserNotification is false", async () => {
|
||||
autoConfirmService.configuration$.mockReturnValue(
|
||||
new BehaviorSubject({
|
||||
...mockAutoConfirmState,
|
||||
showBrowserNotification: false,
|
||||
}),
|
||||
);
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return not dismissed when showBrowserNotification is true and user can manage", async () => {
|
||||
autoConfirmService.configuration$.mockReturnValue(
|
||||
new BehaviorSubject({
|
||||
...mockAutoConfirmState,
|
||||
showBrowserNotification: true,
|
||||
}),
|
||||
);
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: false,
|
||||
hasSpotlightDismissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return not dismissed when showBrowserNotification is undefined and user can manage", async () => {
|
||||
autoConfirmService.configuration$.mockReturnValue(
|
||||
new BehaviorSubject({
|
||||
...mockAutoConfirmState,
|
||||
showBrowserNotification: undefined,
|
||||
}),
|
||||
);
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: false,
|
||||
hasSpotlightDismissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return stored nudge status when badge is already dismissed", async () => {
|
||||
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
|
||||
[NudgeType.AutoConfirmNudge]: {
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: false,
|
||||
},
|
||||
}));
|
||||
|
||||
autoConfirmService.configuration$.mockReturnValue(
|
||||
new BehaviorSubject({
|
||||
...mockAutoConfirmState,
|
||||
showBrowserNotification: true,
|
||||
}),
|
||||
);
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return stored nudge status when spotlight is already dismissed", async () => {
|
||||
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
|
||||
[NudgeType.AutoConfirmNudge]: {
|
||||
hasBadgeDismissed: false,
|
||||
hasSpotlightDismissed: true,
|
||||
},
|
||||
}));
|
||||
|
||||
autoConfirmService.configuration$.mockReturnValue(
|
||||
new BehaviorSubject({
|
||||
...mockAutoConfirmState,
|
||||
showBrowserNotification: true,
|
||||
}),
|
||||
);
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: false,
|
||||
hasSpotlightDismissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return stored nudge status when both badge and spotlight are already dismissed", async () => {
|
||||
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
|
||||
[NudgeType.AutoConfirmNudge]: {
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: true,
|
||||
},
|
||||
}));
|
||||
|
||||
autoConfirmService.configuration$.mockReturnValue(
|
||||
new BehaviorSubject({
|
||||
...mockAutoConfirmState,
|
||||
showBrowserNotification: true,
|
||||
}),
|
||||
);
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(true));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should prioritize user permissions over showBrowserNotification setting", async () => {
|
||||
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
|
||||
[NudgeType.AutoConfirmNudge]: {
|
||||
hasBadgeDismissed: false,
|
||||
hasSpotlightDismissed: false,
|
||||
},
|
||||
}));
|
||||
|
||||
autoConfirmService.configuration$.mockReturnValue(
|
||||
new BehaviorSubject({
|
||||
...mockAutoConfirmState,
|
||||
showBrowserNotification: true,
|
||||
}),
|
||||
);
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should respect stored dismissal even when user cannot manage auto-confirm", async () => {
|
||||
await fakeStateProvider.getUser(userId, NUDGE_DISMISSED_DISK_KEY).update(() => ({
|
||||
[NudgeType.AutoConfirmNudge]: {
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: false,
|
||||
},
|
||||
}));
|
||||
|
||||
autoConfirmService.configuration$.mockReturnValue(new BehaviorSubject(mockAutoConfirmState));
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(new BehaviorSubject(false));
|
||||
|
||||
const result = await firstValueFrom(service.nudgeStatus$(NudgeType.AutoConfirmNudge, userId));
|
||||
|
||||
expect(result).toEqual({
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "@bitwarden/auto-confirm";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
|
||||
import { NudgeType, NudgeStatus } from "../nudges.service";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class AutoConfirmNudgeService extends DefaultSingleNudgeService {
|
||||
autoConfirmService = inject(AutomaticUserConfirmationService);
|
||||
|
||||
nudgeStatus$(nudgeType: NudgeType, userId: UserId): Observable<NudgeStatus> {
|
||||
return combineLatest([
|
||||
this.getNudgeStatus$(nudgeType, userId),
|
||||
this.autoConfirmService.configuration$(userId),
|
||||
this.autoConfirmService.canManageAutoConfirm$(userId),
|
||||
]).pipe(
|
||||
map(([nudgeStatus, autoConfirmState, canManageAutoConfirm]) => {
|
||||
if (!canManageAutoConfirm) {
|
||||
return {
|
||||
hasBadgeDismissed: true,
|
||||
hasSpotlightDismissed: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (nudgeStatus.hasBadgeDismissed || nudgeStatus.hasSpotlightDismissed) {
|
||||
return nudgeStatus;
|
||||
}
|
||||
|
||||
const dismissed = autoConfirmState.showBrowserNotification === false;
|
||||
|
||||
return {
|
||||
hasBadgeDismissed: dismissed,
|
||||
hasSpotlightDismissed: dismissed,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from "./account-security-nudge.service";
|
||||
export * from "./auto-confirm-nudge.service";
|
||||
export * from "./has-items-nudge.service";
|
||||
export * from "./empty-vault-nudge.service";
|
||||
export * from "./vault-settings-import-nudge.service";
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
AccountSecurityNudgeService,
|
||||
VaultSettingsImportNudgeService,
|
||||
} from "./custom-nudges-services";
|
||||
import { AutoConfirmNudgeService } from "./custom-nudges-services/auto-confirm-nudge.service";
|
||||
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
|
||||
import { NudgesService, NudgeType } from "./nudges.service";
|
||||
|
||||
@@ -35,6 +36,7 @@ describe("Vault Nudges Service", () => {
|
||||
EmptyVaultNudgeService,
|
||||
NewAccountNudgeService,
|
||||
AccountSecurityNudgeService,
|
||||
AutoConfirmNudgeService,
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -73,6 +75,10 @@ describe("Vault Nudges Service", () => {
|
||||
provide: VaultSettingsImportNudgeService,
|
||||
useValue: mock<VaultSettingsImportNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: AutoConfirmNudgeService,
|
||||
useValue: mock<AutoConfirmNudgeService>(),
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useValue: mock<ApiService>(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NewItemNudgeService,
|
||||
AccountSecurityNudgeService,
|
||||
VaultSettingsImportNudgeService,
|
||||
AutoConfirmNudgeService,
|
||||
NoOpNudgeService,
|
||||
} from "./custom-nudges-services";
|
||||
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
|
||||
@@ -39,6 +40,7 @@ export const NudgeType = {
|
||||
NewNoteItemStatus: "new-note-item-status",
|
||||
NewSshItemStatus: "new-ssh-item-status",
|
||||
GeneratorNudgeStatus: "generator-nudge-status",
|
||||
AutoConfirmNudge: "auto-confirm-nudge",
|
||||
PremiumUpgrade: "premium-upgrade",
|
||||
} as const;
|
||||
|
||||
@@ -82,6 +84,7 @@ export class NudgesService {
|
||||
[NudgeType.NewIdentityItemStatus]: this.newItemNudgeService,
|
||||
[NudgeType.NewNoteItemStatus]: this.newItemNudgeService,
|
||||
[NudgeType.NewSshItemStatus]: this.newItemNudgeService,
|
||||
[NudgeType.AutoConfirmNudge]: inject(AutoConfirmNudgeService),
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -148,6 +151,7 @@ export class NudgesService {
|
||||
NudgeType.EmptyVaultNudge,
|
||||
NudgeType.DownloadBitwarden,
|
||||
NudgeType.AutofillNudge,
|
||||
NudgeType.AutoConfirmNudge,
|
||||
];
|
||||
|
||||
const nudgeTypesWithBadge$ = nudgeTypes.map((nudge) => {
|
||||
|
||||
@@ -51,7 +51,8 @@ export class VaultFilter {
|
||||
cipherPassesFilter = CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
if (this.status === "archive" && cipherPassesFilter) {
|
||||
cipherPassesFilter = CipherViewLikeUtils.isArchived(cipher);
|
||||
cipherPassesFilter =
|
||||
CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
if (this.cipherType != null && cipherPassesFilter) {
|
||||
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
|
||||
|
||||
@@ -14,8 +14,6 @@ import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -45,7 +43,6 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
|
||||
protected policyService: PolicyService,
|
||||
protected stateProvider: StateProvider,
|
||||
protected accountService: AccountService,
|
||||
protected configService: ConfigService,
|
||||
protected i18nService: I18nService,
|
||||
) {}
|
||||
|
||||
@@ -116,18 +113,13 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti
|
||||
),
|
||||
);
|
||||
const orgs = await this.buildOrganizations();
|
||||
const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.CreateDefaultLocation,
|
||||
);
|
||||
|
||||
let collections =
|
||||
organizationId == null
|
||||
? storedCollections
|
||||
: storedCollections.filter((c) => c.organizationId === organizationId);
|
||||
|
||||
if (defaulCollectionsFlagEnabled) {
|
||||
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
|
||||
}
|
||||
collections = sortDefaultCollections(collections, orgs, this.i18nService.collator);
|
||||
|
||||
const nestedCollections = await this.collectionService.getAllNested(collections);
|
||||
return new DynamicTreeNode<CollectionView>({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
export type LogoutReason =
|
||||
| "invalidGrantError"
|
||||
| "vaultTimeout"
|
||||
| "invalidSecurityStamp"
|
||||
| "logoutNotification"
|
||||
| "keyConnectorError"
|
||||
| "sessionExpired"
|
||||
| "accessTokenUnableToBeDecrypted"
|
||||
| "accountDeleted"
|
||||
| "invalidAccessToken"
|
||||
| "invalidSecurityStamp"
|
||||
| "keyConnectorError"
|
||||
| "logoutNotification"
|
||||
| "refreshTokenSecureStorageRetrievalFailure"
|
||||
| "accountDeleted";
|
||||
| "sessionExpired"
|
||||
| "vaultTimeout";
|
||||
|
||||
18
libs/auto-confirm/README.md
Normal file
18
libs/auto-confirm/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Automatic User Confirmation
|
||||
|
||||
Owned by: admin-console
|
||||
|
||||
The automatic user confirmation (auto confirm) feature enables an organization to confirm users to an organization without manual intervention
|
||||
from any user as long as an administrator's device is unlocked. The feature is enabled via the following:
|
||||
|
||||
1. an organization plan feature in the Bitwarden Portal (enabled by an internal team)
|
||||
2. the automatic user confirmation policy in the Admin Console (enabled by an organization admin)
|
||||
3. a toggle switch in the extension's admin settings page (enabled on the admin's local device)
|
||||
|
||||
Once these three toggles are enabled, auto confirm will be enabled and users will be auto confirmed as long as an admin is logged in. Note that the setting in
|
||||
the browser extension is not synced across clients, therefore it will not be enabled if the same admin logs into another browser until it is enabled in that
|
||||
browser. This is an intentional security measure to ensure that the server cannot enable the feature unilaterally.
|
||||
|
||||
Once enabled, the AutomaticUserConfirmationService runs in the background on admins' devices and reacts to push notifications from the server containing organization members who need confirmation.
|
||||
|
||||
For more information about security goals and the push notification system, see [README in server repo](https://github.com/bitwarden/server/tree/main/src/Core/AdminConsole/OrganizationFeatures/OrganizationUsers/AutoConfirmUser).
|
||||
3
libs/auto-confirm/eslint.config.mjs
Normal file
3
libs/auto-confirm/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from "../../eslint.config.mjs";
|
||||
|
||||
export default [...baseConfig];
|
||||
18
libs/auto-confirm/jest.config.js
Normal file
18
libs/auto-confirm/jest.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const { pathsToModuleNameMapper } = require("ts-jest");
|
||||
|
||||
const { compilerOptions } = require("../../tsconfig.base");
|
||||
|
||||
const sharedConfig = require("../../libs/shared/jest.config.angular");
|
||||
|
||||
module.exports = {
|
||||
...sharedConfig,
|
||||
displayName: "auto-confirm",
|
||||
setupFilesAfterEnv: ["<rootDir>/test.setup.ts"],
|
||||
coverageDirectory: "../../coverage/libs/auto-confirm",
|
||||
moduleNameMapper: pathsToModuleNameMapper(
|
||||
{ "@bitwarden/common/spec": ["libs/common/spec"], ...(compilerOptions?.paths ?? {}) },
|
||||
{
|
||||
prefix: "<rootDir>/../../",
|
||||
},
|
||||
),
|
||||
};
|
||||
11
libs/auto-confirm/package.json
Normal file
11
libs/auto-confirm/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@bitwarden/auto-confirm",
|
||||
"version": "0.0.1",
|
||||
"description": "auto confirm",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"license": "GPL-3.0",
|
||||
"author": "admin-console"
|
||||
}
|
||||
34
libs/auto-confirm/project.json
Normal file
34
libs/auto-confirm/project.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "auto-confirm",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/auto-confirm/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/js:tsc",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/auto-confirm",
|
||||
"main": "libs/auto-confirm/src/index.ts",
|
||||
"tsConfig": "libs/auto-confirm/tsconfig.lib.json",
|
||||
"assets": ["libs/auto-confirm/*.md"],
|
||||
"rootDir": "libs/auto-confirm/src"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nx/eslint:lint",
|
||||
"outputs": ["{options.outputFile}"],
|
||||
"options": {
|
||||
"lintFilePatterns": ["libs/auto-confirm/**/*.ts"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/auto-confirm/jest.config.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AutoConfirmState } from "../models/auto-confirm-state.model";
|
||||
@@ -24,10 +23,7 @@ export abstract class AutomaticUserConfirmationService {
|
||||
* @param userId
|
||||
* @returns Observable<boolean> an observable with a boolean telling us if the provided user may confgure the auto confirm feature.
|
||||
**/
|
||||
abstract canManageAutoConfirm$(
|
||||
userId: UserId,
|
||||
organizationId: OrganizationId,
|
||||
): Observable<boolean>;
|
||||
abstract canManageAutoConfirm$(userId: UserId): Observable<boolean>;
|
||||
/**
|
||||
* Calls the API endpoint to initiate automatic user confirmation.
|
||||
* @param userId The userId of the logged in admin performing auto confirmation. This is neccesary to perform the key exchange and for permissions checks.
|
||||
@@ -0,0 +1,78 @@
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
BadgeComponent,
|
||||
ButtonModule,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<bit-simple-dialog dialogSize="small" hideIcon>
|
||||
<div class="tw-flex tw-flex-col tw-justify-start" bitDialogTitle>
|
||||
<div class="tw-flex tw-justify-start tw-pb-2">
|
||||
<span bitBadge variant="info"> {{ "availableNow" | i18n }}</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col">
|
||||
<h3 class="tw-text-start">
|
||||
<strong>
|
||||
{{ "autoConfirmSetup" | i18n }}
|
||||
</strong>
|
||||
</h3>
|
||||
<span class="tw-overflow-y-auto tw-text-start tw-break-words tw-hyphens-auto tw-text-sm">
|
||||
{{ "autoConfirmSetupDesc" | i18n }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ng-container bitDialogFooter>
|
||||
<div class="tw-flex tw-flex-col tw-justify-center">
|
||||
<button
|
||||
class="tw-mb-2"
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="primary"
|
||||
(click)="dialogRef.close(true)"
|
||||
>
|
||||
{{ "turnOn" | i18n }}
|
||||
</button>
|
||||
<button
|
||||
class="tw-mb-4"
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
(click)="dialogRef.close(false)"
|
||||
>
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
<a
|
||||
class="tw-text-sm tw-text-center"
|
||||
bitLink
|
||||
href="https://bitwarden.com/help/automatic-confirmation/"
|
||||
target="_blank"
|
||||
>
|
||||
<strong class="tw-pr-1">
|
||||
{{ "autoConfirmSetupHint" | i18n }}
|
||||
</strong>
|
||||
<i class="bwi bwi-external-link bwi-fw"></i>
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
imports: [ButtonModule, DialogModule, CommonModule, JslibModule, BadgeComponent],
|
||||
})
|
||||
export class AutoConfirmExtensionSetupDialogComponent {
|
||||
constructor(public dialogRef: DialogRef<boolean>) {}
|
||||
|
||||
static open(dialogService: DialogService) {
|
||||
return dialogService.open<boolean>(AutoConfirmExtensionSetupDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<bit-simple-dialog dialogSize="small">
|
||||
<span bitDialogTitle>
|
||||
<strong>{{ "warningCapitalized" | i18n }}</strong>
|
||||
</span>
|
||||
<span bitDialogContent>
|
||||
{{ "autoConfirmWarning" | i18n }}
|
||||
<a
|
||||
bitLink
|
||||
href="https://bitwarden.com/help/automatic-confirmation/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ "autoConfirmWarningLink" | i18n }}
|
||||
<i class="bwi bwi-external-link bwi-fw"></i>
|
||||
</a>
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close(true)">
|
||||
{{ "turnOn" | i18n }}
|
||||
</button>
|
||||
<button type="button" bitButton buttonType="secondary" (click)="dialogRef.close(false)">
|
||||
{{ "close" | i18n }}
|
||||
</button>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import {
|
||||
ButtonModule,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
} from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "./auto-confirm-warning-dialog.component.html",
|
||||
imports: [ButtonModule, DialogModule, CommonModule, I18nPipe],
|
||||
})
|
||||
export class AutoConfirmWarningDialogComponent {
|
||||
constructor(public dialogRef: DialogRef<boolean>) {}
|
||||
|
||||
static open(dialogService: DialogService) {
|
||||
return dialogService.open<boolean>(AutoConfirmWarningDialogComponent, {
|
||||
positionStrategy: new CenterPositionStrategy(),
|
||||
});
|
||||
}
|
||||
}
|
||||
2
libs/auto-confirm/src/components/index.ts
Normal file
2
libs/auto-confirm/src/components/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./auto-confirm-extension-dialog.component";
|
||||
export * from "./auto-confirm-warning-dialog.component";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Router, UrlTree } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, Observable, of } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "../abstractions";
|
||||
|
||||
import { canAccessAutoConfirmSettings } from "./automatic-user-confirmation-settings.guard";
|
||||
|
||||
describe("canAccessAutoConfirmSettings", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let autoConfirmService: MockProxy<AutomaticUserConfirmationService>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let router: MockProxy<Router>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockAccount: Account = {
|
||||
id: mockUserId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
creationDate: undefined,
|
||||
};
|
||||
let activeAccount$: BehaviorSubject<Account | null>;
|
||||
|
||||
const runGuard = () => {
|
||||
return TestBed.runInInjectionContext(() => {
|
||||
return canAccessAutoConfirmSettings(null as any, null as any) as Observable<
|
||||
boolean | UrlTree
|
||||
>;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
autoConfirmService = mock<AutomaticUserConfirmationService>();
|
||||
toastService = mock<ToastService>();
|
||||
i18nService = mock<I18nService>();
|
||||
router = mock<Router>();
|
||||
|
||||
activeAccount$ = new BehaviorSubject<Account | null>(mockAccount);
|
||||
accountService.activeAccount$ = activeAccount$;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: AutomaticUserConfirmationService, useValue: autoConfirmService },
|
||||
{ provide: ToastService, useValue: toastService },
|
||||
{ provide: I18nService, useValue: i18nService },
|
||||
{ provide: Router, useValue: router },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow access when user has permission", async () => {
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(true));
|
||||
|
||||
const result = await firstValueFrom(runGuard());
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should redirect to vault when user lacks permission", async () => {
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(false));
|
||||
const mockUrlTree = {} as UrlTree;
|
||||
router.createUrlTree.mockReturnValue(mockUrlTree);
|
||||
|
||||
const result = await firstValueFrom(runGuard());
|
||||
|
||||
expect(result).toBe(mockUrlTree);
|
||||
expect(router.createUrlTree).toHaveBeenCalledWith(["/tabs/vault"]);
|
||||
});
|
||||
|
||||
it("should not emit when active account is null", async () => {
|
||||
activeAccount$.next(null);
|
||||
autoConfirmService.canManageAutoConfirm$.mockReturnValue(of(true));
|
||||
|
||||
let guardEmitted = false;
|
||||
const subscription = runGuard().subscribe(() => {
|
||||
guardEmitted = true;
|
||||
});
|
||||
|
||||
expect(guardEmitted).toBe(false);
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
import { map, switchMap } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
|
||||
import { AutomaticUserConfirmationService } from "../abstractions";
|
||||
|
||||
export const canAccessAutoConfirmSettings: CanActivateFn = () => {
|
||||
const accountService = inject(AccountService);
|
||||
const autoConfirmService = inject(AutomaticUserConfirmationService);
|
||||
const toastService = inject(ToastService);
|
||||
const i18nService = inject(I18nService);
|
||||
const router = inject(Router);
|
||||
|
||||
return accountService.activeAccount$.pipe(
|
||||
filterOutNullish(),
|
||||
switchMap((user) => autoConfirmService.canManageAutoConfirm$(user.id)),
|
||||
map((canManageAutoConfirm) => {
|
||||
if (!canManageAutoConfirm) {
|
||||
toastService.showToast({
|
||||
variant: "error",
|
||||
title: "",
|
||||
message: i18nService.t("noPermissionsViewPage"),
|
||||
});
|
||||
|
||||
return router.createUrlTree(["/tabs/vault"]);
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
};
|
||||
1
libs/auto-confirm/src/guards/index.ts
Normal file
1
libs/auto-confirm/src/guards/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./automatic-user-confirmation-settings.guard";
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./abstractions";
|
||||
export * from "./components";
|
||||
export * from "./guards";
|
||||
export * from "./models";
|
||||
export * from "./services";
|
||||
@@ -1,62 +1,55 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom, of, throwError } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
|
||||
import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
DefaultOrganizationUserService,
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserConfirmRequest,
|
||||
} from "../../organization-user";
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { PermissionsApi } from "@bitwarden/common/admin-console/models/api/permissions.api";
|
||||
import { OrganizationData } from "@bitwarden/common/admin-console/models/data/organization.data";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { OrganizationId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model";
|
||||
|
||||
import { DefaultAutomaticUserConfirmationService } from "./default-auto-confirm.service";
|
||||
|
||||
describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
let service: DefaultAutomaticUserConfirmationService;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
let apiService: jest.Mocked<ApiService>;
|
||||
let organizationUserService: jest.Mocked<DefaultOrganizationUserService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let organizationUserService: MockProxy<DefaultOrganizationUserService>;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let organizationService: jest.Mocked<InternalOrganizationServiceAbstraction>;
|
||||
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
|
||||
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
|
||||
let organizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const mockConfirmingUserId = Utils.newGuid() as UserId;
|
||||
const mockOrganizationId = Utils.newGuid() as OrganizationId;
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockConfirmingUserId = newGuid() as UserId;
|
||||
const mockOrganizationId = newGuid() as OrganizationId;
|
||||
let mockOrganization: Organization;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = {
|
||||
getFeatureFlag$: jest.fn(),
|
||||
} as any;
|
||||
|
||||
apiService = {
|
||||
getUserPublicKey: jest.fn(),
|
||||
} as any;
|
||||
|
||||
organizationUserService = {
|
||||
buildConfirmRequest: jest.fn(),
|
||||
} as any;
|
||||
|
||||
configService = mock<ConfigService>();
|
||||
apiService = mock<ApiService>();
|
||||
organizationUserService = mock<DefaultOrganizationUserService>();
|
||||
stateProvider = new FakeStateProvider(mockAccountServiceWith(mockUserId));
|
||||
|
||||
organizationService = {
|
||||
organizations$: jest.fn(),
|
||||
} as any;
|
||||
|
||||
organizationUserApiService = {
|
||||
postOrganizationUserConfirm: jest.fn(),
|
||||
} as any;
|
||||
organizationService = mock<InternalOrganizationServiceAbstraction>();
|
||||
organizationUserApiService = mock<OrganizationUserApiService>();
|
||||
policyService = mock<PolicyService>();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
@@ -70,6 +63,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
useValue: organizationService,
|
||||
},
|
||||
{ provide: OrganizationUserApiService, useValue: organizationUserApiService },
|
||||
{ provide: PolicyService, useValue: policyService },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -80,9 +74,13 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
stateProvider,
|
||||
organizationService,
|
||||
organizationUserApiService,
|
||||
policyService,
|
||||
);
|
||||
|
||||
const mockOrgData = new OrganizationData({} as any, {} as any);
|
||||
const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, {
|
||||
isMember: true,
|
||||
isProviderUser: false,
|
||||
});
|
||||
mockOrgData.id = mockOrganizationId;
|
||||
mockOrgData.useAutomaticUserConfirmation = true;
|
||||
|
||||
@@ -180,7 +178,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
});
|
||||
|
||||
it("should preserve other user configurations when updating", async () => {
|
||||
const otherUserId = Utils.newGuid() as UserId;
|
||||
const otherUserId = newGuid() as UserId;
|
||||
const otherConfig = new AutoConfirmState();
|
||||
otherConfig.enabled = true;
|
||||
|
||||
@@ -209,12 +207,13 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
beforeEach(() => {
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([mockOrganization]);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
policyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
});
|
||||
|
||||
it("should return true when feature flag is enabled and organization allows management", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(true);
|
||||
@@ -223,7 +222,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
it("should return false when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(false);
|
||||
@@ -233,7 +232,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
// Create organization without manageUsers permission
|
||||
const mockOrgData = new OrganizationData({} as any, {} as any);
|
||||
const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, {
|
||||
isMember: true,
|
||||
isProviderUser: false,
|
||||
});
|
||||
mockOrgData.id = mockOrganizationId;
|
||||
mockOrgData.useAutomaticUserConfirmation = true;
|
||||
const permissions = new PermissionsApi();
|
||||
@@ -244,7 +246,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([orgWithoutManageUsers]);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(false);
|
||||
@@ -254,7 +256,10 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
// Create organization without useAutomaticUserConfirmation
|
||||
const mockOrgData = new OrganizationData({} as any, {} as any);
|
||||
const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, {
|
||||
isMember: true,
|
||||
isProviderUser: false,
|
||||
});
|
||||
mockOrgData.id = mockOrganizationId;
|
||||
mockOrgData.useAutomaticUserConfirmation = false;
|
||||
const permissions = new PermissionsApi();
|
||||
@@ -265,7 +270,7 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([orgWithoutAutoConfirm]);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(false);
|
||||
@@ -277,7 +282,31 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([]);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when the user is not a member of any organizations", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
// Create organization where user is not a member
|
||||
const mockOrgData = new OrganizationData({} as ProfileOrganizationResponse, {
|
||||
isMember: false,
|
||||
isProviderUser: false,
|
||||
});
|
||||
mockOrgData.id = mockOrganizationId;
|
||||
mockOrgData.useAutomaticUserConfirmation = true;
|
||||
const permissions = new PermissionsApi();
|
||||
permissions.manageUsers = true;
|
||||
mockOrgData.permissions = permissions;
|
||||
const orgWhereNotMember = new Organization(mockOrgData);
|
||||
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([orgWhereNotMember]);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(false);
|
||||
@@ -286,11 +315,58 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
it("should use the correct feature flag", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId, mockOrganizationId);
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
await firstValueFrom(canManage$);
|
||||
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(FeatureFlag.AutoConfirm);
|
||||
});
|
||||
|
||||
it("should return false when policy does not apply to user", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
policyService.policyAppliesToUser$.mockReturnValue(of(false));
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when policy applies to user", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
policyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(true);
|
||||
});
|
||||
|
||||
it("should check policy with correct PolicyType and userId", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
policyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
await firstValueFrom(canManage$);
|
||||
|
||||
expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith(
|
||||
PolicyType.AutoConfirm,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
|
||||
it("should return false when feature flag is enabled but policy does not apply", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
policyService.policyAppliesToUser$.mockReturnValue(of(false));
|
||||
|
||||
const canManage$ = service.canManageAutoConfirm$(mockUserId);
|
||||
const canManage = await firstValueFrom(canManage$);
|
||||
|
||||
expect(canManage).toBe(false);
|
||||
expect(policyService.policyAppliesToUser$).toHaveBeenCalledWith(
|
||||
PolicyType.AutoConfirm,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("autoConfirmUser", () => {
|
||||
@@ -305,8 +381,11 @@ describe("DefaultAutomaticUserConfirmationService", () => {
|
||||
const organizations$ = new BehaviorSubject<Organization[]>([mockOrganization]);
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
policyService.policyAppliesToUser$.mockReturnValue(of(true));
|
||||
|
||||
apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any);
|
||||
apiService.getUserPublicKey.mockResolvedValue({
|
||||
publicKey: mockPublicKey,
|
||||
} as UserKeyResponse);
|
||||
jest.spyOn(Utils, "fromB64ToArray").mockReturnValue(mockPublicKeyArray);
|
||||
organizationUserService.buildConfirmRequest.mockReturnValue(of(mockConfirmRequest));
|
||||
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue(undefined);
|
||||
@@ -1,17 +1,20 @@
|
||||
import { combineLatest, firstValueFrom, map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserService,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { InternalOrganizationServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { OrganizationId } from "@bitwarden/common/types/guid";
|
||||
import { StateProvider } from "@bitwarden/state";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { OrganizationUserApiService, OrganizationUserService } from "../../organization-user";
|
||||
import { AutomaticUserConfirmationService } from "../abstractions/auto-confirm.service.abstraction";
|
||||
import { AUTO_CONFIRM_STATE, AutoConfirmState } from "../models/auto-confirm-state.model";
|
||||
|
||||
@@ -23,6 +26,7 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon
|
||||
private stateProvider: StateProvider,
|
||||
private organizationService: InternalOrganizationServiceAbstraction,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private policyService: PolicyService,
|
||||
) {}
|
||||
private autoConfirmState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, AUTO_CONFIRM_STATE);
|
||||
@@ -43,15 +47,19 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon
|
||||
});
|
||||
}
|
||||
|
||||
canManageAutoConfirm$(userId: UserId, organizationId: OrganizationId): Observable<boolean> {
|
||||
canManageAutoConfirm$(userId: UserId): Observable<boolean> {
|
||||
return combineLatest([
|
||||
this.configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
|
||||
this.organizationService.organizations$(userId).pipe(getById(organizationId)),
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
// auto-confirm does not allow the user to be part of any other organization (even if admin or owner)
|
||||
// so we can assume that the first organization is the relevant one.
|
||||
.pipe(map((organizations) => organizations[0])),
|
||||
this.policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(
|
||||
map(
|
||||
([enabled, organization]) =>
|
||||
(enabled && organization?.canManageUsers && organization?.useAutomaticUserConfirmation) ??
|
||||
false,
|
||||
([enabled, organization, policyEnabled]) =>
|
||||
enabled && policyEnabled && (organization?.canManageAutoConfirm ?? false),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -62,7 +70,7 @@ export class DefaultAutomaticUserConfirmationService implements AutomaticUserCon
|
||||
organization: Organization,
|
||||
): Promise<void> {
|
||||
await firstValueFrom(
|
||||
this.canManageAutoConfirm$(userId, organization.id).pipe(
|
||||
this.canManageAutoConfirm$(userId).pipe(
|
||||
map((canManage) => {
|
||||
if (!canManage) {
|
||||
throw new Error("Cannot automatically confirm user (insufficient permissions)");
|
||||
23
libs/auto-confirm/test.setup.ts
Normal file
23
libs/auto-confirm/test.setup.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import "@bitwarden/ui-common/setup-jest";
|
||||
|
||||
Object.defineProperty(window, "CSS", { value: null });
|
||||
Object.defineProperty(window, "getComputedStyle", {
|
||||
value: () => {
|
||||
return {
|
||||
display: "none",
|
||||
appearance: ["-webkit-appearance"],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "doctype", {
|
||||
value: "<!DOCTYPE html>",
|
||||
});
|
||||
Object.defineProperty(document.body.style, "transform", {
|
||||
value: () => {
|
||||
return {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
6
libs/auto-confirm/tsconfig.eslint.json
Normal file
6
libs/auto-confirm/tsconfig.eslint.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": ["src/**/*.ts", "src/**/*.js"],
|
||||
"exclude": ["**/build", "**/dist"]
|
||||
}
|
||||
13
libs/auto-confirm/tsconfig.json
Normal file
13
libs/auto-confirm/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/auto-confirm/tsconfig.lib.json
Normal file
10
libs/auto-confirm/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.js", "src/**/*.spec.ts"]
|
||||
}
|
||||
10
libs/auto-confirm/tsconfig.spec.json
Normal file
10
libs/auto-confirm/tsconfig.spec.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"]
|
||||
}
|
||||
@@ -446,6 +446,13 @@ export abstract class ApiService {
|
||||
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
|
||||
abstract postSetupPayment(): Promise<string>;
|
||||
|
||||
/**
|
||||
* Retrieves the bearer access token for the user.
|
||||
* If the access token is expired or within 5 minutes of expiration, attempts to refresh the token
|
||||
* and persists the refresh token to state before returning it.
|
||||
* @param userId The user for whom we're retrieving the access token
|
||||
* @returns The access token, or an Error if no access token exists.
|
||||
*/
|
||||
abstract getActiveBearerToken(userId: UserId): Promise<string>;
|
||||
abstract fetch(request: Request): Promise<Response>;
|
||||
abstract nativeFetch(request: Request): Promise<Response>;
|
||||
|
||||
@@ -75,8 +75,8 @@ export function canAccessEmergencyAccess(
|
||||
) {
|
||||
return combineLatest([
|
||||
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
|
||||
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
|
||||
policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policyAppliesToUser]) => !(enabled && policyAppliesToUser)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,6 +64,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
isAdminInitiated: false,
|
||||
ssoEnabled: false,
|
||||
ssoMemberDecryptionType: undefined,
|
||||
useDisableSMAdsForUsers: false,
|
||||
usePhishingBlocker: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export class OrganizationData {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
@@ -133,6 +134,7 @@ export class OrganizationData {
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useAccessIntelligence = response.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
this.useDisableSMAdsForUsers = response.useDisableSMAdsForUsers ?? false;
|
||||
this.isAdminInitiated = response.isAdminInitiated;
|
||||
this.ssoEnabled = response.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = response.ssoMemberDecryptionType;
|
||||
|
||||
@@ -95,6 +95,7 @@ export class Organization {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
@@ -160,6 +161,7 @@ export class Organization {
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useAccessIntelligence = obj.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
this.useDisableSMAdsForUsers = obj.useDisableSMAdsForUsers ?? false;
|
||||
this.isAdminInitiated = obj.isAdminInitiated;
|
||||
this.ssoEnabled = obj.ssoEnabled;
|
||||
this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType;
|
||||
@@ -381,6 +383,13 @@ export class Organization {
|
||||
return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not call this function to perform business logic, use the function in @link AutomaticUserConfirmationService instead.
|
||||
**/
|
||||
get canManageAutoConfirm() {
|
||||
return this.isMember && this.canManageUsers && this.useAutomaticUserConfirmation;
|
||||
}
|
||||
|
||||
static fromJSON(json: Jsonify<Organization>) {
|
||||
if (json == null) {
|
||||
return null;
|
||||
|
||||
@@ -38,6 +38,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
usePhishingBlocker: boolean;
|
||||
|
||||
@@ -81,6 +82,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false;
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
|
||||
|
||||
@@ -61,6 +61,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
userIsManagedByOrganization: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
useDisableSMAdsForUsers: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
ssoMemberDecryptionType?: MemberDecryptionType;
|
||||
@@ -135,6 +136,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false;
|
||||
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
|
||||
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
|
||||
this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Auth Request Answering Service
|
||||
|
||||
This feature is to allow for the taking of auth requests that are received via websockets by the background service to
|
||||
be acted on when the user loads up a client. Currently only implemented with the browser client.
|
||||
This feature is to allow for the taking of auth requests that are received via websockets to be acted on when the user loads up a client.
|
||||
|
||||
See diagram for the high level picture of how this is wired up.
|
||||
|
||||
|
||||
@@ -1,30 +1,50 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
export abstract class AuthRequestAnsweringServiceAbstraction {
|
||||
export abstract class AuthRequestAnsweringService {
|
||||
/**
|
||||
* Tries to either display the dialog for the user or will preserve its data and show it at a
|
||||
* later time. Even in the event the dialog is shown immediately, this will write to global state
|
||||
* so that even if someone closes a window or a popup and comes back, it could be processed later.
|
||||
* Only way to clear out the global state is to respond to the auth request.
|
||||
* - Implemented on Extension and Desktop.
|
||||
*
|
||||
* Currently, this is only implemented for browser extension.
|
||||
*
|
||||
* @param userId The UserId that the auth request is for.
|
||||
* @param authRequestUserId The UserId that the auth request is for.
|
||||
* @param authRequestId The id of the auth request that is to be processed.
|
||||
*/
|
||||
abstract receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void>;
|
||||
abstract receivedPendingAuthRequest?(
|
||||
authRequestUserId: UserId,
|
||||
authRequestId: string,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* When a system notification is clicked, this function is used to process that event.
|
||||
* Confirms whether or not the user meets the conditions required to show them an
|
||||
* approval dialog immediately.
|
||||
*
|
||||
* @param authRequestUserId the UserId that the auth request is for.
|
||||
* @returns boolean stating whether or not the user meets conditions
|
||||
*/
|
||||
abstract activeUserMeetsConditionsToShowApprovalDialog(
|
||||
authRequestUserId: UserId,
|
||||
): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Sets up listeners for scenarios where the user unlocks and we want to process
|
||||
* any pending auth requests in state.
|
||||
*
|
||||
* @param destroy$ The destroy$ observable from the caller
|
||||
*/
|
||||
abstract setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void;
|
||||
|
||||
/**
|
||||
* When a system notification is clicked, this method is used to process that event.
|
||||
* - Implemented on Extension only.
|
||||
* - Desktop does not implement this method because click handling is already setup in
|
||||
* electron-main-messaging.service.ts.
|
||||
*
|
||||
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
|
||||
*/
|
||||
abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void>;
|
||||
|
||||
/**
|
||||
* Process notifications that have been received but didn't meet the conditions to display the
|
||||
* approval dialog.
|
||||
*/
|
||||
abstract processPendingAuthRequests(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "./auth-request-answering.service";
|
||||
import { PendingAuthRequestsStateService } from "./pending-auth-requests.state";
|
||||
|
||||
describe("AuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let actionService: MockProxy<ActionsService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let systemNotificationsService: MockProxy<SystemNotificationsService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
actionService = mock<ActionsService>();
|
||||
authService = mock<AuthService>();
|
||||
i18nService = mock<I18nService>();
|
||||
masterPasswordService = { forceSetPasswordReason$: jest.fn() };
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
systemNotificationsService = mock<SystemNotificationsService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
const accountInfo = mockAccountInfoWith({
|
||||
email: "user@example.com",
|
||||
name: "User",
|
||||
});
|
||||
accountService.activeAccount$ = of({
|
||||
id: userId,
|
||||
...accountInfo,
|
||||
});
|
||||
accountService.accounts$ = of({
|
||||
[userId]: accountInfo,
|
||||
});
|
||||
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
|
||||
of(ForceSetPasswordReason.None),
|
||||
);
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
i18nService.t.mockImplementation(
|
||||
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
|
||||
);
|
||||
systemNotificationsService.create.mockResolvedValue("notif-id");
|
||||
|
||||
sut = new AuthRequestAnsweringService(
|
||||
accountService,
|
||||
actionService,
|
||||
authService,
|
||||
i18nService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
platformUtilsService,
|
||||
systemNotificationsService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("handleAuthRequestNotificationClicked", () => {
|
||||
it("clears notification and opens popup when notification body is clicked", async () => {
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.NotificationButton,
|
||||
};
|
||||
|
||||
await sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" });
|
||||
expect(actionService.openPopup).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does nothing when an optional button is clicked", async () => {
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.FirstOptionalButton,
|
||||
};
|
||||
|
||||
await sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
expect(systemNotificationsService.clear).not.toHaveBeenCalled();
|
||||
expect(actionService.openPopup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("receivedPendingAuthRequest", () => {
|
||||
const authRequestId = "req-abc";
|
||||
|
||||
it("creates a system notification when popup is not open", async () => {
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(false);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested");
|
||||
expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com");
|
||||
expect(systemNotificationsService.create).toHaveBeenCalledWith({
|
||||
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`,
|
||||
title: "accountAccessRequested",
|
||||
body: "confirmAccessAttempt:user@example.com",
|
||||
buttons: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create a notification when popup is open, user is active, unlocked, and no force set password", async () => {
|
||||
platformUtilsService.isPopupOpen.mockResolvedValue(true);
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
|
||||
of(ForceSetPasswordReason.None),
|
||||
);
|
||||
|
||||
await sut.receivedPendingAuthRequest(userId, authRequestId);
|
||||
|
||||
expect(systemNotificationsService.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
|
||||
constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly actionService: ActionsService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private readonly messagingService: MessagingService,
|
||||
private readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
private readonly platformUtilsService: PlatformUtilsService,
|
||||
private readonly systemNotificationsService: SystemNotificationsService,
|
||||
) {}
|
||||
|
||||
async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void> {
|
||||
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
const activeUserId: UserId | null = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(userId),
|
||||
);
|
||||
const popupOpen = await this.platformUtilsService.isPopupOpen();
|
||||
|
||||
// Always persist the pending marker for this user to global state.
|
||||
await this.pendingAuthRequestsState.add(userId);
|
||||
|
||||
// These are the conditions we are looking for to know if the extension is in a state to show
|
||||
// the approval dialog.
|
||||
const userIsAvailableToReceiveAuthRequest =
|
||||
popupOpen &&
|
||||
authStatus === AuthenticationStatus.Unlocked &&
|
||||
activeUserId === userId &&
|
||||
forceSetPasswordReason === ForceSetPasswordReason.None;
|
||||
|
||||
if (!userIsAvailableToReceiveAuthRequest) {
|
||||
// Get the user's email to include in the system notification
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
const emailForUser = accounts[userId].email;
|
||||
|
||||
await this.systemNotificationsService.create({
|
||||
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter.
|
||||
title: this.i18nService.t("accountAccessRequested"),
|
||||
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
|
||||
buttons: [],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Popup is open and conditions are met; open dialog immediately for this request
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
|
||||
await this.systemNotificationsService.clear({
|
||||
id: `${event.id}`,
|
||||
});
|
||||
await this.actionService.openPopup();
|
||||
}
|
||||
}
|
||||
|
||||
async processPendingAuthRequests(): Promise<void> {
|
||||
// Prune any stale pending requests (older than 15 minutes)
|
||||
// This comes from GlobalSettings.cs
|
||||
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
const fifteenMinutesMs = 15 * 60 * 1000;
|
||||
|
||||
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
|
||||
|
||||
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
|
||||
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
|
||||
|
||||
if (pendingAuthRequestsInState.length > 0) {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
|
||||
(e) => e.userId === activeUserId,
|
||||
);
|
||||
|
||||
if (pendingAuthRequestsForActiveUser) {
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import {
|
||||
ButtonLocation,
|
||||
SystemNotificationEvent,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import { DefaultAuthRequestAnsweringService } from "./default-auth-request-answering.service";
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
describe("DefaultAuthRequestAnsweringService", () => {
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
|
||||
|
||||
let sut: AuthRequestAnsweringService;
|
||||
|
||||
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
|
||||
const userAccountInfo = mockAccountInfoWith({
|
||||
name: "User",
|
||||
email: "user@example.com",
|
||||
});
|
||||
const userAccount: Account = {
|
||||
id: userId,
|
||||
...userAccountInfo,
|
||||
};
|
||||
|
||||
const otherUserId = "554c3112-9a75-23af-ab80-8dk3e9bl5i8e" as UserId;
|
||||
const otherUserAccountInfo = mockAccountInfoWith({
|
||||
name: "Other",
|
||||
email: "other@example.com",
|
||||
});
|
||||
const otherUserAccount: Account = {
|
||||
id: otherUserId,
|
||||
...otherUserAccountInfo,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
authService = mock<AuthService>();
|
||||
masterPasswordService = {
|
||||
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
|
||||
};
|
||||
messagingService = mock<MessagingService>();
|
||||
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
accountService.activeAccount$ = of(userAccount);
|
||||
accountService.accounts$ = of({
|
||||
[userId]: userAccountInfo,
|
||||
[otherUserId]: otherUserAccountInfo,
|
||||
});
|
||||
|
||||
sut = new DefaultAuthRequestAnsweringService(
|
||||
accountService,
|
||||
authService,
|
||||
masterPasswordService,
|
||||
messagingService,
|
||||
pendingAuthRequestsState,
|
||||
);
|
||||
});
|
||||
|
||||
describe("activeUserMeetsConditionsToShowApprovalDialog()", () => {
|
||||
it("should return false if there is no active user", async () => {
|
||||
// Arrange
|
||||
accountService.activeAccount$ = of(null);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is not the intended recipient of the auth request", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(otherUserId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is not unlocked", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if the active user is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Act
|
||||
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setupUnlockListenersForProcessingAuthRequests()", () => {
|
||||
let destroy$: Subject<void>;
|
||||
let activeAccount$: BehaviorSubject<Account>;
|
||||
let activeAccountStatus$: BehaviorSubject<AuthenticationStatus>;
|
||||
let authStatusForSubjects: Map<UserId, BehaviorSubject<AuthenticationStatus>>;
|
||||
let pendingRequestMarkers: PendingAuthUserMarker[];
|
||||
|
||||
beforeEach(() => {
|
||||
destroy$ = new Subject<void>();
|
||||
activeAccount$ = new BehaviorSubject(userAccount);
|
||||
activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Locked);
|
||||
authStatusForSubjects = new Map();
|
||||
pendingRequestMarkers = [];
|
||||
|
||||
accountService.activeAccount$ = activeAccount$;
|
||||
authService.activeAccountStatus$ = activeAccountStatus$;
|
||||
authService.authStatusFor$.mockImplementation((id: UserId) => {
|
||||
if (!authStatusForSubjects.has(id)) {
|
||||
authStatusForSubjects.set(id, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
}
|
||||
return authStatusForSubjects.get(id)!;
|
||||
});
|
||||
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of([]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroy$.next();
|
||||
destroy$.complete();
|
||||
});
|
||||
|
||||
describe("active account switching", () => {
|
||||
it("should process pending auth requests when switching to an unlocked user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Simulate account switching to an Unlocked account
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0)); // Allows observable chain to complete before assertion
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to a locked user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to a logged out user", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.LoggedOut));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when active account becomes null", async () => {
|
||||
// Arrange
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(null);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple user switches correctly", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(userId, new BehaviorSubject(AuthenticationStatus.Locked));
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Switch to unlocked user (should trigger)
|
||||
activeAccount$.next(otherUserAccount);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Switch to locked user (should NOT trigger)
|
||||
activeAccount$.next(userAccount);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when switching to an Unlocked user who is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccount$.next(otherUserAccount);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authentication status transitions", () => {
|
||||
it("should process pending auth requests when active account transitions to Unlocked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should process pending auth requests when transitioning from LoggedOut to Unlocked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when transitioning from Unlocked to Locked", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Clear any calls from the initial trigger (from null -> Unlocked)
|
||||
messagingService.send.mockClear();
|
||||
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when transitioning from Locked to LoggedOut", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when staying in Unlocked status", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Clear any calls from the initial trigger (from null -> Unlocked)
|
||||
messagingService.send.mockClear();
|
||||
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple status transitions correctly", async () => {
|
||||
// Arrange
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Transition to Unlocked (should trigger)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Transition to Locked (should NOT trigger)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Transition back to Unlocked (should trigger again)
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Assert
|
||||
expect(messagingService.send).toHaveBeenCalledTimes(2);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
|
||||
});
|
||||
|
||||
it("should NOT process pending auth requests when active account transitions to Unlocked but is required to set/change their master password", async () => {
|
||||
// Arrange
|
||||
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
|
||||
of(ForceSetPasswordReason.WeakMasterPassword),
|
||||
);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Locked);
|
||||
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscription cleanup", () => {
|
||||
it("should stop processing when destroy$ emits", async () => {
|
||||
// Arrange
|
||||
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
|
||||
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
|
||||
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
|
||||
|
||||
// Act
|
||||
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
|
||||
|
||||
// Emit destroy signal
|
||||
destroy$.next();
|
||||
|
||||
// Try to trigger processing after cleanup
|
||||
activeAccount$.next(otherUserAccount);
|
||||
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
|
||||
|
||||
// Assert
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAuthRequestNotificationClicked()", () => {
|
||||
it("should throw an error", async () => {
|
||||
// Arrange
|
||||
const event: SystemNotificationEvent = {
|
||||
id: "123",
|
||||
buttonIdentifier: ButtonLocation.NotificationButton,
|
||||
};
|
||||
|
||||
// Act
|
||||
const promise = sut.handleAuthRequestNotificationClicked(event);
|
||||
|
||||
// Assert
|
||||
await expect(promise).rejects.toThrow(
|
||||
"handleAuthRequestNotificationClicked() not implemented for this client",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import {
|
||||
PendingAuthRequestsStateService,
|
||||
PendingAuthUserMarker,
|
||||
} from "./pending-auth-requests.state";
|
||||
|
||||
export class DefaultAuthRequestAnsweringService implements AuthRequestAnsweringService {
|
||||
constructor(
|
||||
protected readonly accountService: AccountService,
|
||||
protected readonly authService: AuthService,
|
||||
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
protected readonly messagingService: MessagingService,
|
||||
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
|
||||
) {}
|
||||
|
||||
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
|
||||
// If the active user is not the intended recipient of the auth request, return false
|
||||
const activeUserId: UserId | null = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(getOptionalUserId),
|
||||
);
|
||||
if (activeUserId !== authRequestUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the active user is not unlocked, return false
|
||||
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
|
||||
if (authStatus !== AuthenticationStatus.Unlocked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the active user is required to set/change their master password, return false
|
||||
// Note that by this point we know that the authRequestUserId is the active UserId (see check above)
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(authRequestUserId),
|
||||
);
|
||||
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// User meets conditions: they are the intended recipient, unlocked, and not required to set/change their master password
|
||||
return true;
|
||||
}
|
||||
|
||||
setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void {
|
||||
// When account switching to a user who is Unlocked, process any pending auth requests.
|
||||
this.accountService.activeAccount$
|
||||
.pipe(
|
||||
map((a) => a?.id), // Extract active userId
|
||||
distinctUntilChanged(), // Only when userId actually changes
|
||||
filter((userId) => userId != null), // Require a valid userId
|
||||
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
|
||||
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
|
||||
tap(() => {
|
||||
void this.processPendingAuthRequests();
|
||||
}),
|
||||
takeUntil(destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
// When the active account transitions TO Unlocked, process any pending auth requests.
|
||||
this.authService.activeAccountStatus$
|
||||
.pipe(
|
||||
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
|
||||
pairwise(), // Compare previous and current statuses
|
||||
filter(
|
||||
([prev, curr]) =>
|
||||
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
|
||||
),
|
||||
takeUntil(destroy$),
|
||||
)
|
||||
.subscribe(() => {
|
||||
void this.processPendingAuthRequests();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process notifications that have been received but didn't meet the conditions to display the
|
||||
* approval dialog.
|
||||
*/
|
||||
private async processPendingAuthRequests(): Promise<void> {
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
// Only continue if the active user is not required to set/change their master password
|
||||
const forceSetPasswordReason = await firstValueFrom(
|
||||
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
|
||||
);
|
||||
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prune any stale pending requests (older than 15 minutes)
|
||||
// This comes from GlobalSettings.cs
|
||||
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
const fifteenMinutesMs = 15 * 60 * 1000;
|
||||
|
||||
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
|
||||
|
||||
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
|
||||
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
|
||||
|
||||
if (pendingAuthRequestsInState.length > 0) {
|
||||
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
|
||||
(e) => e.userId === activeUserId,
|
||||
);
|
||||
|
||||
if (pendingAuthRequestsForActiveUser) {
|
||||
this.messagingService.send("openLoginApproval");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,22 @@
|
||||
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
|
||||
constructor() {}
|
||||
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringService {
|
||||
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
|
||||
throw new Error(
|
||||
"activeUserMeetsConditionsToShowApprovalDialog() not implemented for this client",
|
||||
);
|
||||
}
|
||||
|
||||
async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void> {}
|
||||
setupUnlockListenersForProcessingAuthRequests(): void {
|
||||
throw new Error(
|
||||
"setupUnlockListenersForProcessingAuthRequests() not implemented for this client",
|
||||
);
|
||||
}
|
||||
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {}
|
||||
|
||||
async processPendingAuthRequests(): Promise<void> {}
|
||||
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
|
||||
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response";
|
||||
import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart } from "@bitwarden/pricing";
|
||||
import {
|
||||
BitwardenSubscription,
|
||||
Storage,
|
||||
SubscriptionStatus,
|
||||
SubscriptionStatuses,
|
||||
} from "@bitwarden/subscription";
|
||||
|
||||
export class BitwardenSubscriptionResponse extends BaseResponse {
|
||||
status: SubscriptionStatus;
|
||||
cart: Cart;
|
||||
storage: Storage;
|
||||
cancelAt?: Date;
|
||||
canceled?: Date;
|
||||
nextCharge?: Date;
|
||||
suspension?: Date;
|
||||
gracePeriod?: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const status = this.getResponseProperty("Status");
|
||||
if (
|
||||
status !== SubscriptionStatuses.Incomplete &&
|
||||
status !== SubscriptionStatuses.IncompleteExpired &&
|
||||
status !== SubscriptionStatuses.Trialing &&
|
||||
status !== SubscriptionStatuses.Active &&
|
||||
status !== SubscriptionStatuses.PastDue &&
|
||||
status !== SubscriptionStatuses.Canceled &&
|
||||
status !== SubscriptionStatuses.Unpaid
|
||||
) {
|
||||
throw new Error(`Failed to parse invalid subscription status: ${status}`);
|
||||
}
|
||||
this.status = status;
|
||||
|
||||
this.cart = new CartResponse(this.getResponseProperty("Cart"));
|
||||
this.storage = new StorageResponse(this.getResponseProperty("Storage"));
|
||||
|
||||
const suspension = this.getResponseProperty("Suspension");
|
||||
if (suspension) {
|
||||
this.suspension = new Date(suspension);
|
||||
}
|
||||
|
||||
const gracePeriod = this.getResponseProperty("GracePeriod");
|
||||
if (gracePeriod) {
|
||||
this.gracePeriod = gracePeriod;
|
||||
}
|
||||
|
||||
const nextCharge = this.getResponseProperty("NextCharge");
|
||||
if (nextCharge) {
|
||||
this.nextCharge = new Date(nextCharge);
|
||||
}
|
||||
|
||||
const cancelAt = this.getResponseProperty("CancelAt");
|
||||
if (cancelAt) {
|
||||
this.cancelAt = new Date(cancelAt);
|
||||
}
|
||||
|
||||
const canceled = this.getResponseProperty("Canceled");
|
||||
if (canceled) {
|
||||
this.canceled = new Date(canceled);
|
||||
}
|
||||
}
|
||||
|
||||
toDomain = (): BitwardenSubscription => {
|
||||
switch (this.status) {
|
||||
case SubscriptionStatuses.Incomplete:
|
||||
case SubscriptionStatuses.IncompleteExpired:
|
||||
case SubscriptionStatuses.PastDue:
|
||||
case SubscriptionStatuses.Unpaid: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
suspension: this.suspension!,
|
||||
gracePeriod: this.gracePeriod!,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Trialing:
|
||||
case SubscriptionStatuses.Active: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
nextCharge: this.nextCharge!,
|
||||
cancelAt: this.cancelAt,
|
||||
};
|
||||
}
|
||||
case SubscriptionStatuses.Canceled: {
|
||||
return {
|
||||
cart: this.cart,
|
||||
storage: this.storage,
|
||||
status: this.status,
|
||||
canceled: this.canceled!,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
97
libs/common/src/billing/models/response/cart.response.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Cart, CartItem, Discount } from "@bitwarden/pricing";
|
||||
|
||||
import { DiscountResponse } from "./discount.response";
|
||||
|
||||
export class CartItemResponse extends BaseResponse implements CartItem {
|
||||
translationKey: string;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
discount?: Discount;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.translationKey = this.getResponseProperty("TranslationKey");
|
||||
this.quantity = this.getResponseProperty("Quantity");
|
||||
this.cost = this.getResponseProperty("Cost");
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = discount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalStorage = this.getResponseProperty("AdditionalStorage");
|
||||
if (additionalStorage) {
|
||||
this.additionalStorage = new CartItemResponse(additionalStorage);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SecretsManagerCartItemResponse extends BaseResponse {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
|
||||
const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts");
|
||||
if (additionalServiceAccounts) {
|
||||
this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class CartResponse extends BaseResponse implements Cart {
|
||||
passwordManager: {
|
||||
seats: CartItem;
|
||||
additionalStorage?: CartItem;
|
||||
};
|
||||
secretsManager?: {
|
||||
seats: CartItem;
|
||||
additionalServiceAccounts?: CartItem;
|
||||
};
|
||||
cadence: SubscriptionCadence;
|
||||
discount?: Discount;
|
||||
estimatedTax: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.passwordManager = new PasswordManagerCartItemResponse(
|
||||
this.getResponseProperty("PasswordManager"),
|
||||
);
|
||||
|
||||
const secretsManager = this.getResponseProperty("SecretsManager");
|
||||
if (secretsManager) {
|
||||
this.secretsManager = new SecretsManagerCartItemResponse(secretsManager);
|
||||
}
|
||||
|
||||
const cadence = this.getResponseProperty("Cadence");
|
||||
if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) {
|
||||
throw new Error(`Failed to parse invalid cadence: ${cadence}`);
|
||||
}
|
||||
this.cadence = cadence;
|
||||
|
||||
const discount = this.getResponseProperty("Discount");
|
||||
if (discount) {
|
||||
this.discount = new DiscountResponse(discount);
|
||||
}
|
||||
|
||||
this.estimatedTax = this.getResponseProperty("EstimatedTax");
|
||||
}
|
||||
}
|
||||
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
18
libs/common/src/billing/models/response/discount.response.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing";
|
||||
|
||||
export class DiscountResponse extends BaseResponse implements Discount {
|
||||
type: DiscountType;
|
||||
value: number;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
const type = this.getResponseProperty("Type");
|
||||
if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) {
|
||||
throw new Error(`Failed to parse invalid discount type: ${type}`);
|
||||
}
|
||||
this.type = type;
|
||||
this.value = this.getResponseProperty("Value");
|
||||
}
|
||||
}
|
||||
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
16
libs/common/src/billing/models/response/storage.response.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { Storage } from "@bitwarden/subscription";
|
||||
|
||||
export class StorageResponse extends BaseResponse implements Storage {
|
||||
available: number;
|
||||
used: number;
|
||||
readableUsed: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
|
||||
this.available = this.getResponseProperty("Available");
|
||||
this.used = this.getResponseProperty("Used");
|
||||
this.readableUsed = this.getResponseProperty("ReadableUsed");
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
// eslint-disable-next-line @bitwarden/platform/no-enums
|
||||
export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
CreateDefaultLocation = "pm-19467-create-default-location",
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
|
||||
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
|
||||
@@ -28,11 +27,12 @@ export enum FeatureFlag {
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
|
||||
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
|
||||
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
|
||||
PM26462_Milestone_3 = "pm-26462-milestone-3",
|
||||
PM23341_Milestone_2 = "pm-23341-milestone-2",
|
||||
PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page",
|
||||
PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -40,7 +40,6 @@ export enum FeatureFlag {
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
@@ -57,7 +56,6 @@ export enum FeatureFlag {
|
||||
/* DIRT */
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
PhishingDetection = "phishing-detection",
|
||||
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
|
||||
|
||||
/* Vault */
|
||||
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
|
||||
@@ -80,6 +78,9 @@ export enum FeatureFlag {
|
||||
|
||||
/* UIF */
|
||||
RouterFocusManagement = "router-focus-management",
|
||||
|
||||
/* Secrets Manager */
|
||||
SM1719_RemoveSecretsManagerAds = "sm-1719-remove-secrets-manager-ads",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -97,7 +98,6 @@ const FALSE = false as boolean;
|
||||
*/
|
||||
export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.CreateDefaultLocation]: FALSE,
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
|
||||
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
|
||||
@@ -117,7 +117,6 @@ export const DefaultFeatureFlagValue = {
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
@@ -136,11 +135,12 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
|
||||
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
[FeatureFlag.PM23341_Milestone_2]: FALSE,
|
||||
[FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE,
|
||||
[FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
@@ -148,7 +148,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
@@ -166,6 +165,9 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* UIF */
|
||||
[FeatureFlag.RouterFocusManagement]: FALSE,
|
||||
|
||||
/* Secrets Manager */
|
||||
[FeatureFlag.SM1719_RemoveSecretsManagerAds]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
|
||||
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
|
||||
if (salt == null) {
|
||||
const bytes = await this.cryptoFunctionService.randomBytes(32);
|
||||
salt = Utils.fromBufferToUtf8(bytes);
|
||||
salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer);
|
||||
}
|
||||
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
|
||||
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, Subject, ObservedValueOf
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import { mockAccountInfoWith } from "../../../../spec";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
@@ -33,7 +33,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringServiceAbstraction>;
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let policyService: MockProxy<InternalPolicyService>;
|
||||
|
||||
@@ -127,7 +127,7 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
return webPushSupportStatusByUser.get(userId)!.asObservable();
|
||||
});
|
||||
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
|
||||
|
||||
policyService = mock<InternalPolicyService>();
|
||||
|
||||
@@ -270,13 +270,13 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
// allow async queue to drain
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
|
||||
notificationId: "auth-id-2",
|
||||
});
|
||||
// When authRequestAnsweringService.receivedPendingAuthRequest exists (Extension/Desktop),
|
||||
// only that method is called. messagingService.send is only called for Web (NoopAuthRequestAnsweringService).
|
||||
expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith(
|
||||
mockUserId2,
|
||||
"auth-id-2",
|
||||
);
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
|
||||
subscription.unsubscribe();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BehaviorSubject, bufferCount, firstValueFrom, ObservedValueOf, of, Subj
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import { awaitAsync, mockAccountInfoWith } from "../../../../spec";
|
||||
import { Matrix } from "../../../../spec/matrix";
|
||||
@@ -42,7 +42,7 @@ describe("NotificationsService", () => {
|
||||
let signalRNotificationConnectionService: MockProxy<SignalRConnectionService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let webPushNotificationConnectionService: MockProxy<WebPushConnectionService>;
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringServiceAbstraction>;
|
||||
let authRequestAnsweringService: MockProxy<AuthRequestAnsweringService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let policyService: MockProxy<InternalPolicyService>;
|
||||
|
||||
@@ -72,7 +72,7 @@ describe("NotificationsService", () => {
|
||||
signalRNotificationConnectionService = mock<SignalRConnectionService>();
|
||||
authService = mock<AuthService>();
|
||||
webPushNotificationConnectionService = mock<WorkerWebPushConnectionService>();
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringServiceAbstraction>();
|
||||
authRequestAnsweringService = mock<AuthRequestAnsweringService>();
|
||||
configService = mock<ConfigService>();
|
||||
policyService = mock<InternalPolicyService>();
|
||||
|
||||
@@ -471,5 +471,41 @@ describe("NotificationsService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationType.AuthRequest", () => {
|
||||
it("should call receivedPendingAuthRequest when it exists (Extension/Desktop)", async () => {
|
||||
authRequestAnsweringService.receivedPendingAuthRequest!.mockResolvedValue(undefined as any);
|
||||
|
||||
const notification = new NotificationResponse({
|
||||
type: NotificationType.AuthRequest,
|
||||
payload: { userId: mockUser1, id: "auth-request-123" },
|
||||
contextId: "different-app-id",
|
||||
});
|
||||
|
||||
await sut["processNotification"](notification, mockUser1);
|
||||
|
||||
expect(authRequestAnsweringService.receivedPendingAuthRequest).toHaveBeenCalledWith(
|
||||
mockUser1,
|
||||
"auth-request-123",
|
||||
);
|
||||
expect(messagingService.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call messagingService.send when receivedPendingAuthRequest does not exist (Web)", async () => {
|
||||
authRequestAnsweringService.receivedPendingAuthRequest = undefined as any;
|
||||
|
||||
const notification = new NotificationResponse({
|
||||
type: NotificationType.AuthRequest,
|
||||
payload: { userId: mockUser1, id: "auth-request-456" },
|
||||
contextId: "different-app-id",
|
||||
});
|
||||
|
||||
await sut["processNotification"](notification, mockUser1);
|
||||
|
||||
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval", {
|
||||
notificationId: "auth-request-456",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { AuthRequestAnsweringService } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { trackedMerge } from "@bitwarden/common/platform/misc";
|
||||
|
||||
@@ -67,7 +67,7 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
private readonly signalRConnectionService: SignalRConnectionService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly webPushConnectionService: WebPushConnectionService,
|
||||
private readonly authRequestAnsweringService: AuthRequestAnsweringServiceAbstraction,
|
||||
private readonly authRequestAnsweringService: AuthRequestAnsweringService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly policyService: InternalPolicyService,
|
||||
) {
|
||||
@@ -250,26 +250,28 @@ export class DefaultServerNotificationsService implements ServerNotificationsSer
|
||||
case NotificationType.SyncSendDelete:
|
||||
await this.syncService.syncDeleteSend(notification.payload as SyncSendNotification);
|
||||
break;
|
||||
case NotificationType.AuthRequest:
|
||||
await this.authRequestAnsweringService.receivedPendingAuthRequest(
|
||||
notification.payload.userId,
|
||||
notification.payload.id,
|
||||
);
|
||||
|
||||
/**
|
||||
* This call is necessary for Desktop, which for the time being uses a noop for the
|
||||
* authRequestAnsweringService.receivedPendingAuthRequest() call just above. Desktop
|
||||
* will eventually use the new AuthRequestAnsweringService, at which point we can remove
|
||||
* this second call.
|
||||
*
|
||||
* The Extension AppComponent has logic (see processingPendingAuth) that only allows one
|
||||
* pending auth request to process at a time, so this second call will not cause any
|
||||
* duplicate processing conflicts on Extension.
|
||||
*/
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
notificationId: notification.payload.id,
|
||||
});
|
||||
case NotificationType.AuthRequest: {
|
||||
// Only Extension and Desktop implement the AuthRequestAnsweringService
|
||||
if (this.authRequestAnsweringService.receivedPendingAuthRequest) {
|
||||
try {
|
||||
await this.authRequestAnsweringService.receivedPendingAuthRequest(
|
||||
notification.payload.userId,
|
||||
notification.payload.id,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logService.error(`Failed to process auth request notification: ${error}`);
|
||||
}
|
||||
} else {
|
||||
// This call is necessary for Web, which uses a NoopAuthRequestAnsweringService
|
||||
// that does not have a receivedPendingAuthRequest() method
|
||||
this.messagingService.send("openLoginApproval", {
|
||||
// Include the authRequestId so the DeviceManagementComponent can upsert the correct device.
|
||||
// This will only matter if the user is on the /device-management screen when the auth request is received.
|
||||
notificationId: notification.payload.id,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case NotificationType.SyncOrganizationStatusChanged:
|
||||
await this.syncService.fullSync(true);
|
||||
break;
|
||||
|
||||
@@ -449,4 +449,800 @@ describe("ApiService", () => {
|
||||
).rejects.toThrow(InsecureUrlNotAllowedError);
|
||||
expect(nativeFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("When a 401 Unauthorized status is received", () => {
|
||||
it("retries request with refreshed token when initial request with access token returns 401", async () => {
|
||||
// This test verifies the 401 retry flow:
|
||||
// 1. Initial request with valid token returns 401 (token expired server-side)
|
||||
// 2. After 401, buildRequest is called again, which checks tokenNeedsRefresh
|
||||
// 3. tokenNeedsRefresh returns true, triggering refreshToken via getActiveBearerToken
|
||||
// 4. refreshToken makes an HTTP call to /connect/token to get new tokens
|
||||
// 5. setTokens is called to store the new tokens, returning the refreshed access token
|
||||
// 6. Request is retried with the refreshed token and succeeds
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
|
||||
// First call (initial request): token doesn't need refresh yet
|
||||
// Subsequent calls (after 401): token needs refresh, triggering the refresh flow
|
||||
tokenService.tokenNeedsRefresh
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith("new_access_token")
|
||||
.mockResolvedValue({ sub: testActiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
"new_access_token",
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
"new_refresh_token",
|
||||
)
|
||||
.mockResolvedValue({ accessToken: "new_access_token" });
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
let callCount = 0;
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
callCount++;
|
||||
|
||||
// First call: initial request with expired token returns 401
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Second call: token refresh request
|
||||
if (callCount === 2 && request.url.includes("identity")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new_access_token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "new_refresh_token",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Third call: retry with refreshed token succeeds
|
||||
if (callCount === 3) {
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: "success" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
const response = await sut.send("GET", "/something", null, true, true, null, null);
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(3);
|
||||
expect(response).toEqual({ data: "success" });
|
||||
});
|
||||
|
||||
it("does not retry when request has no access token and returns 401", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, false, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Unauthorized" });
|
||||
|
||||
// Should only be called once (no retry)
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry when request returns non-401 error", async () => {
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
|
||||
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ message: "Bad Request" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Bad Request" });
|
||||
|
||||
// Should only be called once (no retry for non-401 errors)
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not attempt to log out unauthenticated user", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, false, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Unauthorized" });
|
||||
|
||||
expect(logoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not retry when hasResponse is false", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
|
||||
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
// When hasResponse is false, the method should throw even though no retry happens
|
||||
await expect(
|
||||
async () => await sut.send("POST", "/something", null, true, false, null, null),
|
||||
).rejects.toMatchObject({ message: "Unauthorized" });
|
||||
|
||||
// Should only be called once (no retry when hasResponse is false)
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses original user token for retry even if active user changes between requests", async () => {
|
||||
// Setup: Initial request is for testActiveUser, but during the retry, the active user switches
|
||||
// to testInactiveUser. The retry should still use testActiveUser's refreshed token.
|
||||
|
||||
let activeUserId = testActiveUser;
|
||||
|
||||
// Mock accountService to return different active users based on when it's called
|
||||
accountService.activeAccount$ = of({
|
||||
id: activeUserId,
|
||||
...mockAccountInfoWith({
|
||||
email: "user1@example.com",
|
||||
name: "Test Name",
|
||||
}),
|
||||
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testInactiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://inactive.example.com",
|
||||
getIdentityUrl: () => "https://identity.inactive.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue("active_access_token");
|
||||
tokenService.tokenNeedsRefresh
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
tokenService.getRefreshToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue("active_refresh_token");
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith("active_new_access_token")
|
||||
.mockResolvedValue({ sub: testActiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
"active_new_access_token",
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
"active_new_refresh_token",
|
||||
)
|
||||
.mockResolvedValue({ accessToken: "active_new_access_token" });
|
||||
|
||||
// Mock tokens for inactive user (should NOT be used)
|
||||
tokenService.getAccessToken
|
||||
.calledWith(testInactiveUser)
|
||||
.mockResolvedValue("inactive_access_token");
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
let callCount = 0;
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
callCount++;
|
||||
|
||||
// First call: initial request with active user's token returns 401
|
||||
if (callCount === 1) {
|
||||
expect(request.url).toBe("https://example.com/something");
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer active_access_token");
|
||||
|
||||
// After the 401, simulate active user changing
|
||||
activeUserId = testInactiveUser;
|
||||
accountService.activeAccount$ = of({
|
||||
id: testInactiveUser,
|
||||
...mockAccountInfoWith({
|
||||
email: "user2@example.com",
|
||||
name: "Inactive User",
|
||||
}),
|
||||
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
|
||||
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Second call: token refresh request for ORIGINAL user (testActiveUser)
|
||||
if (callCount === 2 && request.url.includes("identity")) {
|
||||
expect(request.url).toContain("identity.example.com");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "active_new_access_token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "active_new_refresh_token",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Third call: retry with ORIGINAL user's refreshed token, NOT the new active user's token
|
||||
if (callCount === 3) {
|
||||
expect(request.url).toBe("https://example.com/something");
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer active_new_access_token");
|
||||
// Verify we're NOT using the inactive user's endpoint
|
||||
expect(request.url).not.toContain("inactive");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: "success with original user" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected call #${callCount}: ${request.method} ${request.url}`);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
// Explicitly pass testActiveUser to ensure the request is for that specific user
|
||||
const response = await sut.send("GET", "/something", null, testActiveUser, true, null, null);
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(3);
|
||||
expect(response).toEqual({ data: "success with original user" });
|
||||
|
||||
// Verify that inactive user's token was never requested
|
||||
expect(tokenService.getAccessToken.calledWith(testInactiveUser)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws error when retry also returns 401", async () => {
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("access_token");
|
||||
// First call (initial request): token doesn't need refresh yet
|
||||
// Subsequent calls (after 401): token needs refresh, triggering the refresh flow
|
||||
tokenService.tokenNeedsRefresh
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValue(true);
|
||||
|
||||
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith("new_access_token")
|
||||
.mockResolvedValue({ sub: testActiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
"new_access_token",
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
"new_refresh_token",
|
||||
)
|
||||
.mockResolvedValue({ accessToken: "new_access_token" });
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
let callCount = 0;
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
callCount++;
|
||||
|
||||
// First call: initial request with expired token returns 401
|
||||
if (callCount === 1) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Second call: token refresh request
|
||||
if (callCount === 2 && request.url.includes("identity")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new_access_token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "new_refresh_token",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Third call: retry with refreshed token still returns 401 (user no longer has permission)
|
||||
if (callCount === 3) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Still Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
throw new Error("Unexpected call");
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Still Unauthorized" });
|
||||
|
||||
expect(nativeFetch).toHaveBeenCalledTimes(3);
|
||||
expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
|
||||
});
|
||||
|
||||
it("handles concurrent requests that both receive 401 and share token refresh", async () => {
|
||||
// This test verifies the race condition scenario:
|
||||
// 1. Request A starts with valid token
|
||||
// 2. Request B starts with valid token
|
||||
// 3. Request A gets 401, triggers refresh
|
||||
// 4. Request B gets 401 while A is refreshing
|
||||
// 5. Request B should wait for A's refresh to complete (via refreshTokenPromise cache)
|
||||
// 6. Both requests retry with the new token
|
||||
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
getIdentityUrl: () => "https://identity.example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("expired_token");
|
||||
|
||||
// First two calls: token doesn't need refresh yet
|
||||
// Subsequent calls: token needs refresh
|
||||
tokenService.tokenNeedsRefresh
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValueOnce(false) // Request A initial
|
||||
.mockResolvedValueOnce(false) // Request B initial
|
||||
.mockResolvedValue(true); // Both retries after 401
|
||||
|
||||
tokenService.getRefreshToken.calledWith(testActiveUser).mockResolvedValue("refresh_token");
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith(testActiveUser)
|
||||
.mockResolvedValue({ client_id: "web" });
|
||||
|
||||
tokenService.decodeAccessToken
|
||||
.calledWith("new_access_token")
|
||||
.mockResolvedValue({ sub: testActiveUser });
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutAction.Lock));
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$
|
||||
.calledWith(testActiveUser)
|
||||
.mockReturnValue(of(VaultTimeoutStringType.Never));
|
||||
|
||||
tokenService.setTokens
|
||||
.calledWith(
|
||||
"new_access_token",
|
||||
VaultTimeoutAction.Lock,
|
||||
VaultTimeoutStringType.Never,
|
||||
"new_refresh_token",
|
||||
)
|
||||
.mockResolvedValue({ accessToken: "new_access_token" });
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
let apiRequestCount = 0;
|
||||
let refreshRequestCount = 0;
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
if (request.url.includes("identity")) {
|
||||
refreshRequestCount++;
|
||||
// Simulate slow token refresh to expose race condition
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
access_token: "new_access_token",
|
||||
token_type: "Bearer",
|
||||
refresh_token: "new_refresh_token",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response),
|
||||
100,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
apiRequestCount++;
|
||||
const currentCall = apiRequestCount;
|
||||
|
||||
// First two calls (Request A and B initial attempts): both return 401
|
||||
if (currentCall === 1 || currentCall === 2) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: () => Promise.resolve({ message: "Unauthorized" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
// Third and fourth calls (retries after refresh): both succeed
|
||||
if (currentCall === 3 || currentCall === 4) {
|
||||
expect(request.headers.get("Authorization")).toBe("Bearer new_access_token");
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ data: `success-${currentCall}` }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected API call #${currentCall}: ${request.method} ${request.url}`);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
// Make two concurrent requests
|
||||
const [responseA, responseB] = await Promise.all([
|
||||
sut.send("GET", "/endpoint-a", null, testActiveUser, true, null, null),
|
||||
sut.send("GET", "/endpoint-b", null, testActiveUser, true, null, null),
|
||||
]);
|
||||
|
||||
// Both requests should succeed
|
||||
expect(responseA).toMatchObject({ data: expect.stringContaining("success") });
|
||||
expect(responseB).toMatchObject({ data: expect.stringContaining("success") });
|
||||
|
||||
// Verify only ONE token refresh was made (they shared the refresh)
|
||||
expect(refreshRequestCount).toBe(1);
|
||||
|
||||
// Verify the total number of API requests: 2 initial + 2 retries = 4
|
||||
expect(apiRequestCount).toBe(4);
|
||||
|
||||
// Verify setTokens was only called once
|
||||
expect(tokenService.setTokens).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("When 403 Forbidden response is received from API request", () => {
|
||||
it("logs out the authenticated user", async () => {
|
||||
environmentService.getEnvironment$.calledWith(testActiveUser).mockReturnValue(
|
||||
of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment),
|
||||
);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
tokenService.getAccessToken.calledWith(testActiveUser).mockResolvedValue("valid_token");
|
||||
tokenService.tokenNeedsRefresh.calledWith(testActiveUser).mockResolvedValue(false);
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ message: "Forbidden" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, true, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Forbidden" });
|
||||
|
||||
expect(logoutCallback).toHaveBeenCalledWith("invalidAccessToken");
|
||||
});
|
||||
|
||||
it("does not attempt to log out unauthenticated user", async () => {
|
||||
environmentService.environment$ = of({
|
||||
getApiUrl: () => "https://example.com",
|
||||
} satisfies Partial<Environment> as Environment);
|
||||
|
||||
httpOperations.createRequest.mockImplementation((url, request) => {
|
||||
return {
|
||||
url: url,
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
method: request.method,
|
||||
mode: request.mode,
|
||||
signal: request.signal,
|
||||
headers: new Headers(request.headers),
|
||||
} satisfies Partial<Request> as unknown as Request;
|
||||
});
|
||||
|
||||
const nativeFetch = jest.fn<Promise<Response>, [request: Request]>();
|
||||
|
||||
nativeFetch.mockImplementation((request) => {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ message: "Forbidden" }),
|
||||
headers: new Headers({
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
} satisfies Partial<Response> as Response);
|
||||
});
|
||||
|
||||
sut.nativeFetch = nativeFetch;
|
||||
|
||||
await expect(
|
||||
async () => await sut.send("GET", "/something", null, false, true, null, null),
|
||||
).rejects.toMatchObject({ message: "Forbidden" });
|
||||
|
||||
expect(logoutCallback).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ import { BillingHistoryResponse } from "../billing/models/response/billing-histo
|
||||
import { PaymentResponse } from "../billing/models/response/payment.response";
|
||||
import { PlanResponse } from "../billing/models/response/plan.response";
|
||||
import { SubscriptionResponse } from "../billing/models/response/subscription.response";
|
||||
import { ClientType, DeviceType } from "../enums";
|
||||
import { ClientType, DeviceType, HttpStatusCode } from "../enums";
|
||||
import { KeyConnectorUserKeyRequest } from "../key-management/key-connector/models/key-connector-user-key.request";
|
||||
import { SetKeyConnectorKeyRequest } from "../key-management/key-connector/models/set-key-connector-key.request";
|
||||
import { VaultTimeoutSettingsService } from "../key-management/vault-timeout";
|
||||
@@ -330,6 +330,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return new PaymentResponse(r);
|
||||
}
|
||||
|
||||
// TODO: Remove with deletion of pm-29594-update-individual-subscription-page
|
||||
postReinstatePremium(): Promise<any> {
|
||||
return this.send("POST", "/accounts/reinstate-premium", null, true, false);
|
||||
}
|
||||
@@ -1252,8 +1253,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const error = await this.handleError(response, false, true);
|
||||
if (response.status !== HttpStatusCode.Ok) {
|
||||
const error = await this.handleApiRequestError(response, true);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
@@ -1283,8 +1284,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const error = await this.handleError(response, false, true);
|
||||
if (response.status !== HttpStatusCode.Ok) {
|
||||
const error = await this.handleApiRequestError(response, true);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -1301,14 +1302,12 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
const error = await this.handleError(response, false, true);
|
||||
if (response.status !== HttpStatusCode.Ok) {
|
||||
const error = await this.handleApiRequestError(response, true);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
async getActiveBearerToken(userId: UserId): Promise<string> {
|
||||
let accessToken = await this.tokenService.getAccessToken(userId);
|
||||
if (await this.tokenService.tokenNeedsRefresh(userId)) {
|
||||
@@ -1370,7 +1369,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
const body = await response.json();
|
||||
return new SsoPreValidateResponse(body);
|
||||
} else {
|
||||
const error = await this.handleError(response, false, true);
|
||||
const error = await this.handleApiRequestError(response, false);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -1525,7 +1524,7 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
);
|
||||
return refreshedTokens.accessToken;
|
||||
} else {
|
||||
const error = await this.handleError(response, true, true);
|
||||
const error = await this.handleTokenRefreshRequestError(response);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
@@ -1580,6 +1579,89 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
apiUrl?: string | null,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
): Promise<any> {
|
||||
// We assume that if there is a UserId making the request, it is also an authenticated
|
||||
// request and we will attempt to add an access token to the request.
|
||||
const userIdMakingRequest = await this.getUserIdMakingRequest(authedOrUserId);
|
||||
|
||||
const environment = await firstValueFrom(
|
||||
userIdMakingRequest == null
|
||||
? this.environmentService.environment$
|
||||
: this.environmentService.getEnvironment$(userIdMakingRequest),
|
||||
);
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? environment.getApiUrl() : apiUrl;
|
||||
|
||||
const requestUrl = await this.buildSafeApiRequestUrl(apiUrl, path);
|
||||
|
||||
let request = await this.buildRequest(
|
||||
method,
|
||||
userIdMakingRequest,
|
||||
environment,
|
||||
hasResponse,
|
||||
body,
|
||||
alterHeaders,
|
||||
);
|
||||
|
||||
let response = await this.fetch(this.httpOperations.createRequest(requestUrl, request));
|
||||
|
||||
// First, check to see if we were making an authenticated request and received an Unauthorized (401)
|
||||
// response. This could mean that we attempted to make a request with an expired access token.
|
||||
// If so, attempt to refresh the token and try again.
|
||||
if (
|
||||
hasResponse &&
|
||||
userIdMakingRequest != null &&
|
||||
response.status === HttpStatusCode.Unauthorized
|
||||
) {
|
||||
this.logService.warning(
|
||||
"Unauthorized response received for request to " + path + ". Attempting request again.",
|
||||
);
|
||||
request = await this.buildRequest(
|
||||
method,
|
||||
userIdMakingRequest,
|
||||
environment,
|
||||
hasResponse,
|
||||
body,
|
||||
alterHeaders,
|
||||
);
|
||||
response = await this.fetch(this.httpOperations.createRequest(requestUrl, request));
|
||||
}
|
||||
|
||||
// At this point we are processing either the initial response or the response for the retry with the refreshed
|
||||
// access token.
|
||||
const responseType = response.headers.get("content-type");
|
||||
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
|
||||
const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1;
|
||||
if (hasResponse && response.status === HttpStatusCode.Ok && responseIsJson) {
|
||||
const responseJson = await response.json();
|
||||
return responseJson;
|
||||
} else if (hasResponse && response.status === HttpStatusCode.Ok && responseIsCsv) {
|
||||
return await response.text();
|
||||
} else if (
|
||||
response.status !== HttpStatusCode.Ok &&
|
||||
response.status !== HttpStatusCode.NoContent
|
||||
) {
|
||||
const error = await this.handleApiRequestError(response, userIdMakingRequest != null);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
private buildSafeApiRequestUrl(apiUrl: string, path: string): string {
|
||||
const pathParts = path.split("?");
|
||||
|
||||
// Check for path traversal patterns from any URL.
|
||||
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
|
||||
if (isInvalidUrl) {
|
||||
throw new Error("The request URL contains dangerous patterns.");
|
||||
}
|
||||
|
||||
const requestUrl =
|
||||
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
return requestUrl;
|
||||
}
|
||||
|
||||
private async getUserIdMakingRequest(authedOrUserId: UserId | boolean): Promise<UserId> {
|
||||
if (authedOrUserId == null) {
|
||||
throw new Error("A user id was given but it was null, cannot complete API request.");
|
||||
}
|
||||
@@ -1591,29 +1673,19 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
} else if (typeof authedOrUserId === "string") {
|
||||
userId = authedOrUserId;
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
const env = await firstValueFrom(
|
||||
userId == null
|
||||
? this.environmentService.environment$
|
||||
: this.environmentService.getEnvironment$(userId),
|
||||
);
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
|
||||
|
||||
const pathParts = path.split("?");
|
||||
// Check for path traversal patterns from any URL.
|
||||
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
|
||||
if (isInvalidUrl) {
|
||||
throw new Error("The request URL contains dangerous patterns.");
|
||||
}
|
||||
|
||||
// Prevent directory traversal from malicious paths
|
||||
const requestUrl =
|
||||
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
private async buildRequest(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
userForAccessToken: UserId | null,
|
||||
environment: Environment,
|
||||
hasResponse: boolean,
|
||||
body: string,
|
||||
alterHeaders?: (headers: Headers) => void,
|
||||
): Promise<RequestInit> {
|
||||
const [requestHeaders, requestBody] = await this.buildHeadersAndBody(
|
||||
userId,
|
||||
userForAccessToken,
|
||||
hasResponse,
|
||||
body,
|
||||
alterHeaders,
|
||||
@@ -1621,29 +1693,17 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
|
||||
const requestInit: RequestInit = {
|
||||
cache: "no-store",
|
||||
credentials: await this.getCredentials(env),
|
||||
credentials: await this.getCredentials(environment),
|
||||
method: method,
|
||||
};
|
||||
requestInit.headers = requestHeaders;
|
||||
requestInit.body = requestBody;
|
||||
const response = await this.fetch(this.httpOperations.createRequest(requestUrl, requestInit));
|
||||
|
||||
const responseType = response.headers.get("content-type");
|
||||
const responseIsJson = responseType != null && responseType.indexOf("application/json") !== -1;
|
||||
const responseIsCsv = responseType != null && responseType.indexOf("text/csv") !== -1;
|
||||
if (hasResponse && response.status === 200 && responseIsJson) {
|
||||
const responseJson = await response.json();
|
||||
return responseJson;
|
||||
} else if (hasResponse && response.status === 200 && responseIsCsv) {
|
||||
return await response.text();
|
||||
} else if (response.status !== 200 && response.status !== 204) {
|
||||
const error = await this.handleError(response, false, userId != null);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return requestInit;
|
||||
}
|
||||
|
||||
private async buildHeadersAndBody(
|
||||
userToAuthenticate: UserId | null,
|
||||
userForAccessToken: UserId | null,
|
||||
hasResponse: boolean,
|
||||
body: any,
|
||||
alterHeaders: (headers: Headers) => void,
|
||||
@@ -1665,8 +1725,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
if (alterHeaders != null) {
|
||||
alterHeaders(headers);
|
||||
}
|
||||
if (userToAuthenticate != null) {
|
||||
const authHeader = await this.getActiveBearerToken(userToAuthenticate);
|
||||
if (userForAccessToken != null) {
|
||||
const authHeader = await this.getActiveBearerToken(userForAccessToken);
|
||||
headers.set("Authorization", "Bearer " + authHeader);
|
||||
} else {
|
||||
// For unauthenticated requests, we need to tell the server what the device is for flag targeting,
|
||||
@@ -1692,32 +1752,59 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
return [headers, requestBody];
|
||||
}
|
||||
|
||||
private async handleError(
|
||||
/**
|
||||
* Handle an error response from a request to the Bitwarden API.
|
||||
* If the request is made with an access token (aka the user is authenticated),
|
||||
* and we receive a 401 or 403 response, we will log the user out, as this indicates
|
||||
* that the access token used on the request is either expired or does not have the appropriate permissions.
|
||||
* It is unlikely that it is expired, as we attempt to refresh the token on initial failure.
|
||||
* @param response The response from the API request
|
||||
* @param userIsAuthenticated A boolean indicating whether this is an authenticated request.
|
||||
* @returns An ErrorResponse with a message based on the response status.
|
||||
*/
|
||||
private async handleApiRequestError(
|
||||
response: Response,
|
||||
tokenError: boolean,
|
||||
authed: boolean,
|
||||
userIsAuthenticated: boolean,
|
||||
): Promise<ErrorResponse> {
|
||||
if (
|
||||
userIsAuthenticated &&
|
||||
(response.status === HttpStatusCode.Unauthorized ||
|
||||
response.status === HttpStatusCode.Forbidden)
|
||||
) {
|
||||
await this.logoutCallback("invalidAccessToken");
|
||||
}
|
||||
|
||||
const responseJson = await this.getJsonResponse(response);
|
||||
return new ErrorResponse(responseJson, response.status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle an error response when trying to refresh an access token.
|
||||
* If the error indicates that the user's session has expired, it will log the user out.
|
||||
* @param response The response from the token refresh request.
|
||||
* @returns An ErrorResponse with a message based on the response status.
|
||||
*/
|
||||
private async handleTokenRefreshRequestError(response: Response): Promise<ErrorResponse> {
|
||||
const responseJson = await this.getJsonResponse(response);
|
||||
|
||||
// IdentityServer will return an invalid_grant response if the refresh token has expired.
|
||||
// This means that the user's session has expired, and they need to log out.
|
||||
// We issue the logoutCallback() to log the user out through messaging.
|
||||
if (response.status === HttpStatusCode.BadRequest && responseJson?.error === "invalid_grant") {
|
||||
await this.logoutCallback("sessionExpired");
|
||||
}
|
||||
|
||||
return new ErrorResponse(responseJson, response.status, true);
|
||||
}
|
||||
|
||||
private async getJsonResponse(response: Response): Promise<any> {
|
||||
let responseJson: any = null;
|
||||
if (this.isJsonResponse(response)) {
|
||||
responseJson = await response.json();
|
||||
} else if (this.isTextPlainResponse(response)) {
|
||||
responseJson = { Message: await response.text() };
|
||||
}
|
||||
|
||||
if (authed) {
|
||||
if (
|
||||
response.status === 401 ||
|
||||
response.status === 403 ||
|
||||
(tokenError &&
|
||||
response.status === 400 &&
|
||||
responseJson != null &&
|
||||
responseJson.error === "invalid_grant")
|
||||
) {
|
||||
await this.logoutCallback("invalidGrantError");
|
||||
}
|
||||
}
|
||||
|
||||
return new ErrorResponse(responseJson, response.status, tokenError);
|
||||
return responseJson;
|
||||
}
|
||||
|
||||
private qsStringify(params: any): string {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendResponse } from "../response/send.response";
|
||||
|
||||
import { SendFileData } from "./send-file.data";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { mockContainerService, mockEnc } from "../../../../../spec";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccessResponse } from "../response/send-access.response";
|
||||
|
||||
import { SendAccess } from "./send-access";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import Domain from "../../../../platform/models/domain/domain-base";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccessResponse } from "../response/send-access.response";
|
||||
import { SendAccessView } from "../view/send-access.view";
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { EncryptService } from "../../../../key-management/crypto/abstractions/e
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ContainerService } from "../../../../platform/services/container.service";
|
||||
import { UserKey } from "../../../../types/key";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
|
||||
import { Send } from "./send";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import Domain from "../../../../platform/models/domain/domain-base";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendData } from "../data/send.data";
|
||||
import { SendView } from "../view/send.view";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
import { Send } from "../domain/send";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendFileApi } from "../api/send-file.api";
|
||||
import { SendTextApi } from "../api/send-text.api";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { View } from "../../../../models/view/view";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { SendAccess } from "../domain/send-access";
|
||||
|
||||
import { SendFileView } from "./send-file.view";
|
||||
|
||||
@@ -4,7 +4,7 @@ import { View } from "../../../../models/view/view";
|
||||
import { Utils } from "../../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { DeepJsonify } from "../../../../types/deep-jsonify";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendType } from "../../types/send-type";
|
||||
import { Send } from "../domain/send";
|
||||
|
||||
import { SendFileView } from "./send-file.view";
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
FileUploadService,
|
||||
} from "../../../platform/abstractions/file-upload/file-upload.service";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { Send } from "../models/domain/send";
|
||||
import { SendAccessRequest } from "../models/request/send-access.request";
|
||||
@@ -16,6 +15,7 @@ import { SendFileDownloadDataResponse } from "../models/response/send-file-downl
|
||||
import { SendFileUploadDataResponse } from "../models/response/send-file-upload-data.response";
|
||||
import { SendResponse } from "../models/response/send.response";
|
||||
import { SendAccessView } from "../models/view/send-access.view";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SendApiService as SendApiServiceAbstraction } from "./send-api.service.abstraction";
|
||||
import { InternalSendService } from "./send.service.abstraction";
|
||||
|
||||
@@ -24,13 +24,13 @@ import { ContainerService } from "../../../platform/services/container.service";
|
||||
import { SelfHostedEnvironment } from "../../../platform/services/default-environment.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendFileApi } from "../models/api/send-file.api";
|
||||
import { SendTextApi } from "../models/api/send-text.api";
|
||||
import { SendFileData } from "../models/data/send-file.data";
|
||||
import { SendTextData } from "../models/data/send-text.data";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SEND_USER_DECRYPTED, SEND_USER_ENCRYPTED } from "./key-definitions";
|
||||
import { SendStateProvider } from "./send-state.provider";
|
||||
|
||||
@@ -16,7 +16,6 @@ import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
import { SendType } from "../enums/send-type";
|
||||
import { SendData } from "../models/data/send.data";
|
||||
import { Send } from "../models/domain/send";
|
||||
import { SendFile } from "../models/domain/send-file";
|
||||
@@ -24,6 +23,7 @@ import { SendText } from "../models/domain/send-text";
|
||||
import { SendWithIdRequest } from "../models/request/send-with-id.request";
|
||||
import { SendView } from "../models/view/send.view";
|
||||
import { SEND_KDF_ITERATIONS } from "../send-kdf";
|
||||
import { SendType } from "../types/send-type";
|
||||
|
||||
import { SendStateProvider } from "./send-state.provider.abstraction";
|
||||
import { InternalSendService as InternalSendServiceAbstraction } from "./send.service.abstraction";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { EncString } from "../../../../key-management/crypto/models/enc-string";
|
||||
import { SendType } from "../../enums/send-type";
|
||||
import { SendTextApi } from "../../models/api/send-text.api";
|
||||
import { SendTextData } from "../../models/data/send-text.data";
|
||||
import { SendData } from "../../models/data/send.data";
|
||||
import { Send } from "../../models/domain/send";
|
||||
import { SendView } from "../../models/view/send.view";
|
||||
import { SendType } from "../../types/send-type";
|
||||
|
||||
export function testSendViewData(id: string, name: string) {
|
||||
const data = new SendView({} as any);
|
||||
|
||||
7
libs/common/src/tools/send/types/send-filter-type.ts
Normal file
7
libs/common/src/tools/send/types/send-filter-type.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const SendFilterType = Object.freeze({
|
||||
All: "all",
|
||||
Text: "text",
|
||||
File: "file",
|
||||
} as const);
|
||||
|
||||
export type SendFilterType = (typeof SendFilterType)[keyof typeof SendFilterType];
|
||||
@@ -3,12 +3,14 @@ import { Observable } from "rxjs";
|
||||
import { CipherId, UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
|
||||
export abstract class CipherArchiveService {
|
||||
abstract hasArchiveFlagEnabled$: Observable<boolean>;
|
||||
abstract archivedCiphers$(userId: UserId): Observable<CipherViewLike[]>;
|
||||
abstract userCanArchive$(userId: UserId): Observable<boolean>;
|
||||
abstract userHasPremium$(userId: UserId): Observable<boolean>;
|
||||
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
|
||||
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void>;
|
||||
abstract archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
|
||||
abstract unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData>;
|
||||
abstract showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean>;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ export abstract class CipherEncryptionService {
|
||||
*/
|
||||
abstract encrypt(model: CipherView, userId: UserId): Promise<EncryptionContext | undefined>;
|
||||
|
||||
/**
|
||||
* Encrypts multiple ciphers using the SDK for the given userId.
|
||||
*
|
||||
* @param models The cipher views to encrypt
|
||||
* @param userId The user ID to initialize the SDK client with
|
||||
*
|
||||
* @returns A promise that resolves to an array of encryption contexts
|
||||
*/
|
||||
abstract encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]>;
|
||||
|
||||
/**
|
||||
* Move the cipher to the specified organization by re-encrypting its keys with the organization's key.
|
||||
* The cipher.organizationId will be updated to the new organizationId.
|
||||
|
||||
@@ -50,6 +50,15 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
|
||||
keyForCipherKeyDecryption?: SymmetricCryptoKey,
|
||||
originalCipher?: Cipher,
|
||||
): Promise<EncryptionContext>;
|
||||
/**
|
||||
* Encrypts multiple ciphers for the given user.
|
||||
*
|
||||
* @param models The cipher views to encrypt
|
||||
* @param userId The user ID to encrypt for
|
||||
*
|
||||
* @returns A promise that resolves to an array of encryption contexts
|
||||
*/
|
||||
abstract encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]>;
|
||||
abstract encryptFields(fieldsModel: FieldView[], key: SymmetricCryptoKey): Promise<Field[]>;
|
||||
abstract encryptField(fieldModel: FieldView, key: SymmetricCryptoKey): Promise<Field>;
|
||||
abstract get(id: string, userId: UserId): Promise<Cipher>;
|
||||
|
||||
@@ -109,6 +109,10 @@ export class CipherView implements View, InitializerMetadata {
|
||||
return this.item?.subTitle;
|
||||
}
|
||||
|
||||
get canBeArchived(): boolean {
|
||||
return !this.isDeleted && !this.isArchived;
|
||||
}
|
||||
|
||||
get hasPasswordHistory(): boolean {
|
||||
return this.passwordHistory && this.passwordHistory.length > 0;
|
||||
}
|
||||
|
||||
@@ -868,7 +868,7 @@ describe("Cipher Service", () => {
|
||||
const result = await firstValueFrom(
|
||||
stateProvider.singleUser.getFake(mockUserId, ENCRYPTED_CIPHERS).state$,
|
||||
);
|
||||
expect(result[cipherId].archivedDate).toBeNull();
|
||||
expect(result[cipherId].archivedDate).toEqual("2024-01-01T12:00:00.000Z");
|
||||
expect(result[cipherId].deletedDate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -340,6 +340,24 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]> {
|
||||
const sdkEncryptionEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM22136_SdkCipherEncryption,
|
||||
);
|
||||
|
||||
if (sdkEncryptionEnabled) {
|
||||
return await this.cipherEncryptionService.encryptMany(models, userId);
|
||||
}
|
||||
|
||||
// Fallback to sequential encryption if SDK disabled
|
||||
const results: EncryptionContext[] = [];
|
||||
for (const model of models) {
|
||||
const result = await this.encrypt(model, userId);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async encryptAttachments(
|
||||
attachmentsModel: AttachmentView[],
|
||||
key: SymmetricCryptoKey,
|
||||
@@ -1461,7 +1479,6 @@ export class CipherService implements CipherServiceAbstraction {
|
||||
return;
|
||||
}
|
||||
ciphers[cipherId].deletedDate = new Date().toISOString();
|
||||
ciphers[cipherId].archivedDate = null;
|
||||
};
|
||||
|
||||
if (typeof id === "string") {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* include structuredClone in test environment.
|
||||
* @jest-environment ../../../../shared/test.environment.ts
|
||||
*/
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of, firstValueFrom, BehaviorSubject } from "rxjs";
|
||||
|
||||
@@ -165,6 +169,7 @@ describe("DefaultCipherArchiveService", () => {
|
||||
|
||||
mockCipherService.cipherListViews$.mockReturnValue(of(mockCiphers));
|
||||
mockBillingAccountProfileStateService.hasPremiumFromAnySource$.mockReturnValue(of(false));
|
||||
featureFlag.next(true);
|
||||
|
||||
const result = await firstValueFrom(service.showSubscriptionEndedMessaging$(userId));
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@bitwarden/common/vault/utils/cipher-view-like-utils";
|
||||
|
||||
import { CipherArchiveService } from "../abstractions/cipher-archive.service";
|
||||
import { CipherData } from "../models/data/cipher.data";
|
||||
|
||||
export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
constructor(
|
||||
@@ -71,21 +72,30 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
|
||||
/** Returns true when the user has previously archived ciphers but lost their premium membership. */
|
||||
showSubscriptionEndedMessaging$(userId: UserId): Observable<boolean> {
|
||||
return combineLatest([this.archivedCiphers$(userId), this.userHasPremium$(userId)]).pipe(
|
||||
map(([archivedCiphers, hasPremium]) => archivedCiphers.length > 0 && !hasPremium),
|
||||
return combineLatest([
|
||||
this.archivedCiphers$(userId),
|
||||
this.userHasPremium$(userId),
|
||||
this.hasArchiveFlagEnabled$,
|
||||
]).pipe(
|
||||
map(
|
||||
([archivedCiphers, hasPremium, flagEnabled]) =>
|
||||
flagEnabled && archivedCiphers.length > 0 && !hasPremium,
|
||||
),
|
||||
shareReplay({ refCount: true, bufferSize: 1 }),
|
||||
);
|
||||
}
|
||||
|
||||
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
|
||||
async archiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
|
||||
const request = new CipherBulkArchiveRequest(Array.isArray(ids) ? ids : [ids]);
|
||||
const r = await this.apiService.send("PUT", "/ciphers/archive", request, true, true);
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
|
||||
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
// prevent mutating ciphers$ state
|
||||
const localCiphers = structuredClone(currentCiphers);
|
||||
|
||||
for (const cipher of response.data) {
|
||||
const localCipher = currentCiphers[cipher.id as CipherId];
|
||||
const localCipher = localCiphers[cipher.id as CipherId];
|
||||
|
||||
if (localCipher == null) {
|
||||
continue;
|
||||
@@ -95,18 +105,21 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.upsert(Object.values(currentCiphers), userId);
|
||||
await this.cipherService.upsert(Object.values(localCiphers), userId);
|
||||
return response.data[0];
|
||||
}
|
||||
|
||||
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<void> {
|
||||
async unarchiveWithServer(ids: CipherId | CipherId[], userId: UserId): Promise<CipherData> {
|
||||
const request = new CipherBulkUnarchiveRequest(Array.isArray(ids) ? ids : [ids]);
|
||||
const r = await this.apiService.send("PUT", "/ciphers/unarchive", request, true, true);
|
||||
const response = new ListResponse(r, CipherResponse);
|
||||
|
||||
const currentCiphers = await firstValueFrom(this.cipherService.ciphers$(userId));
|
||||
// prevent mutating ciphers$ state
|
||||
const localCiphers = structuredClone(currentCiphers);
|
||||
|
||||
for (const cipher of response.data) {
|
||||
const localCipher = currentCiphers[cipher.id as CipherId];
|
||||
const localCipher = localCiphers[cipher.id as CipherId];
|
||||
|
||||
if (localCipher == null) {
|
||||
continue;
|
||||
@@ -116,6 +129,7 @@ export class DefaultCipherArchiveService implements CipherArchiveService {
|
||||
localCipher.revisionDate = cipher.revisionDate;
|
||||
}
|
||||
|
||||
await this.cipherService.upsert(Object.values(currentCiphers), userId);
|
||||
await this.cipherService.upsert(Object.values(localCiphers), userId);
|
||||
return response.data[0];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +253,68 @@ describe("DefaultCipherEncryptionService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptMany", () => {
|
||||
it("should encrypt multiple ciphers", async () => {
|
||||
const cipherView2 = new CipherView(cipherObj);
|
||||
cipherView2.name = "test-name-2";
|
||||
const cipherView3 = new CipherView(cipherObj);
|
||||
cipherView3.name = "test-name-3";
|
||||
|
||||
const ciphers = [cipherViewObj, cipherView2, cipherView3];
|
||||
|
||||
const expectedCipher1: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-1",
|
||||
} as unknown as Cipher;
|
||||
|
||||
const expectedCipher2: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-2",
|
||||
} as unknown as Cipher;
|
||||
|
||||
const expectedCipher3: Cipher = {
|
||||
id: cipherId as string,
|
||||
type: CipherType.Login,
|
||||
name: "encrypted-name-3",
|
||||
} as unknown as Cipher;
|
||||
|
||||
mockSdkClient.vault().ciphers().encrypt.mockReturnValue({
|
||||
cipher: sdkCipher,
|
||||
encryptedFor: userId,
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(Cipher, "fromSdkCipher")
|
||||
.mockReturnValueOnce(expectedCipher1)
|
||||
.mockReturnValueOnce(expectedCipher2)
|
||||
.mockReturnValueOnce(expectedCipher3);
|
||||
|
||||
const results = await cipherEncryptionService.encryptMany(ciphers, userId);
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].cipher).toEqual(expectedCipher1);
|
||||
expect(results[1].cipher).toEqual(expectedCipher2);
|
||||
expect(results[2].cipher).toEqual(expectedCipher3);
|
||||
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).toHaveBeenCalledTimes(3);
|
||||
|
||||
expect(results[0].encryptedFor).toBe(userId);
|
||||
expect(results[1].encryptedFor).toBe(userId);
|
||||
expect(results[2].encryptedFor).toBe(userId);
|
||||
});
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
const results = await cipherEncryptionService.encryptMany([], userId);
|
||||
|
||||
expect(results).toBeDefined();
|
||||
expect(results.length).toBe(0);
|
||||
expect(mockSdkClient.vault().ciphers().encrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptCipherForRotation", () => {
|
||||
it("should call the sdk method to encrypt the cipher with a new key for rotation", async () => {
|
||||
mockSdkClient.vault().ciphers().encrypt_cipher_for_rotation.mockReturnValue({
|
||||
|
||||
@@ -51,6 +51,44 @@ export class DefaultCipherEncryptionService implements CipherEncryptionService {
|
||||
);
|
||||
}
|
||||
|
||||
async encryptMany(models: CipherView[], userId: UserId): Promise<EncryptionContext[]> {
|
||||
if (!models || models.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return firstValueFrom(
|
||||
this.sdkService.userClient$(userId).pipe(
|
||||
map((sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
|
||||
using ref = sdk.take();
|
||||
|
||||
const results: EncryptionContext[] = [];
|
||||
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-30580
|
||||
// Replace this loop with a native SDK encryptMany method for better performance.
|
||||
for (const model of models) {
|
||||
const sdkCipherView = this.toSdkCipherView(model, ref.value);
|
||||
const encryptionContext = ref.value.vault().ciphers().encrypt(sdkCipherView);
|
||||
|
||||
results.push({
|
||||
cipher: Cipher.fromSdkCipher(encryptionContext.cipher)!,
|
||||
encryptedFor: uuidAsString(encryptionContext.encryptedFor) as UserId,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}),
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error(`Failed to encrypt ciphers in batch: ${error}`);
|
||||
return EMPTY;
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async moveToOrganization(
|
||||
model: CipherView,
|
||||
organizationId: OrganizationId,
|
||||
|
||||
@@ -250,6 +250,38 @@ describe("DefaultCipherRiskService", () => {
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter out deleted Login ciphers", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([]);
|
||||
|
||||
const activeCipher = new CipherView();
|
||||
activeCipher.id = mockCipherId1;
|
||||
activeCipher.type = CipherType.Login;
|
||||
activeCipher.login = new LoginView();
|
||||
activeCipher.login.password = "password1";
|
||||
activeCipher.deletedDate = undefined;
|
||||
|
||||
const deletedCipher = new CipherView();
|
||||
deletedCipher.id = mockCipherId2;
|
||||
deletedCipher.type = CipherType.Login;
|
||||
deletedCipher.login = new LoginView();
|
||||
deletedCipher.login.password = "password2";
|
||||
deletedCipher.deletedDate = new Date();
|
||||
|
||||
await cipherRiskService.computeRiskForCiphers([activeCipher, deletedCipher], mockUserId);
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
id: expect.anything(),
|
||||
password: "password1",
|
||||
}),
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPasswordReuseMap", () => {
|
||||
@@ -284,6 +316,41 @@ describe("DefaultCipherRiskService", () => {
|
||||
]);
|
||||
expect(result).toEqual(mockReuseMap);
|
||||
});
|
||||
|
||||
it("should exclude deleted ciphers when building password reuse map", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const mockReuseMap = {
|
||||
password1: 1,
|
||||
};
|
||||
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue(mockReuseMap);
|
||||
|
||||
const activeCipher = new CipherView();
|
||||
activeCipher.id = mockCipherId1;
|
||||
activeCipher.type = CipherType.Login;
|
||||
activeCipher.login = new LoginView();
|
||||
activeCipher.login.password = "password1";
|
||||
activeCipher.deletedDate = undefined;
|
||||
|
||||
const deletedCipherWithSamePassword = new CipherView();
|
||||
deletedCipherWithSamePassword.id = mockCipherId2;
|
||||
deletedCipherWithSamePassword.type = CipherType.Login;
|
||||
deletedCipherWithSamePassword.login = new LoginView();
|
||||
deletedCipherWithSamePassword.login.password = "password1";
|
||||
deletedCipherWithSamePassword.deletedDate = new Date();
|
||||
|
||||
const result = await cipherRiskService.buildPasswordReuseMap(
|
||||
[activeCipher, deletedCipherWithSamePassword],
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ password: "password1" }),
|
||||
]);
|
||||
expect(result).toEqual(mockReuseMap);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeCipherRiskForUser", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user