mirror of
https://github.com/bitwarden/browser
synced 2026-03-02 19:41:26 +00:00
Merge branch 'main' into km/auto-kdf-qa
This commit is contained in:
@@ -16,6 +16,6 @@ export const AUTO_CONFIRM_STATE = UserKeyDefinition.record<AutoConfirmState>(
|
||||
"autoConfirm",
|
||||
{
|
||||
deserializer: (autoConfirmState) => autoConfirmState,
|
||||
clearOn: ["logout"],
|
||||
clearOn: [],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -177,8 +177,7 @@ describe("DefaultCollectionService", () => {
|
||||
// Arrange dependencies
|
||||
void setEncryptedState([collection1, collection2]).then(() => {
|
||||
// Act: emit undefined
|
||||
cryptoKeys.next(undefined);
|
||||
keyService.activeUserOrgKeys$ = of(undefined);
|
||||
cryptoKeys.next(null);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<span slot="secondary" class="tw-text-sm">
|
||||
<br />
|
||||
<div>
|
||||
<span class="tw-font-semibold"> {{ "firstLogin" | i18n }}: </span>
|
||||
<span class="tw-font-medium"> {{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
</div>
|
||||
</span>
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
|
||||
<div>
|
||||
<span class="tw-font-semibold">{{ "firstLogin" | i18n }}: </span>
|
||||
<span class="tw-font-medium">{{ "firstLogin" | i18n }}: </span>
|
||||
<span>{{ device.firstLogin | date: "medium" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div bitTypography="body2">
|
||||
{{ "accessing" | i18n }}:
|
||||
<button [bitMenuTriggerFor]="environmentOptions" bitLink type="button">
|
||||
<b class="tw-text-primary-600 tw-font-semibold">{{
|
||||
<b class="tw-text-primary-600 tw-font-medium">{{
|
||||
data.selectedRegion?.domain || ("selfHostedServer" | i18n)
|
||||
}}</b>
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./premium.component";
|
||||
export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component";
|
||||
|
||||
@@ -10,7 +10,13 @@ import { BadgeModule } from "@bitwarden/components";
|
||||
selector: "app-premium-badge",
|
||||
standalone: true,
|
||||
template: `
|
||||
<button type="button" *appNotPremium bitBadge variant="success" (click)="promptForPremium()">
|
||||
<button
|
||||
type="button"
|
||||
*appNotPremium
|
||||
bitBadge
|
||||
variant="success"
|
||||
(click)="promptForPremium($event)"
|
||||
>
|
||||
{{ "premium" | i18n }}
|
||||
</button>
|
||||
`,
|
||||
@@ -21,7 +27,9 @@ export class PremiumBadgeComponent {
|
||||
|
||||
constructor(private premiumUpgradePromptService: PremiumUpgradePromptService) {}
|
||||
|
||||
async promptForPremium() {
|
||||
async promptForPremium(event: Event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
await this.premiumUpgradePromptService.promptForPremium(this.organizationId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,18 +5,11 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessageSender } from "@bitwarden/common/platform/messaging";
|
||||
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { BadgeModule, I18nMockService } from "@bitwarden/components";
|
||||
|
||||
import { PremiumBadgeComponent } from "./premium-badge.component";
|
||||
|
||||
class MockMessagingService implements MessageSender {
|
||||
send = () => {
|
||||
alert("Clicked on badge");
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Billing/Premium Badge",
|
||||
component: PremiumBadgeComponent,
|
||||
@@ -40,12 +33,6 @@ export default {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MessageSender,
|
||||
useFactory: () => {
|
||||
return new MockMessagingService();
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: BillingAccountProfileStateService,
|
||||
useValue: {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
@if (cardDetails$ | async; as cardDetails) {
|
||||
<section
|
||||
class="tw-min-w-[332px] md:tw-max-w-sm tw-overflow-y-auto tw-self-center tw-bg-background tw-rounded-xl tw-shadow-lg tw-border-secondary-100 tw-border-solid tw-border"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
<header
|
||||
class="tw-flex tw-items-center tw-justify-end tw-pl-6 tw-pt-3 tw-pr-2 !tw-bg-background !tw-border-none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
[label]="'close' | i18n"
|
||||
(click)="close()"
|
||||
></button>
|
||||
</header>
|
||||
<div class="tw-flex tw-justify-center tw-mb-6">
|
||||
<div
|
||||
class="tw-box-border tw-bg-background tw-text-main tw-size-full tw-flex tw-flex-col tw-px-8 tw-pb-2 tw-w-full tw-max-w-md"
|
||||
>
|
||||
<div class="tw-flex tw-items-center tw-justify-between tw-mb-2">
|
||||
<h3 slot="title" class="tw-m-0" bitTypography="h3">
|
||||
{{ "upgradeToPremium" | i18n }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<!-- Tagline with consistent height (exactly 2 lines) -->
|
||||
<div class="tw-mb-6 tw-h-6">
|
||||
<p bitTypography="helper" class="tw-text-muted tw-m-0 tw-leading-relaxed tw-line-clamp-2">
|
||||
{{ cardDetails.tagline }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Price Section -->
|
||||
<div class="tw-mb-6">
|
||||
<div class="tw-flex tw-items-baseline tw-gap-1 tw-flex-wrap">
|
||||
<span class="tw-text-3xl tw-font-medium tw-leading-none tw-m-0">{{
|
||||
cardDetails.price.amount | currency: "$"
|
||||
}}</span>
|
||||
<span bitTypography="helper" class="tw-text-muted">
|
||||
/ {{ cardDetails.price.cadence }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button space (always reserved) -->
|
||||
<div class="tw-mb-6 tw-h-12">
|
||||
<button
|
||||
bitButton
|
||||
[buttonType]="cardDetails.button.type"
|
||||
[block]="true"
|
||||
(click)="upgrade()"
|
||||
type="button"
|
||||
>
|
||||
@if (cardDetails.button.icon?.position === "before") {
|
||||
<i class="bwi {{ cardDetails.button.icon.type }} tw-me-2" aria-hidden="true"></i>
|
||||
}
|
||||
{{ cardDetails.button.text }}
|
||||
@if (
|
||||
cardDetails.button.icon &&
|
||||
(cardDetails.button.icon.position === "after" || !cardDetails.button.icon.position)
|
||||
) {
|
||||
<i class="bwi {{ cardDetails.button.icon.type }} tw-ms-2" aria-hidden="true"></i>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Features List -->
|
||||
<div class="tw-flex-grow">
|
||||
@if (cardDetails.features.length > 0) {
|
||||
<ul class="tw-list-none tw-p-0 tw-m-0">
|
||||
@for (feature of cardDetails.features; track feature) {
|
||||
<li class="tw-flex tw-items-start tw-gap-2 tw-mb-2 last:tw-mb-0">
|
||||
<i
|
||||
class="bwi bwi-check tw-text-primary-600 tw-mt-0.5 tw-flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span bitTypography="helper" class="tw-text-muted tw-leading-relaxed">{{
|
||||
feature
|
||||
}}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
} @else {
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin tw-text-muted"
|
||||
title="{{ 'loading' | i18n }}"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { NoopAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { firstValueFrom, of, throwError } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import {
|
||||
EnvironmentService,
|
||||
Region,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { DialogRef, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
|
||||
|
||||
describe("PremiumUpgradeDialogComponent", () => {
|
||||
let component: PremiumUpgradeDialogComponent;
|
||||
let fixture: ComponentFixture<PremiumUpgradeDialogComponent>;
|
||||
let mockDialogRef: jest.Mocked<DialogRef>;
|
||||
let mockSubscriptionPricingService: jest.Mocked<SubscriptionPricingServiceAbstraction>;
|
||||
let mockI18nService: jest.Mocked<I18nService>;
|
||||
let mockToastService: jest.Mocked<ToastService>;
|
||||
let mockEnvironmentService: jest.Mocked<EnvironmentService>;
|
||||
let mockPlatformUtilsService: jest.Mocked<PlatformUtilsService>;
|
||||
let mockLogService: jest.Mocked<LogService>;
|
||||
|
||||
const mockPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Advanced features for power users",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
features: [
|
||||
{ key: "feature1", value: "Feature 1" },
|
||||
{ key: "feature2", value: "Feature 2" },
|
||||
{ key: "feature3", value: "Feature 3" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockFamiliesTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: "Families",
|
||||
description: "Family plan",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: 6,
|
||||
annualPrice: 40,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
features: [{ key: "featureA", value: "Feature A" }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
mockDialogRef = {
|
||||
close: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockSubscriptionPricingService = {
|
||||
getPersonalSubscriptionPricingTiers$: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockI18nService = {
|
||||
t: jest.fn((key: string) => key),
|
||||
} as any;
|
||||
|
||||
mockToastService = {
|
||||
showToast: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockEnvironmentService = {
|
||||
environment$: of({
|
||||
getWebVaultUrl: () => "https://vault.bitwarden.com",
|
||||
getRegion: () => Region.US,
|
||||
}),
|
||||
} as any;
|
||||
|
||||
mockPlatformUtilsService = {
|
||||
launchUri: jest.fn(),
|
||||
} as any;
|
||||
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
of([mockPremiumTier, mockFamiliesTier]),
|
||||
);
|
||||
|
||||
mockLogService = {
|
||||
error: jest.fn(),
|
||||
} as any;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [NoopAnimationsModule, PremiumUpgradeDialogComponent, CdkTrapFocus],
|
||||
providers: [
|
||||
{ provide: DialogRef, useValue: mockDialogRef },
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: mockSubscriptionPricingService,
|
||||
},
|
||||
{ provide: I18nService, useValue: mockI18nService },
|
||||
{ provide: ToastService, useValue: mockToastService },
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should create", () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should emit cardDetails$ observable with Premium tier data", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$).toHaveBeenCalled();
|
||||
expect(cardDetails).toBeDefined();
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
});
|
||||
|
||||
it("should filter to Premium tier only", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
expect(cardDetails?.title).not.toBe("Families");
|
||||
});
|
||||
|
||||
it("should map Premium tier to card details correctly", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(cardDetails?.title).toBe("Premium");
|
||||
expect(cardDetails?.tagline).toBe("Advanced features for power users");
|
||||
expect(cardDetails?.price.amount).toBe(10 / 12);
|
||||
expect(cardDetails?.price.cadence).toBe("monthly");
|
||||
expect(cardDetails?.button.text).toBe("upgradeNow");
|
||||
expect(cardDetails?.button.type).toBe("primary");
|
||||
expect(cardDetails?.features).toEqual(["Feature 1", "Feature 2", "Feature 3"]);
|
||||
});
|
||||
|
||||
it("should use i18nService for button text", async () => {
|
||||
const cardDetails = await firstValueFrom(component["cardDetails$"]);
|
||||
|
||||
expect(mockI18nService.t).toHaveBeenCalledWith("upgradeNow");
|
||||
expect(cardDetails?.button.text).toBe("upgradeNow");
|
||||
});
|
||||
|
||||
describe("upgrade()", () => {
|
||||
it("should launch URI with query parameter", async () => {
|
||||
await component["upgrade"]();
|
||||
|
||||
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
|
||||
"https://vault.bitwarden.com/#/settings/subscription/premium?callToAction=upgradeToPremium",
|
||||
);
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should close dialog when close button clicked", () => {
|
||||
component["close"]();
|
||||
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should show error toast and return EMPTY and close dialog when getPersonalSubscriptionPricingTiers$ throws an error", (done) => {
|
||||
const error = new Error("Service error");
|
||||
mockSubscriptionPricingService.getPersonalSubscriptionPricingTiers$.mockReturnValue(
|
||||
throwError(() => error),
|
||||
);
|
||||
|
||||
const errorFixture = TestBed.createComponent(PremiumUpgradeDialogComponent);
|
||||
const errorComponent = errorFixture.componentInstance;
|
||||
errorFixture.detectChanges();
|
||||
|
||||
const cardDetails$ = errorComponent["cardDetails$"];
|
||||
|
||||
cardDetails$.subscribe({
|
||||
next: () => {
|
||||
done.fail("Observable should not emit any values");
|
||||
},
|
||||
complete: () => {
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "error",
|
||||
title: "error",
|
||||
message: "unexpectedError",
|
||||
});
|
||||
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||
done();
|
||||
},
|
||||
error: (err: unknown) => done.fail(`Observable should not error: ${err}`),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
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 {
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
ToastOptions,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
|
||||
import { PremiumUpgradeDialogComponent } from "./premium-upgrade-dialog.component";
|
||||
|
||||
const mockPremiumTier: PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: "Premium",
|
||||
description: "Complete online security",
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: 10,
|
||||
annualPricePerAdditionalStorageGB: 4,
|
||||
features: [
|
||||
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
|
||||
{ key: "secureFileStorage", value: "Secure file storage" },
|
||||
{ key: "emergencyAccess", value: "Emergency access" },
|
||||
{ key: "breachMonitoring", value: "Breach monitoring" },
|
||||
{ key: "andMoreFeatures", value: "And more!" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Billing/Premium Upgrade Dialog",
|
||||
component: PremiumUpgradeDialogComponent,
|
||||
description: "A dialog for upgrading to Premium subscription",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [DialogModule, ButtonModule, TypographyModule],
|
||||
providers: [
|
||||
{
|
||||
provide: DialogRef,
|
||||
useValue: {
|
||||
close: () => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useValue: {
|
||||
getPersonalSubscriptionPricingTiers$: () => of([mockPremiumTier]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToastService,
|
||||
useValue: {
|
||||
showToast: (options: ToastOptions) => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
useValue: {
|
||||
cloudWebVaultUrl$: of("https://vault.bitwarden.com"),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: {
|
||||
launchUri: (uri: string) => {},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useValue: {
|
||||
t: (key: string) => {
|
||||
switch (key) {
|
||||
case "upgradeNow":
|
||||
return "Upgrade Now";
|
||||
case "month":
|
||||
return "month";
|
||||
case "upgradeToPremium":
|
||||
return "Upgrade To Premium";
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: LogService,
|
||||
useValue: {
|
||||
error: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/nuFrzHsgEoEk2Sm8fWOGuS/Premium---business-upgrade-flows?node-id=931-17785&t=xOhvwjYLpjoMPgND-1",
|
||||
},
|
||||
},
|
||||
} as Meta<PremiumUpgradeDialogComponent>;
|
||||
|
||||
type Story = StoryObj<PremiumUpgradeDialogComponent>;
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { catchError, EMPTY, firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadence,
|
||||
SubscriptionCadenceIds,
|
||||
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import {
|
||||
ButtonModule,
|
||||
ButtonType,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
type CardDetails = {
|
||||
title: string;
|
||||
tagline: string;
|
||||
price: { amount: number; cadence: SubscriptionCadence };
|
||||
button: { text: string; type: ButtonType; icon?: { type: string; position: "before" | "after" } };
|
||||
features: string[];
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "billing-premium-upgrade-dialog",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
TypographyModule,
|
||||
CdkTrapFocus,
|
||||
JslibModule,
|
||||
],
|
||||
templateUrl: "./premium-upgrade-dialog.component.html",
|
||||
})
|
||||
export class PremiumUpgradeDialogComponent {
|
||||
protected cardDetails$: Observable<CardDetails | null> = this.subscriptionPricingService
|
||||
.getPersonalSubscriptionPricingTiers$()
|
||||
.pipe(
|
||||
map((tiers) => tiers.find((tier) => tier.id === PersonalSubscriptionPricingTierIds.Premium)),
|
||||
map((tier) => this.mapPremiumTierToCardDetails(tier!)),
|
||||
catchError((error: unknown) => {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("error"),
|
||||
message: this.i18nService.t("unexpectedError"),
|
||||
});
|
||||
this.logService.error("Error fetching and mapping pricing tiers", error);
|
||||
this.dialogRef.close();
|
||||
return EMPTY;
|
||||
}),
|
||||
);
|
||||
|
||||
constructor(
|
||||
private dialogRef: DialogRef,
|
||||
private subscriptionPricingService: SubscriptionPricingServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private toastService: ToastService,
|
||||
private environmentService: EnvironmentService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
protected async upgrade(): Promise<void> {
|
||||
const environment = await firstValueFrom(this.environmentService.environment$);
|
||||
const vaultUrl =
|
||||
environment.getWebVaultUrl() +
|
||||
"/#/settings/subscription/premium?callToAction=upgradeToPremium";
|
||||
this.platformUtilsService.launchUri(vaultUrl);
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
protected close(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private mapPremiumTierToCardDetails(tier: PersonalSubscriptionPricingTier): CardDetails {
|
||||
return {
|
||||
title: tier.name,
|
||||
tagline: tier.description,
|
||||
price: {
|
||||
amount: tier.passwordManager.annualPrice / 12,
|
||||
cadence: SubscriptionCadenceIds.Monthly,
|
||||
},
|
||||
button: {
|
||||
text: this.i18nService.t("upgradeNow"),
|
||||
type: "primary",
|
||||
icon: { type: "bwi-external-link", position: "after" },
|
||||
},
|
||||
features: tier.passwordManager.features.map((f) => f.value),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the premium upgrade dialog.
|
||||
*
|
||||
* @param dialogService - The dialog service used to open the component
|
||||
* @returns A dialog reference object
|
||||
*/
|
||||
static open(dialogService: DialogService): DialogRef<PremiumUpgradeDialogComponent> {
|
||||
return dialogService.open(PremiumUpgradeDialogComponent);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { DestroyRef, Directive, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
@@ -16,6 +17,7 @@ export class NotPremiumDirective implements OnInit {
|
||||
private templateRef: TemplateRef<any>,
|
||||
private viewContainer: ViewContainerRef,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
private destroyRef: DestroyRef,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
@@ -27,14 +29,15 @@ export class NotPremiumDirective implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
const premium = await firstValueFrom(
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
|
||||
);
|
||||
|
||||
if (premium) {
|
||||
this.viewContainer.clear();
|
||||
} else {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
this.billingAccountProfileStateService
|
||||
.hasPremiumFromAnySource$(account.id)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((premium) => {
|
||||
if (premium) {
|
||||
this.viewContainer.clear();
|
||||
} else {
|
||||
this.viewContainer.createEmbeddedView(this.templateRef);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { PremiumInterestStateService } from "./premium-interest-state.service.abstraction";
|
||||
|
||||
@Injectable()
|
||||
export class NoopPremiumInterestStateService implements PremiumInterestStateService {
|
||||
async getPremiumInterest(userId: UserId): Promise<boolean | null> {
|
||||
return null;
|
||||
} // no-op
|
||||
async setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void> {} // no-op
|
||||
async clearPremiumInterest(userId: UserId): Promise<void> {} // no-op
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/**
|
||||
* A service that manages state which conveys whether or not a user has expressed interest
|
||||
* in setting up a premium subscription. This applies for users who began the registration
|
||||
* process on https://bitwarden.com/go/start-premium/, which is a marketing page designed
|
||||
* to streamline users who intend to setup a premium subscription after registration.
|
||||
* - Implemented in Web only. No-op for other clients.
|
||||
*/
|
||||
export abstract class PremiumInterestStateService {
|
||||
abstract getPremiumInterest(userId: UserId): Promise<boolean | null>;
|
||||
abstract setPremiumInterest(userId: UserId, premiumInterest: boolean): Promise<void>;
|
||||
abstract clearPremiumInterest(userId: UserId): Promise<void>;
|
||||
}
|
||||
@@ -35,9 +35,6 @@ export const SECURE_STORAGE = new SafeInjectionToken<AbstractStorageService>("SE
|
||||
export const LOGOUT_CALLBACK = new SafeInjectionToken<
|
||||
(logoutReason: LogoutReason, userId?: string) => Promise<void>
|
||||
>("LOGOUT_CALLBACK");
|
||||
export const LOCKED_CALLBACK = new SafeInjectionToken<(userId?: string) => Promise<void>>(
|
||||
"LOCKED_CALLBACK",
|
||||
);
|
||||
export const SUPPORTS_SECURE_STORAGE = new SafeInjectionToken<boolean>("SUPPORTS_SECURE_STORAGE");
|
||||
export const LOCALES_DIRECTORY = new SafeInjectionToken<string>("LOCALES_DIRECTORY");
|
||||
export const SYSTEM_LANGUAGE = new SafeInjectionToken<string>("SYSTEM_LANGUAGE");
|
||||
|
||||
@@ -41,9 +41,11 @@ import {
|
||||
AuthRequestService,
|
||||
AuthRequestServiceAbstraction,
|
||||
DefaultAuthRequestApiService,
|
||||
DefaultLockService,
|
||||
DefaultLoginSuccessHandlerService,
|
||||
DefaultLogoutService,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
LockService,
|
||||
LoginEmailService,
|
||||
LoginEmailServiceAbstraction,
|
||||
LoginStrategyService,
|
||||
@@ -152,6 +154,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { OrganizationMetadataServiceAbstraction } from "@bitwarden/common/billing/abstractions/organization-metadata.service.abstraction";
|
||||
import { OrganizationBillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-billing-api.service.abstraction";
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "@bitwarden/common/billing/abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { SubscriptionPricingServiceAbstraction } from "@bitwarden/common/billing/abstractions/subscription-pricing.service.abstraction";
|
||||
import { AccountBillingApiService } from "@bitwarden/common/billing/services/account/account-billing-api.service";
|
||||
import { DefaultBillingAccountProfileStateService } from "@bitwarden/common/billing/services/account/billing-account-profile-state.service";
|
||||
import { BillingApiService } from "@bitwarden/common/billing/services/billing-api.service";
|
||||
@@ -159,7 +162,9 @@ import { OrganizationBillingApiService } from "@bitwarden/common/billing/service
|
||||
import { DefaultOrganizationMetadataService } from "@bitwarden/common/billing/services/organization/organization-metadata.service";
|
||||
import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/services/organization/organization-sponsorship-api.service";
|
||||
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { DefaultSubscriptionPricingService } from "@bitwarden/common/billing/services/subscription-pricing.service";
|
||||
import { HibpApiService } from "@bitwarden/common/dirt/services/hibp-api.service";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import {
|
||||
DefaultKeyGenerationService,
|
||||
KeyGenerationService,
|
||||
@@ -220,6 +225,7 @@ import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sd
|
||||
import { SdkService } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
|
||||
import { ValidationService as ValidationServiceAbstraction } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { ActionsService } from "@bitwarden/common/platform/actions";
|
||||
import { UnsupportedActionsService } from "@bitwarden/common/platform/actions/unsupported-actions.service";
|
||||
@@ -283,6 +289,7 @@ import {
|
||||
} from "@bitwarden/common/tools/send/services/send.service.abstraction";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherRiskService } from "@bitwarden/common/vault/abstractions/cipher-risk.service";
|
||||
import { CipherService as CipherServiceAbstraction } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { CipherFileUploadService as CipherFileUploadServiceAbstraction } from "@bitwarden/common/vault/abstractions/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
|
||||
@@ -304,6 +311,7 @@ import {
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
import { DefaultCipherRiskService } from "@bitwarden/common/vault/services/default-cipher-risk.service";
|
||||
import { CipherFileUploadService } from "@bitwarden/common/vault/services/file-upload/cipher-file-upload.service";
|
||||
import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder-api.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
|
||||
@@ -382,6 +390,8 @@ import { DefaultSetInitialPasswordService } from "../auth/password-management/se
|
||||
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";
|
||||
import { DeviceTrustToastService } from "../auth/services/device-trust-toast.service.implementation";
|
||||
import { NoopPremiumInterestStateService } from "../billing/services/premium-interest/noop-premium-interest-state.service";
|
||||
import { PremiumInterestStateService } from "../billing/services/premium-interest/premium-interest-state.service.abstraction";
|
||||
import { DefaultEncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service";
|
||||
import { EncryptedMigrationsSchedulerService } from "../key-management/encrypted-migration/encrypted-migrations-scheduler.service.abstraction";
|
||||
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
||||
@@ -403,7 +413,6 @@ import {
|
||||
HTTP_OPERATIONS,
|
||||
INTRAPROCESS_MESSAGING_SUBJECT,
|
||||
LOCALES_DIRECTORY,
|
||||
LOCKED_CALLBACK,
|
||||
LOG_MAC_FAILURES,
|
||||
LOGOUT_CALLBACK,
|
||||
OBSERVABLE_DISK_STORAGE,
|
||||
@@ -459,10 +468,6 @@ const safeProviders: SafeProvider[] = [
|
||||
},
|
||||
deps: [MessagingServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LOCKED_CALLBACK,
|
||||
useValue: null,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LOG_MAC_FAILURES,
|
||||
useValue: true,
|
||||
@@ -624,6 +629,11 @@ const safeProviders: SafeProvider[] = [
|
||||
MessagingServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CipherRiskService,
|
||||
useClass: DefaultCipherRiskService,
|
||||
deps: [SdkService, CipherServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: InternalFolderService,
|
||||
useClass: FolderService,
|
||||
@@ -901,22 +911,12 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultVaultTimeoutService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
CipherServiceAbstraction,
|
||||
FolderServiceAbstraction,
|
||||
CollectionService,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
SearchServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
TokenServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
VaultTimeoutSettingsService,
|
||||
StateEventRunnerService,
|
||||
TaskSchedulerService,
|
||||
LogService,
|
||||
BiometricsService,
|
||||
LOCKED_CALLBACK,
|
||||
LockService,
|
||||
LogoutService,
|
||||
],
|
||||
}),
|
||||
@@ -1478,6 +1478,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
deps: [StateProvider, PlatformUtilsServiceAbstraction, ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: SubscriptionPricingServiceAbstraction,
|
||||
useClass: DefaultSubscriptionPricingService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, I18nServiceAbstraction, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useClass: DefaultOrganizationManagementPreferencesService,
|
||||
@@ -1748,6 +1753,27 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [EncryptedMigrationsSchedulerService],
|
||||
multi: true,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LockService,
|
||||
useClass: DefaultLockService,
|
||||
deps: [
|
||||
AccountService,
|
||||
BiometricsService,
|
||||
VaultTimeoutSettingsService,
|
||||
LogoutService,
|
||||
MessagingServiceAbstraction,
|
||||
SearchServiceAbstraction,
|
||||
FolderServiceAbstraction,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
StateEventRunnerService,
|
||||
CipherServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
SystemService,
|
||||
ProcessReloadServiceAbstraction,
|
||||
LogService,
|
||||
KeyService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: CipherArchiveService,
|
||||
useClass: DefaultCipherArchiveService,
|
||||
@@ -1763,6 +1789,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: DefaultNewDeviceVerificationComponentService,
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PremiumInterestStateService,
|
||||
useClass: NoopPremiumInterestStateService,
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
BehaviorSubject,
|
||||
concatMap,
|
||||
switchMap,
|
||||
tap,
|
||||
} from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
@@ -33,6 +34,7 @@ import { SendTextView } from "@bitwarden/common/tools/send/models/view/send-text
|
||||
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 { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
|
||||
// Value = hours
|
||||
@@ -144,6 +146,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
protected accountService: AccountService,
|
||||
protected toastService: ToastService,
|
||||
protected premiumUpgradePromptService: PremiumUpgradePromptService,
|
||||
) {
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
|
||||
@@ -192,10 +195,15 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
this.formGroup.controls.type.valueChanges.pipe(takeUntil(this.destroy$)).subscribe((val) => {
|
||||
this.type = val;
|
||||
this.typeChanged();
|
||||
});
|
||||
this.formGroup.controls.type.valueChanges
|
||||
.pipe(
|
||||
tap((val) => {
|
||||
this.type = val;
|
||||
}),
|
||||
switchMap(() => this.typeChanged()),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
this.formGroup.controls.selectedDeletionDatePreset.valueChanges
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
@@ -426,11 +434,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
return false;
|
||||
}
|
||||
|
||||
typeChanged() {
|
||||
async typeChanged() {
|
||||
if (this.type === SendType.File && !this.alertShown) {
|
||||
if (!this.canAccessPremium) {
|
||||
this.alertShown = true;
|
||||
this.messagingService.send("premiumRequired");
|
||||
await this.premiumUpgradePromptService.promptForPremium();
|
||||
} else if (!this.emailVerified) {
|
||||
this.alertShown = true;
|
||||
this.messagingService.send("emailVerificationRequired");
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
>
|
||||
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
|
||||
<div>
|
||||
<h2 bitTypography="h4" class="tw-font-semibold !tw-mb-1">{{ title }}</h2>
|
||||
<h2 bitTypography="h4" class="tw-font-medium !tw-mb-1">{{ title }}</h2>
|
||||
<p
|
||||
*ngIf="subtitle"
|
||||
class="tw-text-main tw-mb-0"
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
[(toggled)]="showPassword"
|
||||
></button>
|
||||
<bit-hint *ngIf="flow !== InputPasswordFlow.ChangePasswordDelegation">
|
||||
<span class="tw-font-bold">{{ "important" | i18n }} </span>
|
||||
<span class="tw-font-medium">{{ "important" | i18n }} </span>
|
||||
{{ "masterPassImportant" | i18n }}
|
||||
{{ minPasswordLengthMsg }}.
|
||||
</bit-hint>
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{{ "notificationSentDeviceComplete" | i18n }}
|
||||
</p>
|
||||
|
||||
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<div class="tw-font-medium">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
|
||||
|
||||
<button
|
||||
@@ -50,7 +50,7 @@
|
||||
<ng-container *ngIf="flow === Flow.AdminAuthRequest">
|
||||
<p>{{ "youWillBeNotifiedOnceTheRequestIsApproved" | i18n }}</p>
|
||||
|
||||
<div class="tw-font-semibold">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<div class="tw-font-medium">{{ "fingerprintPhraseHeader" | i18n }}</div>
|
||||
<code class="tw-text-code">{{ fingerprintPhrase }}</code>
|
||||
|
||||
<div class="tw-mt-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!--
|
||||
<!--
|
||||
# Table of Contents
|
||||
|
||||
This file contains a single consolidated template for all visual clients.
|
||||
@@ -21,7 +21,7 @@
|
||||
bitInput
|
||||
appAutofocus
|
||||
(input)="onEmailInput($event)"
|
||||
(keyup.enter)="continuePressed()"
|
||||
(keyup.enter)="ssoRequired ? handleSsoClick() : continuePressed()"
|
||||
/>
|
||||
</bit-form-field>
|
||||
|
||||
|
||||
102
libs/auth/src/angular/login/login.component.spec.ts
Normal file
102
libs/auth/src/angular/login/login.component.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { LoginComponent } from "./login.component";
|
||||
|
||||
describe("LoginComponent continue() integration", () => {
|
||||
function createComponent({ flagEnabled }: { flagEnabled: boolean }) {
|
||||
const activatedRoute: any = { queryParams: { subscribe: () => {} } };
|
||||
const anonLayoutWrapperDataService: any = { setAnonLayoutWrapperData: () => {} };
|
||||
const appIdService: any = {};
|
||||
const broadcasterService: any = { subscribe: () => {}, unsubscribe: () => {} };
|
||||
const destroyRef: any = {};
|
||||
const devicesApiService: any = {};
|
||||
const formBuilder = new FormBuilder();
|
||||
const i18nService: any = { t: () => "" };
|
||||
const loginEmailService: any = {
|
||||
rememberedEmail$: { pipe: () => ({}) },
|
||||
setLoginEmail: async () => {},
|
||||
setRememberedEmailChoice: async () => {},
|
||||
clearLoginEmail: async () => {},
|
||||
};
|
||||
const loginComponentService: any = {
|
||||
showBackButton: () => {},
|
||||
isLoginWithPasskeySupported: () => false,
|
||||
redirectToSsoLogin: async () => {},
|
||||
};
|
||||
const loginStrategyService = mock<LoginStrategyServiceAbstraction>();
|
||||
const messagingService: any = { send: () => {} };
|
||||
const ngZone: any = { isStable: true, onStable: { pipe: () => ({ subscribe: () => {} }) } };
|
||||
const passwordStrengthService: any = {};
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Browser);
|
||||
const policyService: any = { replace: async () => {}, evaluateMasterPassword: () => true };
|
||||
const router: any = { navigate: async () => {}, navigateByUrl: async () => {} };
|
||||
const toastService: any = { showToast: () => {} };
|
||||
const logService: any = { error: () => {} };
|
||||
const validationService: any = { showError: () => {} };
|
||||
const loginSuccessHandlerService: any = { run: async () => {} };
|
||||
const configService = mock<ConfigService>();
|
||||
configService.getFeatureFlag.mockResolvedValue(flagEnabled);
|
||||
const ssoLoginService: any = { ssoRequiredCache$: { pipe: () => ({}) } };
|
||||
const environmentService: any = { environment$: { pipe: () => ({}) } };
|
||||
|
||||
const component = new LoginComponent(
|
||||
activatedRoute,
|
||||
anonLayoutWrapperDataService,
|
||||
appIdService,
|
||||
broadcasterService,
|
||||
destroyRef,
|
||||
devicesApiService,
|
||||
formBuilder,
|
||||
i18nService,
|
||||
loginEmailService,
|
||||
loginComponentService,
|
||||
loginStrategyService,
|
||||
messagingService,
|
||||
ngZone,
|
||||
passwordStrengthService,
|
||||
platformUtilsService,
|
||||
policyService,
|
||||
router,
|
||||
toastService,
|
||||
logService,
|
||||
validationService,
|
||||
loginSuccessHandlerService,
|
||||
configService,
|
||||
ssoLoginService,
|
||||
environmentService,
|
||||
);
|
||||
|
||||
jest.spyOn(component as any, "toggleLoginUiState").mockResolvedValue(undefined);
|
||||
|
||||
return { component, loginStrategyService };
|
||||
}
|
||||
|
||||
it("calls getPasswordPrelogin on continue when flag enabled and email valid", async () => {
|
||||
const { component, loginStrategyService } = createComponent({ flagEnabled: true });
|
||||
(component as any).formGroup.controls.email.setValue("user@example.com");
|
||||
(component as any).formGroup.controls.rememberEmail.setValue(false);
|
||||
(component as any).formGroup.controls.masterPassword.setValue("irrelevant");
|
||||
|
||||
await (component as any).continue();
|
||||
|
||||
expect(loginStrategyService.getPasswordPrelogin).toHaveBeenCalledWith("user@example.com");
|
||||
});
|
||||
|
||||
it("does not call getPasswordPrelogin when flag disabled", async () => {
|
||||
const { component, loginStrategyService } = createComponent({ flagEnabled: false });
|
||||
(component as any).formGroup.controls.email.setValue("user@example.com");
|
||||
(component as any).formGroup.controls.rememberEmail.setValue(false);
|
||||
(component as any).formGroup.controls.masterPassword.setValue("irrelevant");
|
||||
|
||||
await (component as any).continue();
|
||||
|
||||
expect(loginStrategyService.getPasswordPrelogin).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -550,6 +550,8 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
const isEmailValid = this.validateEmail();
|
||||
|
||||
if (isEmailValid) {
|
||||
await this.makePasswordPreloginCall();
|
||||
|
||||
await this.toggleLoginUiState(LoginUiState.MASTER_PASSWORD_ENTRY);
|
||||
}
|
||||
}
|
||||
@@ -652,6 +654,23 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
history.back();
|
||||
}
|
||||
|
||||
private async makePasswordPreloginCall() {
|
||||
// Prefetch prelogin KDF config when enabled
|
||||
try {
|
||||
const flagEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM23801_PrefetchPasswordPrelogin,
|
||||
);
|
||||
if (flagEnabled) {
|
||||
const email = this.formGroup.value.email;
|
||||
if (email) {
|
||||
void this.loginStrategyService.getPasswordPrelogin(email);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to prefetch prelogin data.", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the popstate event to transition back to the email entry state when the back button is clicked.
|
||||
* Also handles the case where the user clicks the forward button.
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-justify-center">
|
||||
<p bitTypography="body1" class="tw-text-center tw-mb-3 tw-text-main" id="follow_the_link_body">
|
||||
{{ "followTheLinkInTheEmailSentTo" | i18n }}
|
||||
<span class="tw-font-bold">{{ email.value }}</span>
|
||||
<span class="tw-font-medium">{{ email.value }}</span>
|
||||
{{ "andContinueCreatingYourAccount" | i18n }}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<div class="tw-size-16 tw-content-center tw-mb-4">
|
||||
<bit-icon [icon]="Icons.UserVerificationBiometricsIcon"></bit-icon>
|
||||
</div>
|
||||
<p class="tw-font-bold tw-mb-1">{{ "verifyWithBiometrics" | i18n }}</p>
|
||||
<p class="tw-font-medium tw-mb-1">{{ "verifyWithBiometrics" | i18n }}</p>
|
||||
<div *ngIf="!biometricsVerificationFailed">
|
||||
<i class="bwi bwi-spinner bwi-spin" title="{{ 'loading' | i18n }}" aria-hidden="true"></i>
|
||||
{{ "awaitingConfirmation" | i18n }}
|
||||
|
||||
@@ -65,7 +65,11 @@ export abstract class LoginStrategyServiceAbstraction {
|
||||
/**
|
||||
* Creates a master key from the provided master password and email.
|
||||
*/
|
||||
abstract makePreloginKey(masterPassword: string, email: string): Promise<MasterKey>;
|
||||
abstract makePasswordPreLoginMasterKey(masterPassword: string, email: string): Promise<MasterKey>;
|
||||
/**
|
||||
* Prefetch and cache the KDF configuration for the given email. No-op if already in-flight or cached.
|
||||
*/
|
||||
abstract getPasswordPrelogin(email: string): Promise<void>;
|
||||
/**
|
||||
* Emits true if the authentication session has expired.
|
||||
*/
|
||||
|
||||
377
libs/auth/src/common/login-strategies/README.md
Normal file
377
libs/auth/src/common/login-strategies/README.md
Normal file
@@ -0,0 +1,377 @@
|
||||
# Overview of Authentication at Bitwarden
|
||||
|
||||
> **Table of Contents**
|
||||
>
|
||||
> - [Authentication Methods](#authentication-methods)
|
||||
> - [The Login Credentials Object](#the-login-credentials-object)
|
||||
> - [The `LoginStrategyService` and our Login Strategies](#the-loginstrategyservice-and-our-login-strategies)
|
||||
> - [The `logIn()` and `startLogIn()` Methods](#the-login-and-startlogin-methods)
|
||||
> - [Handling the `AuthResult`](#handling-the-authresult)
|
||||
> - [Diagram of Authentication Flows](#diagram-of-authentication-flows)
|
||||
|
||||
<br>
|
||||
|
||||
## Authentication Methods
|
||||
|
||||
Bitwarden provides 5 methods for logging in to Bitwarden, as defined in our [`AuthenticationType`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/enums/authentication-type.ts) enum. They are:
|
||||
|
||||
1. [Login with Master Password](https://bitwarden.com/help/bitwarden-security-white-paper/#authentication-and-decryption)
|
||||
2. [Login with Auth Request](https://bitwarden.com/help/log-in-with-device/) (aka Login with Device) — authenticate with a one-time access code
|
||||
3. [Login with Single Sign-On](https://bitwarden.com/help/about-sso/) — authenticate with an SSO Identity Provider (IdP) through SAML or OpenID Connect (OIDC)
|
||||
4. [Login with Passkey](https://bitwarden.com/help/login-with-passkeys/) (WebAuthn)
|
||||
5. [Login with User API Key](https://bitwarden.com/help/personal-api-key/) — authenticate with an API key and secret
|
||||
|
||||
<br>
|
||||
|
||||
**Login Initiation**
|
||||
|
||||
_Angular Clients - Initiating Components_
|
||||
|
||||
A user begins the login process by entering their email on the `/login` screen (`LoginComponent`). From there, the user must click one of the following buttons to initiate a login method by navigating to that method's associated "initiating component":
|
||||
|
||||
- `"Continue"` → user stays on the `LoginComponent` and enters a Master Password
|
||||
- `"Log in with device"` → navigates user to `LoginViaAuthRequestComponent`
|
||||
- `"Use single sign-on"` → navigates user to `SsoComponent`
|
||||
- `"Log in with passkey"` → navigates user to `LoginViaWebAuthnComponent`
|
||||
- Note: Login with Passkey is currently not available on the Desktop client.
|
||||
|
||||
> [!NOTE]
|
||||
>
|
||||
> - Our Angular clients do not support the Login with User API Key method.
|
||||
>
|
||||
> - The Login with Master Password method is also used by the
|
||||
> `RegistrationFinishComponent` and `CompleteTrialInitiationComponent` (the user automatically
|
||||
> gets logged in with their Master Password after registration), as well as the `RecoverTwoFactorComponent`
|
||||
> (the user logs in with their Master Password along with their 2FA recovery code).
|
||||
|
||||
<br>
|
||||
|
||||
_CLI Client - `LoginCommand`_
|
||||
|
||||
The CLI client supports the following login methods via the `LoginCommand`:
|
||||
|
||||
- Login with Master Password
|
||||
- Login with Single Sign-On
|
||||
- Login with User API Key (which can _only_ be initiated from the CLI client)
|
||||
|
||||
<br>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> While each authentication method has its own unique logic, this document discusses the
|
||||
> logic that is _generally_ common to all authentication methods. It provides a high-level
|
||||
> overview of authentication and as such will involve some abstraction and generalization.
|
||||
|
||||
<br>
|
||||
|
||||
## The Login Credentials Object
|
||||
|
||||
When the user presses the "submit" action on an initiating component (or via `LoginCommand` for CLI), we build a **login credentials object**, which contains the core credentials needed to initiate the specific login method.
|
||||
|
||||
For example, when the user clicks "Log in with master password" on the `LoginComponent`, we build a `PasswordLoginCredentials` object, which is defined as:
|
||||
|
||||
```typescript
|
||||
export class PasswordLoginCredentials {
|
||||
readonly type = AuthenticationType.Password;
|
||||
|
||||
constructor(
|
||||
public email: string,
|
||||
public masterPassword: string,
|
||||
public twoFactor?: TokenTwoFactorRequest,
|
||||
public masterPasswordPoliciesFromOrgInvite?: MasterPasswordPolicyOptions,
|
||||
) {}
|
||||
}
|
||||
```
|
||||
|
||||
Notice that the `type` is automatically set to `AuthenticationType.Password`, and the `PasswordLoginCredentials` object simply requires an `email` and `masterPassword` to initiate the login method.
|
||||
|
||||
Each authentication method builds its own type of credentials object. These are defined in [`login-credentials.ts`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/models/domain/login-credentials.ts).
|
||||
|
||||
- `PasswordLoginCredentials`
|
||||
- `AuthRequestLoginCredentials`
|
||||
- `SsoLoginCredentials`
|
||||
- `WebAuthnLoginCredentials`
|
||||
- `UserApiLoginCredentials`
|
||||
|
||||
After building the credentials object, we then call the `logIn()` method on the `LoginStrategyService`, passing in the credentials object as an argument: `LoginStrategyService.logIn(credentials)`
|
||||
|
||||
<br>
|
||||
|
||||
## The `LoginStrategyService` and our Login Strategies
|
||||
|
||||
The [`LoginStrategyService`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/services/login-strategies/login-strategy.service.ts) acts as an orchestrator that determines which of our specific **login strategies** should be initialized and used for the login process.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Our authentication methods are handled by different [login strategies](https://github.com/bitwarden/clients/tree/main/libs/auth/src/common/login-strategies), making use of the [Strategy Design Pattern](https://refactoring.guru/design-patterns/strategy). Those strategies are:
|
||||
>
|
||||
> - `PasswordLoginStrategy`
|
||||
> - `AuthRequestLoginStrategy`
|
||||
> - `SsoLoginStrategy`
|
||||
> - `WebAuthnLoginStrategy`
|
||||
> - `UserApiLoginStrategy`
|
||||
>
|
||||
> Each of those strategies extend the base [`LoginStrategy`](https://github.com/bitwarden/clients/blob/main/libs/auth/src/common/login-strategies/login.strategy.ts), which houses common login logic.
|
||||
|
||||
More specifically, within its `logIn()` method, the `LoginStrategyService` uses the `type` property on the credentials object to determine which specific login strategy to initialize.
|
||||
|
||||
For example, the `PasswordLoginCredentials` object has `type` of `AuthenticationType.Password`. This tells the `LoginStrategyService` to initialize and use the `PasswordLoginStrategy` for the login process.
|
||||
|
||||
Once the `LoginStrategyService` initializes the appropriate strategy, it then calls the `logIn()` method defined on _that_ particular strategy, passing on the credentials object as an argument. For example: `PasswordLoginStrategy.logIn(credentials)`
|
||||
|
||||
<br>
|
||||
|
||||
To summarize everything so far:
|
||||
|
||||
```bash
|
||||
Initiating Component (Submit Action) # ex: LoginComponent.submit()
|
||||
|
|
||||
Build credentials object # ex: PasswordLoginCredentials
|
||||
|
|
||||
Call LoginStrategyService.logIn(credentials)
|
||||
|
|
||||
Initialize specific strategy # ex: PasswordLoginStrategy
|
||||
|
|
||||
Call strategy.logIn(credentials) # ex: PasswordLoginStrategy.logIn(credentials)
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
## The `logIn()` and `startLogIn()` Methods
|
||||
|
||||
Each login strategy has its own unique implementation of the `logIn()` method, but each `logIn()` method performs the following general logic with the help of the credentials object:
|
||||
|
||||
1. Build a `LoginStrategyData` object with a `TokenRequest` property
|
||||
2. Cache the `LoginStrategyData` object
|
||||
3. Call the `startLogIn()` method on the base `LoginStrategy`
|
||||
|
||||
Here are those steps in more detail:
|
||||
|
||||
1. **Build a `LoginStrategyData` object with a `TokenRequest` property**
|
||||
|
||||
Each strategy uses the credentials object to help build a type of `LoginStrategyData` object, which contains the data needed throughout the lifetime of the particular strategy, and must, at minimum, contain a `tokenRequest` property (more on this below).
|
||||
|
||||
```typescript
|
||||
export abstract class LoginStrategyData {
|
||||
tokenRequest:
|
||||
| PasswordTokenRequest
|
||||
| SsoTokenRequest
|
||||
| WebAuthnLoginTokenRequest
|
||||
| UserApiTokenRequest
|
||||
| undefined;
|
||||
|
||||
abstract userEnteredEmail?: string;
|
||||
}
|
||||
```
|
||||
|
||||
Each strategy has its own class that implements the `LoginStrategyData` interface:
|
||||
- `PasswordLoginStrategyData`
|
||||
- `AuthRequestLoginStrategyData`
|
||||
- `SsoLoginStrategyData`
|
||||
- `WebAuthnLoginStrategyData`
|
||||
- `UserApiLoginStrategyData`
|
||||
|
||||
So in our ongoing example that uses the "Login with Master Password" method, the call to `PasswordLoginStrategy.logIn(PasswordLoginCredentials)` would build a `PasswordLoginStrategyData` object that contains the data needed throughout the lifetime of the `PasswordLoginStrategy`.
|
||||
|
||||
That `PasswordLoginStrategyData` object is defined as:
|
||||
|
||||
```typescript
|
||||
export class PasswordLoginStrategyData implements LoginStrategyData {
|
||||
tokenRequest: PasswordTokenRequest;
|
||||
|
||||
userEnteredEmail: string;
|
||||
localMasterKeyHash: string;
|
||||
masterKey: MasterKey;
|
||||
forcePasswordResetReason: ForceSetPasswordReason = ForceSetPasswordReason.None;
|
||||
}
|
||||
```
|
||||
|
||||
Each of the `LoginStrategyData` types have varying properties, but one property common to all is the `tokenRequest` property.
|
||||
|
||||
The `tokenRequest` property holds some type of [`TokenRequest`](https://github.com/bitwarden/clients/tree/main/libs/common/src/auth/models/request/identity-token) object based on the strategy:
|
||||
- `PasswordTokenRequest` — used by both `PasswordLoginStrategy` and `AuthRequestLoginStrategy`
|
||||
- `SsoTokenRequest`
|
||||
- `WebAuthnLoginTokenRequest`
|
||||
- `UserApiTokenRequest`
|
||||
|
||||
This `TokenRequest` object is _also_ built within the `logIn()` method and gets added to the `LoginStrategyData` object as the `tokenRequest` property.
|
||||
|
||||
<br />
|
||||
|
||||
2. **Cache the `LoginStrategyData` object**
|
||||
|
||||
Because a login attempt could "fail" due to a need for Two Factor Authentication (2FA) or New Device Verification (NDV), we need to preserve the `LoginStrategyData` so that we can re-use it later when the user provides their 2FA or NDV token. This way, the user does not need to completely re-enter all of their credentials.
|
||||
|
||||
The way we cache this `LoginStrategyData` is simply by saving it to a property called `cache` on the strategy. There will be more details on how this cache is used later on.
|
||||
|
||||
<br />
|
||||
|
||||
3. **Call the `startLogIn()` method on the base `LoginStrategy`**
|
||||
|
||||
Next, we call the `startLogIn()` method, which exists on the base `LoginStrategy` and is therefore common to all login strategies. The `startLogIn()` method does the following:
|
||||
1. **Makes a `POST` request to the `/connect/token` endpoint on our Identity Server**
|
||||
- `REQUEST`
|
||||
|
||||
The exact payload for this request is determined by the `TokenRequest` object. More specifically, the base `TokenRequest` class contains a `toIdentityToken()` method which gets overridden/extended by the sub-classes (`PasswordTokenRequest.toIdentityToken()`, etc.). This `toIdentityToken()` method produces the exact payload that gets sent to our `/connect/token` endpoint.
|
||||
|
||||
The payload includes OAuth2 parameters, such as `scope`, `client_id`, and `grant_type`, as well as any other credentials that the server needs to complete validation for the specific authentication method.
|
||||
|
||||
- `RESPONSE`
|
||||
|
||||
The Identity Server validates the request and then generates some type of `IdentityResponse`, which can be one of three types:
|
||||
- [`IdentityTokenResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-token.response.ts)
|
||||
- Meaning: the user has been authenticated
|
||||
- Response Contains:
|
||||
- Authentication information, such as:
|
||||
- An access token (which is a JWT with claims about the user)
|
||||
- A refresh token
|
||||
- Decryption information, such as:
|
||||
- The user's master-key-encrypted user key (if the user has a master password), along with their KDF settings
|
||||
- The user's user-key-encrypted private key
|
||||
- A `userDecryptionOptions` object that contains information about which decryption options the user has available to them
|
||||
- A flag that indicates if the user is required to set or change their master password
|
||||
- Any master password policies the user is required to adhere to
|
||||
|
||||
- [`IdentityTwoFactorResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-two-factor.response.ts)
|
||||
- Meaning: the user needs to complete Two Factor Authentication
|
||||
- Response Contains:
|
||||
- A list of which 2FA providers the user has configured
|
||||
- Any master password policies the user is required to adhere to
|
||||
|
||||
- [`IdentityDeviceVerificationResponse`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/response/identity-device-verification.response.ts)
|
||||
- Meaning: the user needs to verify their new device via [new device verification](https://bitwarden.com/help/new-device-verification/)
|
||||
- Response Contains: a simple boolean property that states whether or not the device has been verified
|
||||
|
||||
2. **Calls one of the `process[IdentityType]Response()` methods**
|
||||
|
||||
Each of these methods builds and returns an [`AuthResult`](https://github.com/bitwarden/clients/blob/main/libs/common/src/auth/models/domain/auth-result.ts) object, which gets used later to determine how to direct the user after an authentication attempt.
|
||||
|
||||
The specific method that gets called depends on the type of the `IdentityResponse`:
|
||||
- If `IdentityTokenResponse` → call `processTokenResponse()`
|
||||
- Instantiates a new `AuthResult` object
|
||||
- Calls `saveAccountInformation()` to initialize the account with information from the `IdentityTokenResponse`
|
||||
- Decodes the access token (a JWT) to get information about the user (userId, email, etc.)
|
||||
- Sets several things to state:
|
||||
- The account (via `AccountService`)
|
||||
- The user's environment
|
||||
- `userDecryptionOptions`
|
||||
- `masterPasswordUnlockData` (_if_ `userDecryptionOptions` allows for master password unlock):
|
||||
- Salt
|
||||
- KDF config
|
||||
- Master-key-encrypted user key
|
||||
- Access token and refresh token
|
||||
- KDF config
|
||||
- Premium status
|
||||
- If the `IdentityTokenResponse` contains a `twoFactorToken` (because the user previously selected "remember me" for their 2FA method), set that token to state
|
||||
- Sets cryptographic properties to state: master key, user key, private key
|
||||
- Sets a `forceSetPasswordReason` to state (if necessary)
|
||||
- Returns the `AuthResult`
|
||||
|
||||
- If `IdentityTwoFactorResponse` → call `processTwoFactorResponse()`
|
||||
- Instantiates a new `AuthResult` object
|
||||
- Sets `AuthResult.twoFactorProviders` to the list of 2FA providers from the `IdentityTwoFactorResponse`
|
||||
- Sets that same list of of 2FA providers to global state (memory)
|
||||
- Returns the `AuthResult`
|
||||
|
||||
- If `IdentityDeviceVerificationResponse` → call `processDeviceVerificationResponse()`
|
||||
- Instantiates a new `AuthResult` object
|
||||
- Sets `AuthResult.requiresDeviceVerification` to `true`
|
||||
- Returns the `AuthResult`
|
||||
|
||||
<br>
|
||||
|
||||
## Handling the `AuthResult`
|
||||
|
||||
The `AuthResult` object that gets returned from the `process[IdentityType]Response()` method ultimately gets returned up through the chain of callers until it makes its way back to the initiating component (ex: the `LoginComponent` for Login with Master Password).
|
||||
|
||||
The initiating component will then use the information on that `AuthResult` to determine how to direct the user after an authentication attempt.
|
||||
|
||||
Below is a high-level overview of how the `AuthResult` is handled, but note again that there are abstractions in this diagram — it doesn't depict every edge case, and is just meant to give a general picture.
|
||||
|
||||
```bash
|
||||
Initiating Component (Submit Action) < - - -
|
||||
| \
|
||||
LoginStrategyService.logIn() - \
|
||||
| \ # AuthResult bubbles back up
|
||||
strategy.logIn() - \ # through chain of callers
|
||||
| \ # to the initiating component
|
||||
startLogIn() - \
|
||||
| \
|
||||
process[IdentityType]Response() - \
|
||||
| \
|
||||
returns AuthResult - - - - - - - -
|
||||
|
||||
|
|
||||
- - - - - - - - - - # Initiating component then
|
||||
| # uses the AuthResult in
|
||||
handleAuthResult(authResult) # handleAuthResult()
|
||||
|
|
||||
IF AuthResult.requiresTwoFactor
|
||||
| # route user to /2fa to complete 2FA
|
||||
|
|
||||
IF AuthResult.requiresDeviceVerification
|
||||
| # route user to /device-verification to complete NDV
|
||||
|
|
||||
# Otherwise, route user to /vault
|
||||
```
|
||||
|
||||
<br />
|
||||
|
||||
Now for a more detailed breakdown of how the `AuthResult` is handled...
|
||||
|
||||
There are two broad types of scenarios that the user will fall into:
|
||||
|
||||
1. Re-submit scenarios
|
||||
2. Successful Authentication scenarios
|
||||
|
||||
### Re-submit Scenarios
|
||||
|
||||
There are two cases where a user is required to provide additional information before they can be authenticated: Two Factor Authentication (2FA) and New Device Verification (NDV). In these scenarios, we actually need the user to "re-submit" their original request, along with their added 2FA or NDV token. But remember earlier that we cached the `LoginStrategyData`. This makes it so the user does not need to re-enter their original credentials. Instead, the user simply provides their 2FA or NDV token, we add it to their original (cached) `LoginStrategyData`, and then we re-submit the request.
|
||||
|
||||
Here is how these scenarios work:
|
||||
|
||||
**User must complete Two Factor Authentication**
|
||||
|
||||
1. Remember that when the server response is `IdentityTwoFactorResponse`, we set 2FA provider data into state, and also set `requiresTwoFactor` to `true` on the `AuthResult`.
|
||||
2. When `AuthResult.requiresTwoFactor` is `true`, the specific login strategy exports its `LoginStrategyData` to the `LoginStrategyService`, where it gets stored in memory. This means the `LoginStrategyService` has a cache of the original request the user sent.
|
||||
3. We route the user to `/2fa` (`TwoFactorAuthComponent`).
|
||||
4. The user enters their 2FA token.
|
||||
5. On submission, the `LoginStrategyService` calls `logInTwoFactor()` on the particular login strategy. This method then:
|
||||
- Takes the cached `LoginStrategyData` (the user's original request), and appends the 2FA token onto the `TokenRequest`
|
||||
- Calls `startLogIn()` again, this time using the updated `LoginStrategyData` that includes the 2FA token.
|
||||
|
||||
**User must complete New Device Verification**
|
||||
|
||||
Note that we currently only require new device verification on Master Password logins (`PasswordLoginStrategy`) for users who do not have a 2FA method setup.
|
||||
|
||||
1. Remember that when the server response is `IdentityDeviceVerificationResponse`, we set `requiresDeviceVerification` to `true` on the `AuthResult`.
|
||||
2. When `AuthResult.requiresDeviceVerification` is `true`, the specific login strategy exports its `LoginStrategyData` to the `LoginStrategyService`, where it gets stored in memory. This means the `LoginStrategyService` has a cache of the original request the user sent.
|
||||
3. We route the user to `/device-verification`.
|
||||
4. The user enters their NDV token.
|
||||
5. On submission, the `LoginStrategyService` calls `logInNewDeviceVerification()` on the particular login strategy. This method then:
|
||||
- Takes the cached `LoginStrategyData` (the user's original request), and appends the NDV token onto the `TokenRequest`.
|
||||
- Calls `startLogIn()` again, this time using the updated `LoginStrategyData` that includes the NDV token.
|
||||
|
||||
### Successful Authentication Scenarios
|
||||
|
||||
**User must change their password**
|
||||
|
||||
A user can be successfully authenticated but still required to set/change their master password. In this case, the user gets routed to the relevant set/change password component (`SetInitialPassword` or `ChangePassword`).
|
||||
|
||||
**User does not need to complete 2FA, NDV, or set/change their master password**
|
||||
|
||||
In this case, the user proceeds to their `/vault`.
|
||||
|
||||
**Trusted Device Encryption scenario**
|
||||
|
||||
If the user is on an untrusted device, they get routed to `/login-initiated` to select a decryption option. If the user is on a trusted device, they get routed to `/vault` because decryption can be done automatically.
|
||||
|
||||
<br>
|
||||
|
||||
## Diagram of Authentication Flows
|
||||
|
||||
Here is a high-level overview of what all of this looks like in the end.
|
||||
|
||||
<br>
|
||||
|
||||

|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 215 KiB |
@@ -119,7 +119,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
sub: userId,
|
||||
});
|
||||
|
||||
loginStrategyService.makePreloginKey.mockResolvedValue(masterKey);
|
||||
loginStrategyService.makePasswordPreLoginMasterKey.mockResolvedValue(masterKey);
|
||||
|
||||
keyService.hashMasterKey
|
||||
.calledWith(masterPassword, expect.anything(), undefined)
|
||||
|
||||
@@ -81,7 +81,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
const { email, masterPassword, twoFactor } = credentials;
|
||||
|
||||
const data = new PasswordLoginStrategyData();
|
||||
data.masterKey = await this.loginStrategyService.makePreloginKey(masterPassword, email);
|
||||
data.masterKey = await this.loginStrategyService.makePasswordPreLoginMasterKey(
|
||||
masterPassword,
|
||||
email,
|
||||
);
|
||||
data.masterPassword = masterPassword;
|
||||
data.userEnteredEmail = email;
|
||||
|
||||
|
||||
@@ -1,20 +1,55 @@
|
||||
import { combineLatest, firstValueFrom, map } from "rxjs";
|
||||
import { combineLatest, filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { BiometricsService, KeyService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { StateEventRunnerService } from "@bitwarden/state";
|
||||
|
||||
import { LogoutService } from "../../abstractions";
|
||||
|
||||
export abstract class LockService {
|
||||
/**
|
||||
* Locks all accounts.
|
||||
*/
|
||||
abstract lockAll(): Promise<void>;
|
||||
/**
|
||||
* Performs lock for a user.
|
||||
* @param userId The user id to lock
|
||||
*/
|
||||
abstract lock(userId: UserId): Promise<void>;
|
||||
|
||||
abstract runPlatformOnLockActions(): Promise<void>;
|
||||
}
|
||||
|
||||
export class DefaultLockService implements LockService {
|
||||
constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly vaultTimeoutService: VaultTimeoutService,
|
||||
private readonly biometricService: BiometricsService,
|
||||
private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private readonly logoutService: LogoutService,
|
||||
private readonly messagingService: MessagingService,
|
||||
private readonly searchService: SearchService,
|
||||
private readonly folderService: FolderService,
|
||||
private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private readonly stateEventRunnerService: StateEventRunnerService,
|
||||
private readonly cipherService: CipherService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly systemService: SystemService,
|
||||
private readonly processReloadService: ProcessReloadServiceAbstraction,
|
||||
private readonly logService: LogService,
|
||||
private readonly keyService: KeyService,
|
||||
) {}
|
||||
|
||||
async lockAll() {
|
||||
@@ -36,14 +71,88 @@ export class DefaultLockService implements LockService {
|
||||
);
|
||||
|
||||
for (const otherAccount of accounts.otherAccounts) {
|
||||
await this.vaultTimeoutService.lock(otherAccount);
|
||||
await this.lock(otherAccount);
|
||||
}
|
||||
|
||||
// Do the active account last in case we ever try to route the user on lock
|
||||
// that way this whole operation will be complete before that routing
|
||||
// could take place.
|
||||
if (accounts.activeAccount != null) {
|
||||
await this.vaultTimeoutService.lock(accounts.activeAccount);
|
||||
await this.lock(accounts.activeAccount);
|
||||
}
|
||||
}
|
||||
|
||||
async lock(userId: UserId): Promise<void> {
|
||||
assertNonNullish(userId, "userId", "LockService");
|
||||
|
||||
this.logService.info(`[LockService] Locking user ${userId}`);
|
||||
|
||||
// If user already logged out, then skip locking
|
||||
if (
|
||||
(await firstValueFrom(this.authService.authStatusFor$(userId))) ===
|
||||
AuthenticationStatus.LoggedOut
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If user cannot lock, then logout instead
|
||||
if (!(await this.vaultTimeoutSettingsService.canLock(userId))) {
|
||||
// Logout should perform the same steps
|
||||
await this.logoutService.logout(userId, "vaultTimeout");
|
||||
this.logService.info(`[LockService] User ${userId} cannot lock, logging out instead.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.wipeDecryptedState(userId);
|
||||
await this.waitForLockedStatus(userId);
|
||||
await this.systemService.clearPendingClipboard();
|
||||
await this.runPlatformOnLockActions();
|
||||
|
||||
this.logService.info(`[LockService] Locked user ${userId}`);
|
||||
|
||||
// Subscribers navigate the client to the lock screen based on this lock message.
|
||||
// We need to disable auto-prompting as we are just entering a locked state now.
|
||||
await this.biometricService.setShouldAutopromptNow(false);
|
||||
this.messagingService.send("locked", { userId });
|
||||
|
||||
// Wipe the current process to clear active secrets in memory.
|
||||
await this.processReloadService.startProcessReload();
|
||||
}
|
||||
|
||||
private async wipeDecryptedState(userId: UserId) {
|
||||
// Manually clear state
|
||||
await this.searchService.clearIndex(userId);
|
||||
//! DO NOT REMOVE folderService.clearDecryptedFolderState ! For more information see PM-25660
|
||||
await this.folderService.clearDecryptedFolderState(userId);
|
||||
await this.masterPasswordService.clearMasterKey(userId);
|
||||
await this.cipherService.clearCache(userId);
|
||||
// Clear CLI unlock state
|
||||
await this.keyService.clearStoredUserKey(userId);
|
||||
|
||||
// This will clear ephemeral state such as the user's user key based on the key definition's clear-on
|
||||
await this.stateEventRunnerService.handleEvent("lock", userId);
|
||||
}
|
||||
|
||||
private async waitForLockedStatus(userId: UserId): Promise<void> {
|
||||
// HACK: Start listening for the transition of the locking user from something to the locked state.
|
||||
// This is very much a hack to ensure that the authentication status to retrievable right after
|
||||
// it does its work. Particularly and `"locked"` message. Instead the message should be deprecated
|
||||
// and people should subscribe and react to `authStatusFor$` themselves.
|
||||
await firstValueFrom(
|
||||
this.authService.authStatusFor$(userId).pipe(
|
||||
filter((authStatus) => authStatus === AuthenticationStatus.Locked),
|
||||
timeout({
|
||||
first: 5_000,
|
||||
with: () => {
|
||||
throw new Error("The lock process did not complete in a reasonable amount of time.");
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async runPlatformOnLockActions(): Promise<void> {
|
||||
// No platform specific actions to run for this platform.
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
||||
import { BiometricsService, KeyService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { StateEventRunnerService } from "@bitwarden/state";
|
||||
|
||||
import { LogoutService } from "../../abstractions";
|
||||
|
||||
import { DefaultLockService } from "./lock.service";
|
||||
|
||||
@@ -12,10 +27,57 @@ describe("DefaultLockService", () => {
|
||||
const mockUser3 = "user3" as UserId;
|
||||
|
||||
const accountService = mockAccountServiceWith(mockUser1);
|
||||
const vaultTimeoutService = mock<VaultTimeoutService>();
|
||||
const biometricsService = mock<BiometricsService>();
|
||||
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
const logoutService = mock<LogoutService>();
|
||||
const messagingService = mock<MessagingService>();
|
||||
const searchService = mock<SearchService>();
|
||||
const folderService = mock<FolderService>();
|
||||
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
const stateEventRunnerService = mock<StateEventRunnerService>();
|
||||
const cipherService = mock<CipherService>();
|
||||
const authService = mock<AuthService>();
|
||||
const systemService = mock<SystemService>();
|
||||
const processReloadService = mock<ProcessReloadServiceAbstraction>();
|
||||
const logService = mock<LogService>();
|
||||
const keyService = mock<KeyService>();
|
||||
const sut = new DefaultLockService(
|
||||
accountService,
|
||||
biometricsService,
|
||||
vaultTimeoutSettingsService,
|
||||
logoutService,
|
||||
messagingService,
|
||||
searchService,
|
||||
folderService,
|
||||
masterPasswordService,
|
||||
stateEventRunnerService,
|
||||
cipherService,
|
||||
authService,
|
||||
systemService,
|
||||
processReloadService,
|
||||
logService,
|
||||
keyService,
|
||||
);
|
||||
|
||||
const sut = new DefaultLockService(accountService, vaultTimeoutService);
|
||||
describe("lockAll", () => {
|
||||
const sut = new DefaultLockService(
|
||||
accountService,
|
||||
biometricsService,
|
||||
vaultTimeoutSettingsService,
|
||||
logoutService,
|
||||
messagingService,
|
||||
searchService,
|
||||
folderService,
|
||||
masterPasswordService,
|
||||
stateEventRunnerService,
|
||||
cipherService,
|
||||
authService,
|
||||
systemService,
|
||||
processReloadService,
|
||||
logService,
|
||||
keyService,
|
||||
);
|
||||
|
||||
it("locks the active account last", async () => {
|
||||
await accountService.addAccount(mockUser2, {
|
||||
name: "name2",
|
||||
@@ -25,19 +87,49 @@ describe("DefaultLockService", () => {
|
||||
|
||||
await accountService.addAccount(mockUser3, {
|
||||
name: "name3",
|
||||
email: "email3@example.com",
|
||||
email: "name3@example.com",
|
||||
emailVerified: false,
|
||||
});
|
||||
|
||||
const lockSpy = jest.spyOn(sut, "lock").mockResolvedValue(undefined);
|
||||
|
||||
await sut.lockAll();
|
||||
|
||||
expect(vaultTimeoutService.lock).toHaveBeenCalledTimes(3);
|
||||
// Non-Active users should be called first
|
||||
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(1, mockUser2);
|
||||
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(2, mockUser3);
|
||||
expect(lockSpy).toHaveBeenNthCalledWith(1, mockUser2);
|
||||
expect(lockSpy).toHaveBeenNthCalledWith(2, mockUser3);
|
||||
|
||||
// Active user should be called last
|
||||
expect(vaultTimeoutService.lock).toHaveBeenNthCalledWith(3, mockUser1);
|
||||
expect(lockSpy).toHaveBeenNthCalledWith(3, mockUser1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lock", () => {
|
||||
const userId = mockUser1;
|
||||
|
||||
it("returns early if user is already logged out", async () => {
|
||||
authService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.LoggedOut));
|
||||
await sut.lock(userId);
|
||||
// Should return early, not call logoutService.logout
|
||||
expect(logoutService.logout).not.toHaveBeenCalled();
|
||||
expect(stateEventRunnerService.handleEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs out if user cannot lock", async () => {
|
||||
authService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Unlocked));
|
||||
vaultTimeoutSettingsService.canLock.mockResolvedValue(false);
|
||||
await sut.lock(userId);
|
||||
expect(logoutService.logout).toHaveBeenCalledWith(userId, "vaultTimeout");
|
||||
expect(stateEventRunnerService.handleEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("locks user", async () => {
|
||||
authService.authStatusFor$.mockReturnValue(of(AuthenticationStatus.Locked));
|
||||
logoutService.logout.mockClear();
|
||||
vaultTimeoutSettingsService.canLock.mockResolvedValue(true);
|
||||
await sut.lock(userId);
|
||||
expect(logoutService.logout).not.toHaveBeenCalled();
|
||||
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", userId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,13 @@ import {
|
||||
} from "@bitwarden/common/spec";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KdfConfigService, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import {
|
||||
Argon2KdfConfig,
|
||||
KdfConfigService,
|
||||
KdfType,
|
||||
KeyService,
|
||||
PBKDF2KdfConfig,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import {
|
||||
AuthRequestServiceAbstraction,
|
||||
@@ -158,6 +164,321 @@ describe("LoginStrategyService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("PM23801_PrefetchPasswordPrelogin", () => {
|
||||
describe("Flag On", () => {
|
||||
it("prefetches and caches KDF, then makePrePasswordLoginMasterKey uses cached", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
apiService.postPrelogin.mockResolvedValue(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.getPasswordPrelogin(email);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"pw",
|
||||
email.trim().toLowerCase(),
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
);
|
||||
});
|
||||
|
||||
it("awaits in-flight prelogin promise in makePrePasswordLoginMasterKey", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
let resolveFn: (v: any) => void;
|
||||
const deferred = new Promise<PreloginResponse>((resolve) => (resolveFn = resolve));
|
||||
apiService.postPrelogin.mockReturnValue(deferred as any);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
void sut.getPasswordPrelogin(email);
|
||||
|
||||
const makeKeyPromise = sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
// Resolve after makePrePasswordLoginMasterKey has started awaiting
|
||||
resolveFn!(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
|
||||
await makeKeyPromise;
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"pw",
|
||||
email,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
);
|
||||
});
|
||||
|
||||
it("no cache and no in-flight request", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
apiService.postPrelogin.mockResolvedValue(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(1);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"pw",
|
||||
email,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to API call when prefetched email differs", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const emailPrefetched = "a@a.com";
|
||||
const emailUsed = "b@b.com";
|
||||
|
||||
// Prefetch for A
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
await sut.getPasswordPrelogin(emailPrefetched);
|
||||
|
||||
// makePrePasswordLoginMasterKey for B (forces new API call) -> Argon2
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", emailUsed);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[calls.length - 1][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||
});
|
||||
|
||||
it("ignores stale prelogin resolution for older email (versioning)", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const emailA = "a@a.com";
|
||||
const emailB = "b@b.com";
|
||||
|
||||
let resolveA!: (v: any) => void;
|
||||
let resolveB!: (v: any) => void;
|
||||
const deferredA = new Promise<PreloginResponse>((res) => (resolveA = res));
|
||||
const deferredB = new Promise<PreloginResponse>((res) => (resolveB = res));
|
||||
|
||||
// First call returns A, second returns B
|
||||
apiService.postPrelogin.mockImplementationOnce(() => deferredA as any);
|
||||
apiService.postPrelogin.mockImplementationOnce(() => deferredB as any);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
// Start A prefetch, then B prefetch (B supersedes A)
|
||||
void sut.getPasswordPrelogin(emailA);
|
||||
void sut.getPasswordPrelogin(emailB);
|
||||
|
||||
// Resolve A (stale) to PBKDF2, then B to Argon2
|
||||
resolveA(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
resolveB(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pwB", emailB);
|
||||
|
||||
// Ensure B's Argon2 config is used and stale A doesn't overwrite
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
const argB = calls.find((c) => c[0] === "pwB")[2];
|
||||
expect(argB).toBeInstanceOf(Argon2KdfConfig);
|
||||
});
|
||||
|
||||
it("handles concurrent getPasswordPrelogin calls for same email; uses latest result", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
let resolve1!: (v: any) => void;
|
||||
let resolve2!: (v: any) => void;
|
||||
const deferred1 = new Promise<PreloginResponse>((res) => (resolve1 = res));
|
||||
const deferred2 = new Promise<PreloginResponse>((res) => (resolve2 = res));
|
||||
|
||||
apiService.postPrelogin.mockImplementationOnce(() => deferred1 as any);
|
||||
apiService.postPrelogin.mockImplementationOnce(() => deferred2 as any);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
void sut.getPasswordPrelogin(email);
|
||||
void sut.getPasswordPrelogin(email);
|
||||
|
||||
// First resolves to PBKDF2, second resolves to Argon2 (latest wins)
|
||||
resolve1(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
resolve2(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||
});
|
||||
|
||||
it("does not throw when prefetch network error occurs; fallback works in makePrePasswordLoginMasterKey", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
|
||||
// Prefetch throws non-404 error
|
||||
const err: any = new Error("network");
|
||||
err.statusCode = 500;
|
||||
apiService.postPrelogin.mockRejectedValueOnce(err);
|
||||
|
||||
await expect(sut.getPasswordPrelogin(email)).resolves.toBeUndefined();
|
||||
|
||||
// makePrePasswordLoginMasterKey falls back to a new API call which succeeds
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||
});
|
||||
|
||||
it("treats 404 as null prefetch and falls back in makePrePasswordLoginMasterKey", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
|
||||
const notFound: any = new Error("not found");
|
||||
notFound.statusCode = 404;
|
||||
apiService.postPrelogin.mockRejectedValueOnce(notFound);
|
||||
|
||||
await sut.getPasswordPrelogin(email);
|
||||
|
||||
// Fallback call on makePrePasswordLoginMasterKey
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.Argon2id,
|
||||
KdfIterations: 2,
|
||||
KdfMemory: 16,
|
||||
KdfParallelism: 1,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(Argon2KdfConfig);
|
||||
});
|
||||
|
||||
it("awaits rejected current prelogin promise and then falls back in makePrePasswordLoginMasterKey", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
const email = "a@a.com";
|
||||
const err: any = new Error("network");
|
||||
err.statusCode = 500;
|
||||
let rejectFn!: (e: any) => void;
|
||||
const deferred = new Promise<PreloginResponse>((_res, rej) => (rejectFn = rej));
|
||||
apiService.postPrelogin.mockReturnValueOnce(deferred as any);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
void sut.getPasswordPrelogin(email);
|
||||
const makeKey = sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
rejectFn(err);
|
||||
|
||||
// Fallback call succeeds
|
||||
apiService.postPrelogin.mockResolvedValueOnce(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
|
||||
await makeKey;
|
||||
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
const calls = keyService.makeMasterKey.mock.calls as any[];
|
||||
expect(calls[0][2]).toBeInstanceOf(PBKDF2KdfConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Flag Off", () => {
|
||||
// remove when pm-23801 feature flag comes out
|
||||
it("uses legacy API path", async () => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const email = "a@a.com";
|
||||
// prefetch shouldn't affect behavior when flag off
|
||||
apiService.postPrelogin.mockResolvedValue(
|
||||
new PreloginResponse({
|
||||
Kdf: KdfType.PBKDF2_SHA256,
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN,
|
||||
}),
|
||||
);
|
||||
keyService.makeMasterKey.mockResolvedValue({} as any);
|
||||
|
||||
await sut.getPasswordPrelogin(email);
|
||||
await sut.makePasswordPreLoginMasterKey("pw", email);
|
||||
|
||||
// Called twice: once for prefetch, once for legacy path in makePrePasswordLoginMasterKey
|
||||
expect(apiService.postPrelogin).toHaveBeenCalledTimes(2);
|
||||
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
|
||||
"pw",
|
||||
email,
|
||||
expect.any(PBKDF2KdfConfig),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should return an AuthResult on successful login", async () => {
|
||||
const credentials = new PasswordLoginCredentials("EMAIL", "MASTER_PASSWORD");
|
||||
apiService.postIdentityToken.mockResolvedValue(
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
@@ -92,6 +93,32 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
private authRequestPushNotificationState: GlobalState<string | null>;
|
||||
private authenticationTimeoutSubject = new BehaviorSubject<boolean>(false);
|
||||
|
||||
// Prefetched password prelogin
|
||||
//
|
||||
// About versioning:
|
||||
// Users can quickly change emails (e.g., continue with user1, go back, continue with user2)
|
||||
// which triggers overlapping async prelogin requests. We use a monotonically increasing
|
||||
// "version" to associate each prelogin attempt with the state at the time it was started.
|
||||
// Only if BOTH the email and the version still match when the promise resolves do we commit
|
||||
// the resulting KDF config or clear the in-flight promise. This prevents stale results from
|
||||
// user1 overwriting user2's state in race conditions.
|
||||
private passwordPrelogin: {
|
||||
email: string | null;
|
||||
kdfConfig: KdfConfig | null;
|
||||
promise: Promise<KdfConfig | null> | null;
|
||||
/**
|
||||
* Version guard for prelogin attempts.
|
||||
* Incremented at the start of getPasswordPrelogin for each new submission.
|
||||
* Used to ignore stale async resolutions when email changes mid-flight.
|
||||
*/
|
||||
version: number;
|
||||
} = {
|
||||
email: null,
|
||||
kdfConfig: null,
|
||||
promise: null,
|
||||
version: 0,
|
||||
};
|
||||
|
||||
authenticationSessionTimeout$: Observable<boolean> =
|
||||
this.authenticationTimeoutSubject.asObservable();
|
||||
|
||||
@@ -308,33 +335,106 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async makePreloginKey(masterPassword: string, email: string): Promise<MasterKey> {
|
||||
async makePasswordPreLoginMasterKey(masterPassword: string, email: string): Promise<MasterKey> {
|
||||
email = email.trim().toLowerCase();
|
||||
let kdfConfig: KdfConfig | undefined;
|
||||
|
||||
if (await this.configService.getFeatureFlag(FeatureFlag.PM23801_PrefetchPasswordPrelogin)) {
|
||||
let kdfConfig: KdfConfig | null = null;
|
||||
if (this.passwordPrelogin.email === email) {
|
||||
if (this.passwordPrelogin.kdfConfig) {
|
||||
kdfConfig = this.passwordPrelogin.kdfConfig;
|
||||
} else if (this.passwordPrelogin.promise != null) {
|
||||
try {
|
||||
await this.passwordPrelogin.promise;
|
||||
} catch (error) {
|
||||
this.logService.error(
|
||||
"Failed to prefetch prelogin data, falling back to fetching now.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
kdfConfig = this.passwordPrelogin.kdfConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (!kdfConfig) {
|
||||
try {
|
||||
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
|
||||
kdfConfig = this.buildKdfConfigFromPrelogin(preloginResponse);
|
||||
} catch (e: any) {
|
||||
if (e == null || e.statusCode !== 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!kdfConfig) {
|
||||
throw new Error("KDF config is required");
|
||||
}
|
||||
kdfConfig.validateKdfConfigForPrelogin();
|
||||
return await this.keyService.makeMasterKey(masterPassword, email, kdfConfig);
|
||||
}
|
||||
|
||||
// Legacy behavior when flag is disabled
|
||||
let legacyKdfConfig: KdfConfig | undefined;
|
||||
try {
|
||||
const preloginResponse = await this.apiService.postPrelogin(new PreloginRequest(email));
|
||||
if (preloginResponse != null) {
|
||||
kdfConfig =
|
||||
preloginResponse.kdf === KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(preloginResponse.kdfIterations)
|
||||
: new Argon2KdfConfig(
|
||||
preloginResponse.kdfIterations,
|
||||
preloginResponse.kdfMemory,
|
||||
preloginResponse.kdfParallelism,
|
||||
);
|
||||
}
|
||||
legacyKdfConfig = this.buildKdfConfigFromPrelogin(preloginResponse) ?? undefined;
|
||||
} catch (e: any) {
|
||||
if (e == null || e.statusCode !== 404) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if (!kdfConfig) {
|
||||
if (!legacyKdfConfig) {
|
||||
throw new Error("KDF config is required");
|
||||
}
|
||||
kdfConfig.validateKdfConfigForPrelogin();
|
||||
legacyKdfConfig.validateKdfConfigForPrelogin();
|
||||
return await this.keyService.makeMasterKey(masterPassword, email, legacyKdfConfig);
|
||||
}
|
||||
|
||||
return await this.keyService.makeMasterKey(masterPassword, email, kdfConfig);
|
||||
async getPasswordPrelogin(email: string): Promise<void> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const version = ++this.passwordPrelogin.version;
|
||||
|
||||
this.passwordPrelogin.email = normalizedEmail;
|
||||
this.passwordPrelogin.kdfConfig = null;
|
||||
const promise: Promise<KdfConfig | null> = (async () => {
|
||||
try {
|
||||
const preloginResponse = await this.apiService.postPrelogin(
|
||||
new PreloginRequest(normalizedEmail),
|
||||
);
|
||||
return this.buildKdfConfigFromPrelogin(preloginResponse);
|
||||
} catch (e: any) {
|
||||
if (e == null || e.statusCode !== 404) {
|
||||
throw e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
this.passwordPrelogin.promise = promise;
|
||||
promise
|
||||
.then((cfg) => {
|
||||
// Only apply if still for the same email and same version
|
||||
if (
|
||||
this.passwordPrelogin.email === normalizedEmail &&
|
||||
this.passwordPrelogin.version === version &&
|
||||
cfg
|
||||
) {
|
||||
this.passwordPrelogin.kdfConfig = cfg;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// swallow; best-effort prefetch
|
||||
})
|
||||
.finally(() => {
|
||||
if (
|
||||
this.passwordPrelogin.email === normalizedEmail &&
|
||||
this.passwordPrelogin.version === version
|
||||
) {
|
||||
this.passwordPrelogin.promise = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async clearCache(): Promise<void> {
|
||||
@@ -342,6 +442,12 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
await this.loginStrategyCacheState.update((_) => null);
|
||||
this.authenticationTimeoutSubject.next(false);
|
||||
await this.clearSessionTimeout();
|
||||
|
||||
// Increment to invalidate any in-flight requests
|
||||
this.passwordPrelogin.version++;
|
||||
this.passwordPrelogin.email = null;
|
||||
this.passwordPrelogin.kdfConfig = null;
|
||||
this.passwordPrelogin.promise = null;
|
||||
}
|
||||
|
||||
private async startSessionTimeout(): Promise<void> {
|
||||
@@ -449,4 +555,24 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private buildKdfConfigFromPrelogin(
|
||||
preloginResponse: {
|
||||
kdf: KdfType;
|
||||
kdfIterations: number;
|
||||
kdfMemory?: number;
|
||||
kdfParallelism?: number;
|
||||
} | null,
|
||||
): KdfConfig | null {
|
||||
if (preloginResponse == null) {
|
||||
return null;
|
||||
}
|
||||
return preloginResponse.kdf === KdfType.PBKDF2_SHA256
|
||||
? new PBKDF2KdfConfig(preloginResponse.kdfIterations)
|
||||
: new Argon2KdfConfig(
|
||||
preloginResponse.kdfIterations,
|
||||
preloginResponse.kdfMemory,
|
||||
preloginResponse.kdfParallelism,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,4 @@ export abstract class AuditService {
|
||||
* @returns A promise that resolves to an array of BreachAccountResponse objects.
|
||||
*/
|
||||
abstract breachedAccounts: (username: string) => Promise<BreachAccountResponse[]>;
|
||||
/**
|
||||
* Checks if a domain is known for phishing.
|
||||
* @param domain The domain to check.
|
||||
* @returns A promise that resolves to a boolean indicating if the domain is known for phishing.
|
||||
*/
|
||||
abstract getKnownPhishingDomains: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { map, Observable } from "rxjs";
|
||||
import { combineLatest, map, Observable } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { OrganizationData } from "../../models/data/organization.data";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
import { PolicyService } from "../policy/policy.service.abstraction";
|
||||
|
||||
export function canAccessVaultTab(org: Organization): boolean {
|
||||
return org.canViewAllCollections;
|
||||
@@ -51,6 +56,17 @@ export function canAccessOrgAdmin(org: Organization): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function canAccessEmergencyAccess(
|
||||
userId: UserId,
|
||||
configService: ConfigService,
|
||||
policyService: PolicyService,
|
||||
) {
|
||||
return combineLatest([
|
||||
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
|
||||
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
|
||||
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Please use the general `getById` custom rxjs operator instead.
|
||||
*/
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
allowAdminAccessToAllCollectionItems: false,
|
||||
familySponsorshipLastSyncDate: new Date(),
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
useOrganizationDomains: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
isAdminInitiated: false,
|
||||
|
||||
@@ -62,7 +62,7 @@ export class OrganizationData {
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
@@ -130,7 +130,7 @@ export class OrganizationData {
|
||||
this.limitItemDeletion = response.limitItemDeletion;
|
||||
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useRiskInsights = response.useRiskInsights;
|
||||
this.useAccessIntelligence = response.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
this.isAdminInitiated = response.isAdminInitiated;
|
||||
this.ssoEnabled = response.ssoEnabled;
|
||||
|
||||
@@ -79,7 +79,7 @@ describe("Organization", () => {
|
||||
limitItemDeletion: false,
|
||||
allowAdminAccessToAllCollectionItems: true,
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
isAdminInitiated: false,
|
||||
ssoEnabled: false,
|
||||
|
||||
@@ -93,7 +93,7 @@ export class Organization {
|
||||
* matches one of the verified domains of that organization, and the user is a member of it.
|
||||
*/
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
@@ -157,7 +157,7 @@ export class Organization {
|
||||
this.limitItemDeletion = obj.limitItemDeletion;
|
||||
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useRiskInsights = obj.useRiskInsights;
|
||||
this.useAccessIntelligence = obj.useAccessIntelligence;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
this.isAdminInitiated = obj.isAdminInitiated;
|
||||
this.ssoEnabled = obj.ssoEnabled;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { PolicyType } from "../../enums";
|
||||
|
||||
export type PolicyRequest = {
|
||||
type: PolicyType;
|
||||
enabled: boolean;
|
||||
data: any;
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
limitCollectionDeletion: boolean;
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -80,6 +80,7 @@ export class OrganizationResponse extends BaseResponse {
|
||||
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
limitItemDeletion: boolean;
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAccessIntelligence: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
isAdminInitiated: boolean;
|
||||
ssoEnabled: boolean;
|
||||
@@ -129,7 +129,8 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
"AllowAdminAccessToAllCollectionItems",
|
||||
);
|
||||
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
|
||||
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
|
||||
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
|
||||
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
|
||||
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
|
||||
|
||||
@@ -554,6 +554,77 @@ describe("PolicyService", () => {
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
describe("SingleOrg policy exemptions", () => {
|
||||
it("returns true for SingleOrg policy when AutoConfirm is enabled, even for users who can manage policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org6", PolicyType.AutoConfirm, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is not enabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([policyData("policy1", "org6", PolicyType.SingleOrg, true)]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for SingleOrg policy when user can manage policies and AutoConfirm is disabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org6", PolicyType.AutoConfirm, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for SingleOrg policy for regular users when AutoConfirm is not enabled", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([policyData("policy1", "org1", PolicyType.SingleOrg, true)]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for SingleOrg policy when AutoConfirm is enabled in a different organization", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org6", PolicyType.SingleOrg, true),
|
||||
policyData("policy2", "org1", PolicyType.AutoConfirm, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToUser$(PolicyType.SingleOrg, userId),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("combinePoliciesIntoMasterPasswordPolicyOptions", () => {
|
||||
|
||||
@@ -40,18 +40,16 @@ export class DefaultPolicyService implements PolicyService {
|
||||
}
|
||||
|
||||
policiesByType$(policyType: PolicyType, userId: UserId) {
|
||||
const filteredPolicies$ = this.policies$(userId).pipe(
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("No userId provided");
|
||||
}
|
||||
|
||||
const allPolicies$ = this.policies$(userId);
|
||||
const organizations$ = this.organizationService.organizations$(userId);
|
||||
|
||||
return combineLatest([filteredPolicies$, organizations$]).pipe(
|
||||
return combineLatest([allPolicies$, organizations$]).pipe(
|
||||
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,7 +75,7 @@ export class DefaultPolicyService implements PolicyService {
|
||||
policy.enabled &&
|
||||
organization.status >= OrganizationUserStatusType.Accepted &&
|
||||
organization.usePolicies &&
|
||||
!this.isExemptFromPolicy(policy.type, organization)
|
||||
!this.isExemptFromPolicy(policy.type, organization, policies)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -265,7 +263,11 @@ export class DefaultPolicyService implements PolicyService {
|
||||
* Determines whether an orgUser is exempt from a specific policy because of their role
|
||||
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
|
||||
*/
|
||||
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
|
||||
private isExemptFromPolicy(
|
||||
policyType: PolicyType,
|
||||
organization: Organization,
|
||||
allPolicies: Policy[],
|
||||
) {
|
||||
switch (policyType) {
|
||||
case PolicyType.MaximumVaultTimeout:
|
||||
// Max Vault Timeout applies to everyone except owners
|
||||
@@ -283,9 +285,19 @@ export class DefaultPolicyService implements PolicyService {
|
||||
case PolicyType.RemoveUnlockWithPin:
|
||||
// Remove Unlock with PIN policy
|
||||
return false;
|
||||
case PolicyType.AutoConfirm:
|
||||
return false;
|
||||
case PolicyType.OrganizationDataOwnership:
|
||||
// organization data ownership policy applies to everyone except admins and owners
|
||||
return organization.isAdmin;
|
||||
case PolicyType.SingleOrg:
|
||||
// Check if AutoConfirm policy is enabled for this organization
|
||||
return allPolicies.find(
|
||||
(p) =>
|
||||
p.organizationId === organization.id && p.type === PolicyType.AutoConfirm && p.enabled,
|
||||
)
|
||||
? false
|
||||
: organization.canManagePolicies;
|
||||
default:
|
||||
return organization.canManagePolicies;
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ import { MasterPasswordPolicyResponse } from "./master-password-policy.response"
|
||||
import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-decryption-options.response";
|
||||
|
||||
export class IdentityTokenResponse extends BaseResponse {
|
||||
// Authentication Information
|
||||
accessToken: string;
|
||||
expiresIn?: number;
|
||||
refreshToken?: string;
|
||||
tokenType: string;
|
||||
|
||||
// Decryption Information
|
||||
resetMasterPassword: boolean;
|
||||
privateKey: string; // userKeyEncryptedPrivateKey
|
||||
key?: EncString; // masterKeyEncryptedUserKey
|
||||
|
||||
@@ -191,6 +191,140 @@ describe("UserVerificationService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRequest", () => {
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
i18nService.t
|
||||
.calledWith("verificationCodeRequired")
|
||||
.mockReturnValue("Verification code is required");
|
||||
i18nService.t
|
||||
.calledWith("masterPasswordRequired")
|
||||
.mockReturnValue("Master Password is required");
|
||||
});
|
||||
|
||||
describe("OTP verification", () => {
|
||||
it("should build request with OTP secret", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.OTP,
|
||||
secret: "123456",
|
||||
} as any;
|
||||
|
||||
const result = await sut.buildRequest(verification);
|
||||
|
||||
expect(result.otp).toBe("123456");
|
||||
});
|
||||
|
||||
it("should throw if OTP secret is empty", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.OTP,
|
||||
secret: "",
|
||||
} as any;
|
||||
|
||||
await expect(sut.buildRequest(verification)).rejects.toThrow(
|
||||
"Verification code is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if OTP secret is null", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.OTP,
|
||||
secret: null,
|
||||
} as any;
|
||||
|
||||
await expect(sut.buildRequest(verification)).rejects.toThrow(
|
||||
"Verification code is required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Master password verification", () => {
|
||||
beforeEach(() => {
|
||||
kdfConfigService.getKdfConfig.mockResolvedValue("kdfConfig" as unknown as KdfConfig);
|
||||
masterPasswordService.saltForUser$.mockReturnValue(of("salt" as any));
|
||||
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
masterPasswordAuthenticationHash: "hash",
|
||||
} as any);
|
||||
});
|
||||
|
||||
it("should build request with master password secret", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
const result = await sut.buildRequest(verification);
|
||||
|
||||
expect(result.masterPasswordHash).toBe("hash");
|
||||
});
|
||||
|
||||
it("should use default SecretVerificationRequest if no custom class provided", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
const result = await sut.buildRequest(verification);
|
||||
|
||||
expect(result).toHaveProperty("masterPasswordHash");
|
||||
});
|
||||
|
||||
it("should get KDF config for the active user", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
await sut.buildRequest(verification);
|
||||
|
||||
expect(kdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should get salt for the active user", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
await sut.buildRequest(verification);
|
||||
|
||||
expect(masterPasswordService.saltForUser$).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should call makeMasterPasswordAuthenticationData with correct parameters", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "password123",
|
||||
} as any;
|
||||
|
||||
await sut.buildRequest(verification);
|
||||
|
||||
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
||||
"password123",
|
||||
"kdfConfig",
|
||||
"salt",
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw if master password secret is empty", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: "",
|
||||
} as any;
|
||||
|
||||
await expect(sut.buildRequest(verification)).rejects.toThrow("Master Password is required");
|
||||
});
|
||||
|
||||
it("should throw if master password secret is null", async () => {
|
||||
const verification = {
|
||||
type: VerificationType.MasterPassword,
|
||||
secret: null,
|
||||
} as any;
|
||||
|
||||
await expect(sut.buildRequest(verification)).rejects.toThrow("Master Password is required");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyUserByMasterPassword", () => {
|
||||
beforeAll(() => {
|
||||
i18nService.t.calledWith("invalidMasterPassword").mockReturnValue("Invalid master password");
|
||||
@@ -228,7 +362,6 @@ describe("UserVerificationService", () => {
|
||||
expect(result).toEqual({
|
||||
policyOptions: null,
|
||||
masterKey: "masterKey",
|
||||
kdfConfig: "kdfConfig",
|
||||
email: "email",
|
||||
});
|
||||
});
|
||||
@@ -288,7 +421,6 @@ describe("UserVerificationService", () => {
|
||||
expect(result).toEqual({
|
||||
policyOptions: "MasterPasswordPolicyOptions",
|
||||
masterKey: "masterKey",
|
||||
kdfConfig: "kdfConfig",
|
||||
email: "email",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
VerificationWithSecret,
|
||||
verificationHasSecret,
|
||||
} from "../../types/verification";
|
||||
import { getUserId } from "../account.service";
|
||||
|
||||
/**
|
||||
* Used for general-purpose user verification throughout the app.
|
||||
@@ -101,7 +102,6 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
async buildRequest<T extends SecretVerificationRequest>(
|
||||
verification: ServerSideVerification,
|
||||
requestClass?: new () => T,
|
||||
alreadyHashed?: boolean,
|
||||
) {
|
||||
this.validateSecretInput(verification);
|
||||
|
||||
@@ -111,20 +111,17 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
if (verification.type === VerificationType.OTP) {
|
||||
request.otp = verification.secret;
|
||||
} else {
|
||||
const [userId, email] = await firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
|
||||
);
|
||||
let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
|
||||
if (!masterKey && !alreadyHashed) {
|
||||
masterKey = await this.keyService.makeMasterKey(
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const kdf = await this.kdfConfigService.getKdfConfig(userId as UserId);
|
||||
const salt = await firstValueFrom(this.masterPasswordService.saltForUser$(userId as UserId));
|
||||
|
||||
const authenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
verification.secret,
|
||||
email,
|
||||
await this.kdfConfigService.getKdfConfig(userId),
|
||||
kdf,
|
||||
salt,
|
||||
);
|
||||
}
|
||||
request.masterPasswordHash = alreadyHashed
|
||||
? verification.secret
|
||||
: await this.keyService.hashMasterKey(verification.secret, masterKey);
|
||||
request.authenticateWith(authenticationData);
|
||||
}
|
||||
|
||||
return request;
|
||||
@@ -239,7 +236,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
);
|
||||
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
return { policyOptions, masterKey, kdfConfig, email };
|
||||
return { policyOptions, masterKey, email };
|
||||
}
|
||||
|
||||
private async verifyUserByPIN(verification: PinVerification, userId: UserId): Promise<boolean> {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { VerificationType } from "../enums/verification-type";
|
||||
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
|
||||
|
||||
export type OtpVerification = { type: VerificationType.OTP; secret: string };
|
||||
export type MasterPasswordVerification = { type: VerificationType.MasterPassword; secret: string };
|
||||
export type MasterPasswordVerification = {
|
||||
type: VerificationType.MasterPassword;
|
||||
/** Secret here means the master password, *NOT* a hash of it */
|
||||
secret: string;
|
||||
};
|
||||
export type PinVerification = { type: VerificationType.PIN; secret: string };
|
||||
export type BiometricsVerification = { type: VerificationType.Biometrics };
|
||||
|
||||
@@ -25,8 +25,8 @@ export function verificationHasSecret(
|
||||
export type ServerSideVerification = OtpVerification | MasterPasswordVerification;
|
||||
|
||||
export type MasterPasswordVerificationResponse = {
|
||||
/** @deprecated */
|
||||
masterKey: MasterKey;
|
||||
kdfConfig: KdfConfig;
|
||||
email: string;
|
||||
policyOptions: MasterPasswordPolicyResponse | null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTier,
|
||||
} from "../types/subscription-pricing-tier";
|
||||
|
||||
export abstract class SubscriptionPricingServiceAbstraction {
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getPersonalSubscriptionPricingTiers$(): Observable<PersonalSubscriptionPricingTier[]>;
|
||||
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getBusinessSubscriptionPricingTiers$(): Observable<BusinessSubscriptionPricingTier[]>;
|
||||
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
abstract getDeveloperSubscriptionPricingTiers$(): Observable<BusinessSubscriptionPricingTier[]>;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export enum PlanType {
|
||||
EnterpriseMonthly2019 = 4,
|
||||
EnterpriseAnnually2019 = 5,
|
||||
Custom = 6,
|
||||
FamiliesAnnually = 7,
|
||||
FamiliesAnnually2025 = 7,
|
||||
TeamsMonthly2020 = 8,
|
||||
TeamsAnnually2020 = 9,
|
||||
EnterpriseMonthly2020 = 10,
|
||||
@@ -23,4 +23,5 @@ export enum PlanType {
|
||||
EnterpriseMonthly = 19,
|
||||
EnterpriseAnnually = 20,
|
||||
TeamsStarter = 21,
|
||||
FamiliesAnnually = 22,
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
case PlanType.Free:
|
||||
case PlanType.FamiliesAnnually:
|
||||
case PlanType.FamiliesAnnually2019:
|
||||
case PlanType.FamiliesAnnually2025:
|
||||
case PlanType.TeamsStarter2023:
|
||||
case PlanType.TeamsStarter:
|
||||
return true;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
417
libs/common/src/billing/services/subscription-pricing.service.ts
Normal file
417
libs/common/src/billing/services/subscription-pricing.service.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
import {
|
||||
combineLatest,
|
||||
combineLatestWith,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
take,
|
||||
throwError,
|
||||
} from "rxjs";
|
||||
import { catchError } from "rxjs/operators";
|
||||
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { PlanType } from "@bitwarden/common/billing/enums";
|
||||
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
|
||||
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
|
||||
import { SubscriptionPricingServiceAbstraction } from "../abstractions/subscription-pricing.service.abstraction";
|
||||
import {
|
||||
BusinessSubscriptionPricingTier,
|
||||
BusinessSubscriptionPricingTierIds,
|
||||
PersonalSubscriptionPricingTier,
|
||||
PersonalSubscriptionPricingTierIds,
|
||||
SubscriptionCadenceIds,
|
||||
} from "../types/subscription-pricing-tier";
|
||||
|
||||
export class DefaultSubscriptionPricingService implements SubscriptionPricingServiceAbstraction {
|
||||
/**
|
||||
* Fallback premium pricing used when the feature flag is disabled.
|
||||
* These values represent the legacy pricing model and will not reflect
|
||||
* server-side price changes. They are retained for backward compatibility
|
||||
* during the feature flag rollout period.
|
||||
*/
|
||||
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
|
||||
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
|
||||
|
||||
constructor(
|
||||
private billingApiService: BillingApiServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Gets personal subscription pricing tiers (Premium and Families).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of personal subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getPersonalSubscriptionPricingTiers$ = (): Observable<PersonalSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.premium$, this.families$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load personal subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getBusinessSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.teams$, this.enterprise$, this.custom$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load business subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
|
||||
* Throws any errors that occur during api request so callers must handle errors.
|
||||
* @returns An observable of an array of business subscription pricing tiers for developers.
|
||||
* @throws Error if any errors occur during api request.
|
||||
*/
|
||||
getDeveloperSubscriptionPricingTiers$ = (): Observable<BusinessSubscriptionPricingTier[]> =>
|
||||
combineLatest([this.free$, this.teams$, this.enterprise$]).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to load developer subscription pricing tiers", error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
|
||||
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
|
||||
this.billingApiService.getPlans(),
|
||||
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
|
||||
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
|
||||
this.billingApiService.getPremiumPlan(),
|
||||
).pipe(
|
||||
catchError((error: unknown) => {
|
||||
this.logService.error("Failed to fetch premium plan from API", error);
|
||||
return throwError(() => error); // Re-throw to propagate to higher-level error handler
|
||||
}),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
|
||||
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
|
||||
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
|
||||
.pipe(
|
||||
take(1), // Lock behavior at first subscription to prevent switching data sources mid-stream
|
||||
switchMap((fetchPremiumFromPricingService) =>
|
||||
fetchPremiumFromPricingService
|
||||
? this.premiumPlanResponse$.pipe(
|
||||
map((premiumPlan) => ({
|
||||
seat: premiumPlan.seat.price,
|
||||
storage: premiumPlan.storage.price,
|
||||
})),
|
||||
)
|
||||
: of({
|
||||
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
|
||||
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
|
||||
}),
|
||||
),
|
||||
map((premiumPrices) => ({
|
||||
id: PersonalSubscriptionPricingTierIds.Premium,
|
||||
name: this.i18nService.t("premium"),
|
||||
description: this.i18nService.t("planDescPremium"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "standalone",
|
||||
annualPrice: premiumPrices.seat,
|
||||
annualPricePerAdditionalStorageGB: premiumPrices.storage,
|
||||
features: [
|
||||
this.featureTranslations.builtInAuthenticator(),
|
||||
this.featureTranslations.secureFileStorage(),
|
||||
this.featureTranslations.emergencyAccess(),
|
||||
this.featureTranslations.breachMonitoring(),
|
||||
this.featureTranslations.andMoreFeatures(),
|
||||
],
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
|
||||
map(([plans, milestone3FeatureEnabled]) => {
|
||||
const familiesPlan = plans.data.find(
|
||||
(plan) =>
|
||||
plan.type ===
|
||||
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
|
||||
)!;
|
||||
|
||||
return {
|
||||
id: PersonalSubscriptionPricingTierIds.Families,
|
||||
name: this.i18nService.t("planNameFamilies"),
|
||||
description: this.i18nService.t("planDescFamiliesV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually],
|
||||
passwordManager: {
|
||||
type: "packaged",
|
||||
users: familiesPlan.PasswordManager.baseSeats,
|
||||
annualPrice: familiesPlan.PasswordManager.basePrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
familiesPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
features: [
|
||||
this.featureTranslations.premiumAccounts(),
|
||||
this.featureTranslations.familiesUnlimitedSharing(),
|
||||
this.featureTranslations.familiesUnlimitedCollections(),
|
||||
this.featureTranslations.familiesSharedStorage(),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans): BusinessSubscriptionPricingTier => {
|
||||
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Free,
|
||||
name: this.i18nService.t("planNameFree"),
|
||||
description: this.i18nService.t("planDescFreeV2", "1"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
|
||||
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
|
||||
this.featureTranslations.alwaysFree(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "free",
|
||||
features: [
|
||||
this.featureTranslations.twoSecretsIncluded(),
|
||||
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Teams,
|
||||
name: this.i18nService.t("planNameTeams"),
|
||||
description: this.i18nService.t("teamsPlanUpgradeMessage"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
|
||||
features: [
|
||||
this.featureTranslations.secureItemSharing(),
|
||||
this.featureTranslations.eventLogMonitoring(),
|
||||
this.featureTranslations.directoryIntegration(),
|
||||
this.featureTranslations.scimSupport(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedSecretsAndProjects(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualTeamsPlan.SecretsManager.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map((plans) => {
|
||||
const annualEnterprisePlan = plans.data.find(
|
||||
(plan) => plan.type === PlanType.EnterpriseAnnually,
|
||||
)!;
|
||||
|
||||
return {
|
||||
id: BusinessSubscriptionPricingTierIds.Enterprise,
|
||||
name: this.i18nService.t("planNameEnterprise"),
|
||||
description: this.i18nService.t("planDescEnterpriseV2"),
|
||||
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
|
||||
passwordManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
|
||||
annualPricePerAdditionalStorageGB:
|
||||
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
|
||||
features: [
|
||||
this.featureTranslations.enterpriseSecurityPolicies(),
|
||||
this.featureTranslations.passwordLessSso(),
|
||||
this.featureTranslations.accountRecovery(),
|
||||
this.featureTranslations.selfHostOption(),
|
||||
this.featureTranslations.complimentaryFamiliesPlan(),
|
||||
],
|
||||
},
|
||||
secretsManager: {
|
||||
type: "scalable",
|
||||
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
|
||||
annualPricePerAdditionalServiceAccount:
|
||||
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
|
||||
features: [
|
||||
this.featureTranslations.unlimitedUsers(),
|
||||
this.featureTranslations.includedMachineAccountsV2(
|
||||
annualEnterprisePlan.SecretsManager.baseServiceAccount,
|
||||
),
|
||||
],
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
|
||||
map(
|
||||
(): BusinessSubscriptionPricingTier => ({
|
||||
id: BusinessSubscriptionPricingTierIds.Custom,
|
||||
name: this.i18nService.t("planNameCustom"),
|
||||
description: this.i18nService.t("planDescCustom"),
|
||||
availableCadences: [],
|
||||
passwordManager: {
|
||||
type: "custom",
|
||||
features: [
|
||||
this.featureTranslations.strengthenCybersecurity(),
|
||||
this.featureTranslations.boostProductivity(),
|
||||
this.featureTranslations.seamlessIntegration(),
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
private featureTranslations = {
|
||||
builtInAuthenticator: () => ({
|
||||
key: "builtInAuthenticator",
|
||||
value: this.i18nService.t("builtInAuthenticator"),
|
||||
}),
|
||||
emergencyAccess: () => ({
|
||||
key: "emergencyAccess",
|
||||
value: this.i18nService.t("emergencyAccess"),
|
||||
}),
|
||||
breachMonitoring: () => ({
|
||||
key: "breachMonitoring",
|
||||
value: this.i18nService.t("breachMonitoring"),
|
||||
}),
|
||||
andMoreFeatures: () => ({
|
||||
key: "andMoreFeatures",
|
||||
value: this.i18nService.t("andMoreFeatures"),
|
||||
}),
|
||||
premiumAccounts: () => ({
|
||||
key: "premiumAccounts",
|
||||
value: this.i18nService.t("premiumAccounts"),
|
||||
}),
|
||||
secureFileStorage: () => ({
|
||||
key: "secureFileStorage",
|
||||
value: this.i18nService.t("secureFileStorage"),
|
||||
}),
|
||||
familiesUnlimitedSharing: () => ({
|
||||
key: "familiesUnlimitedSharing",
|
||||
value: this.i18nService.t("familiesUnlimitedSharing"),
|
||||
}),
|
||||
familiesUnlimitedCollections: () => ({
|
||||
key: "familiesUnlimitedCollections",
|
||||
value: this.i18nService.t("familiesUnlimitedCollections"),
|
||||
}),
|
||||
familiesSharedStorage: () => ({
|
||||
key: "familiesSharedStorage",
|
||||
value: this.i18nService.t("familiesSharedStorage"),
|
||||
}),
|
||||
limitedUsersV2: (users: number) => ({
|
||||
key: "limitedUsersV2",
|
||||
value: this.i18nService.t("limitedUsersV2", users),
|
||||
}),
|
||||
limitedCollectionsV2: (collections: number) => ({
|
||||
key: "limitedCollectionsV2",
|
||||
value: this.i18nService.t("limitedCollectionsV2", collections),
|
||||
}),
|
||||
alwaysFree: () => ({
|
||||
key: "alwaysFree",
|
||||
value: this.i18nService.t("alwaysFree"),
|
||||
}),
|
||||
twoSecretsIncluded: () => ({
|
||||
key: "twoSecretsIncluded",
|
||||
value: this.i18nService.t("twoSecretsIncluded"),
|
||||
}),
|
||||
projectsIncludedV2: (projects: number) => ({
|
||||
key: "projectsIncludedV2",
|
||||
value: this.i18nService.t("projectsIncludedV2", projects),
|
||||
}),
|
||||
secureItemSharing: () => ({
|
||||
key: "secureItemSharing",
|
||||
value: this.i18nService.t("secureItemSharing"),
|
||||
}),
|
||||
eventLogMonitoring: () => ({
|
||||
key: "eventLogMonitoring",
|
||||
value: this.i18nService.t("eventLogMonitoring"),
|
||||
}),
|
||||
directoryIntegration: () => ({
|
||||
key: "directoryIntegration",
|
||||
value: this.i18nService.t("directoryIntegration"),
|
||||
}),
|
||||
scimSupport: () => ({
|
||||
key: "scimSupport",
|
||||
value: this.i18nService.t("scimSupport"),
|
||||
}),
|
||||
unlimitedSecretsAndProjects: () => ({
|
||||
key: "unlimitedSecretsAndProjects",
|
||||
value: this.i18nService.t("unlimitedSecretsAndProjects"),
|
||||
}),
|
||||
includedMachineAccountsV2: (included: number) => ({
|
||||
key: "includedMachineAccountsV2",
|
||||
value: this.i18nService.t("includedMachineAccountsV2", included),
|
||||
}),
|
||||
enterpriseSecurityPolicies: () => ({
|
||||
key: "enterpriseSecurityPolicies",
|
||||
value: this.i18nService.t("enterpriseSecurityPolicies"),
|
||||
}),
|
||||
passwordLessSso: () => ({
|
||||
key: "passwordLessSso",
|
||||
value: this.i18nService.t("passwordLessSso"),
|
||||
}),
|
||||
accountRecovery: () => ({
|
||||
key: "accountRecovery",
|
||||
value: this.i18nService.t("accountRecovery"),
|
||||
}),
|
||||
selfHostOption: () => ({
|
||||
key: "selfHostOption",
|
||||
value: this.i18nService.t("selfHostOption"),
|
||||
}),
|
||||
complimentaryFamiliesPlan: () => ({
|
||||
key: "complimentaryFamiliesPlan",
|
||||
value: this.i18nService.t("complimentaryFamiliesPlan"),
|
||||
}),
|
||||
unlimitedUsers: () => ({
|
||||
key: "unlimitedUsers",
|
||||
value: this.i18nService.t("unlimitedUsers"),
|
||||
}),
|
||||
strengthenCybersecurity: () => ({
|
||||
key: "strengthenCybersecurity",
|
||||
value: this.i18nService.t("strengthenCybersecurity"),
|
||||
}),
|
||||
boostProductivity: () => ({
|
||||
key: "boostProductivity",
|
||||
value: this.i18nService.t("boostProductivity"),
|
||||
}),
|
||||
seamlessIntegration: () => ({
|
||||
key: "seamlessIntegration",
|
||||
value: this.i18nService.t("seamlessIntegration"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
85
libs/common/src/billing/types/subscription-pricing-tier.ts
Normal file
85
libs/common/src/billing/types/subscription-pricing-tier.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export const PersonalSubscriptionPricingTierIds = {
|
||||
Premium: "premium",
|
||||
Families: "families",
|
||||
} as const;
|
||||
|
||||
export const BusinessSubscriptionPricingTierIds = {
|
||||
Free: "free",
|
||||
Teams: "teams",
|
||||
Enterprise: "enterprise",
|
||||
Custom: "custom",
|
||||
} as const;
|
||||
|
||||
export const SubscriptionCadenceIds = {
|
||||
Annually: "annually",
|
||||
Monthly: "monthly",
|
||||
} as const;
|
||||
|
||||
export type PersonalSubscriptionPricingTierId =
|
||||
(typeof PersonalSubscriptionPricingTierIds)[keyof typeof PersonalSubscriptionPricingTierIds];
|
||||
export type BusinessSubscriptionPricingTierId =
|
||||
(typeof BusinessSubscriptionPricingTierIds)[keyof typeof BusinessSubscriptionPricingTierIds];
|
||||
export type SubscriptionCadence =
|
||||
(typeof SubscriptionCadenceIds)[keyof typeof SubscriptionCadenceIds];
|
||||
|
||||
type HasFeatures = {
|
||||
features: { key: string; value: string }[];
|
||||
};
|
||||
|
||||
type HasAdditionalStorage = {
|
||||
annualPricePerAdditionalStorageGB: number;
|
||||
};
|
||||
|
||||
type StandalonePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "standalone";
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
type PackagedPasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "packaged";
|
||||
users: number;
|
||||
annualPrice: number;
|
||||
};
|
||||
|
||||
type FreePasswordManager = HasFeatures & {
|
||||
type: "free";
|
||||
};
|
||||
|
||||
type CustomPasswordManager = HasFeatures & {
|
||||
type: "custom";
|
||||
};
|
||||
|
||||
type ScalablePasswordManager = HasFeatures &
|
||||
HasAdditionalStorage & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
};
|
||||
|
||||
type FreeSecretsManager = HasFeatures & {
|
||||
type: "free";
|
||||
};
|
||||
|
||||
type ScalableSecretsManager = HasFeatures & {
|
||||
type: "scalable";
|
||||
annualPricePerUser: number;
|
||||
annualPricePerAdditionalServiceAccount: number;
|
||||
};
|
||||
|
||||
export type PersonalSubscriptionPricingTier = {
|
||||
id: PersonalSubscriptionPricingTierId;
|
||||
name: string;
|
||||
description: string;
|
||||
availableCadences: Omit<SubscriptionCadence, "monthly">[]; // personal plans are only ever annual
|
||||
passwordManager: StandalonePasswordManager | PackagedPasswordManager;
|
||||
};
|
||||
|
||||
export type BusinessSubscriptionPricingTier = {
|
||||
id: BusinessSubscriptionPricingTierId;
|
||||
name: string;
|
||||
description: string;
|
||||
availableCadences: SubscriptionCadence[];
|
||||
passwordManager: FreePasswordManager | ScalablePasswordManager | CustomPasswordManager;
|
||||
secretsManager?: FreeSecretsManager | ScalableSecretsManager;
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* Auth */
|
||||
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
|
||||
/* Autofill */
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
@@ -30,6 +31,8 @@ export enum FeatureFlag {
|
||||
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
|
||||
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
|
||||
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",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
@@ -40,6 +43,7 @@ export enum FeatureFlag {
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
|
||||
/* Tools */
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
@@ -57,6 +61,8 @@ export enum FeatureFlag {
|
||||
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
|
||||
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -105,9 +111,12 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
|
||||
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
|
||||
[FeatureFlag.AutofillConfirmation]: FALSE,
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
@@ -118,6 +127,8 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
|
||||
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
|
||||
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
|
||||
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
|
||||
[FeatureFlag.PM26462_Milestone_3]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
@@ -128,6 +139,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { AuthService } from "../../auth/abstractions/auth.service";
|
||||
|
||||
export abstract class ProcessReloadServiceAbstraction {
|
||||
abstract startProcessReload(authService: AuthService): Promise<void>;
|
||||
abstract startProcessReload(): Promise<void>;
|
||||
abstract cancelProcessReload(): void;
|
||||
}
|
||||
|
||||
@@ -22,13 +22,15 @@ export class MasterPasswordUnlockResponse extends BaseResponse {
|
||||
|
||||
this.kdf = new KdfConfigResponse(this.getResponseProperty("Kdf"));
|
||||
|
||||
const masterKeyEncryptedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey");
|
||||
if (masterKeyEncryptedUserKey == null || typeof masterKeyEncryptedUserKey !== "string") {
|
||||
// Note: MasterKeyEncryptedUserKey and masterKeyWrappedUserKey are the same thing, and
|
||||
// used inconsistently in the codebase
|
||||
const masterKeyWrappedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey");
|
||||
if (masterKeyWrappedUserKey == null || typeof masterKeyWrappedUserKey !== "string") {
|
||||
throw new Error(
|
||||
"MasterPasswordUnlockResponse does not contain a valid master key encrypted user key",
|
||||
);
|
||||
}
|
||||
this.masterKeyWrappedUserKey = masterKeyEncryptedUserKey as MasterKeyWrappedUserKey;
|
||||
this.masterKeyWrappedUserKey = masterKeyWrappedUserKey as MasterKeyWrappedUserKey;
|
||||
}
|
||||
|
||||
toMasterPasswordUnlockData() {
|
||||
|
||||
@@ -30,16 +30,17 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract
|
||||
private biometricStateService: BiometricStateService,
|
||||
private accountService: AccountService,
|
||||
private logService: LogService,
|
||||
private authService: AuthService,
|
||||
) {}
|
||||
|
||||
async startProcessReload(authService: AuthService): Promise<void> {
|
||||
async startProcessReload(): Promise<void> {
|
||||
const accounts = await firstValueFrom(this.accountService.accounts$);
|
||||
if (accounts != null) {
|
||||
const keys = Object.keys(accounts);
|
||||
if (keys.length > 0) {
|
||||
for (const userId of keys) {
|
||||
let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
|
||||
status = await authService.getAuthStatus(userId);
|
||||
let status = await firstValueFrom(this.authService.authStatusFor$(userId as UserId));
|
||||
status = await this.authService.getAuthStatus(userId);
|
||||
if (status === AuthenticationStatus.Unlocked) {
|
||||
this.logService.info(
|
||||
"[Process Reload Service] User unlocked, preventing process reload",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export abstract class VaultTimeoutService {
|
||||
abstract checkVaultTimeout(): Promise<void>;
|
||||
abstract lock(userId?: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -8,3 +8,4 @@ export {
|
||||
VaultTimeoutOption,
|
||||
VaultTimeoutStringType,
|
||||
} from "./types/vault-timeout.type";
|
||||
export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type";
|
||||
|
||||
@@ -5,31 +5,17 @@ import { BehaviorSubject, from, of } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutService } from "@bitwarden/auth/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
import { StateService } from "@bitwarden/state";
|
||||
import { LockService, LogoutService } from "@bitwarden/auth/common";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
|
||||
import { AccountInfo } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../../platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { TaskSchedulerService } from "../../../platform/scheduling";
|
||||
import { StateEventRunnerService } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SearchService } from "../../../vault/abstractions/search.service";
|
||||
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
|
||||
|
||||
@@ -38,23 +24,13 @@ import { VaultTimeoutService } from "./vault-timeout.service";
|
||||
|
||||
describe("VaultTimeoutService", () => {
|
||||
let accountService: FakeAccountService;
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let cipherService: MockProxy<CipherService>;
|
||||
let folderService: MockProxy<FolderService>;
|
||||
let collectionService: MockProxy<CollectionService>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let searchService: MockProxy<SearchService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
let authService: MockProxy<AuthService>;
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
|
||||
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
let biometricsService: MockProxy<BiometricsService>;
|
||||
let lockService: MockProxy<LockService>;
|
||||
let logoutService: MockProxy<LogoutService>;
|
||||
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
|
||||
|
||||
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
|
||||
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
|
||||
@@ -65,25 +41,14 @@ describe("VaultTimeoutService", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
cipherService = mock();
|
||||
folderService = mock();
|
||||
collectionService = mock();
|
||||
platformUtilsService = mock();
|
||||
messagingService = mock();
|
||||
searchService = mock();
|
||||
stateService = mock();
|
||||
tokenService = mock();
|
||||
authService = mock();
|
||||
vaultTimeoutSettingsService = mock();
|
||||
stateEventRunnerService = mock();
|
||||
taskSchedulerService = mock<TaskSchedulerService>();
|
||||
lockService = mock<LockService>();
|
||||
logService = mock<LogService>();
|
||||
biometricsService = mock<BiometricsService>();
|
||||
logoutService = mock<LogoutService>();
|
||||
|
||||
lockedCallback = jest.fn();
|
||||
|
||||
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
|
||||
@@ -94,22 +59,12 @@ describe("VaultTimeoutService", () => {
|
||||
|
||||
vaultTimeoutService = new VaultTimeoutService(
|
||||
accountService,
|
||||
masterPasswordService,
|
||||
cipherService,
|
||||
folderService,
|
||||
collectionService,
|
||||
platformUtilsService,
|
||||
messagingService,
|
||||
searchService,
|
||||
stateService,
|
||||
tokenService,
|
||||
authService,
|
||||
vaultTimeoutSettingsService,
|
||||
stateEventRunnerService,
|
||||
taskSchedulerService,
|
||||
logService,
|
||||
biometricsService,
|
||||
lockedCallback,
|
||||
lockService,
|
||||
logoutService,
|
||||
);
|
||||
});
|
||||
@@ -145,9 +100,6 @@ describe("VaultTimeoutService", () => {
|
||||
authService.getAuthStatus.mockImplementation((userId) => {
|
||||
return Promise.resolve(accounts[userId]?.authStatus);
|
||||
});
|
||||
tokenService.hasAccessToken$.mockImplementation((userId) => {
|
||||
return of(accounts[userId]?.isAuthenticated ?? false);
|
||||
});
|
||||
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation((userId) => {
|
||||
return new BehaviorSubject<VaultTimeout>(accounts[userId]?.vaultTimeout);
|
||||
@@ -203,13 +155,7 @@ describe("VaultTimeoutService", () => {
|
||||
};
|
||||
|
||||
const expectUserToHaveLocked = (userId: string) => {
|
||||
// This does NOT assert all the things that the lock process does
|
||||
expect(tokenService.hasAccessToken$).toHaveBeenCalledWith(userId);
|
||||
expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId);
|
||||
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId });
|
||||
expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId);
|
||||
expect(cipherService.clearCache).toHaveBeenCalledWith(userId);
|
||||
expect(lockedCallback).toHaveBeenCalledWith(userId);
|
||||
expect(lockService.lock).toHaveBeenCalledWith(userId);
|
||||
};
|
||||
|
||||
const expectUserToHaveLoggedOut = (userId: string) => {
|
||||
@@ -217,7 +163,7 @@ describe("VaultTimeoutService", () => {
|
||||
};
|
||||
|
||||
const expectNoAction = (userId: string) => {
|
||||
expect(lockedCallback).not.toHaveBeenCalledWith(userId);
|
||||
expect(lockService.lock).not.toHaveBeenCalledWith(userId);
|
||||
expect(logoutService.logout).not.toHaveBeenCalledWith(userId, "vaultTimeout");
|
||||
};
|
||||
|
||||
@@ -347,12 +293,8 @@ describe("VaultTimeoutService", () => {
|
||||
expectNoAction("1");
|
||||
expectUserToHaveLocked("2");
|
||||
|
||||
// Active users should have additional steps ran
|
||||
expect(searchService.clearIndex).toHaveBeenCalled();
|
||||
expect(folderService.clearDecryptedFolderState).toHaveBeenCalled();
|
||||
|
||||
expectUserToHaveLoggedOut("3"); // They have chosen logout as their action and it's available, log them out
|
||||
expectUserToHaveLoggedOut("4"); // They may have had lock as their chosen action but it's not available to them so logout
|
||||
expectUserToHaveLocked("4"); // They don't have lock available. But this is handled in lock service so we do not check for logout here
|
||||
});
|
||||
|
||||
it("should lock an account if they haven't been active passed their vault timeout even if a view is open when they are not the active user.", async () => {
|
||||
@@ -392,70 +334,4 @@ describe("VaultTimeoutService", () => {
|
||||
expectNoAction("1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("lock", () => {
|
||||
const setupLock = () => {
|
||||
setupAccounts(
|
||||
{
|
||||
user1: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
user2: {
|
||||
authStatus: AuthenticationStatus.Unlocked,
|
||||
isAuthenticated: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: "user1",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
it("should call state event runner with currently active user if no user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
await vaultTimeoutService.lock();
|
||||
|
||||
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
|
||||
});
|
||||
|
||||
it("should call locked callback with the locking user if no userID is passed in.", async () => {
|
||||
setupLock();
|
||||
|
||||
await vaultTimeoutService.lock();
|
||||
|
||||
expect(lockedCallback).toHaveBeenCalledWith("user1");
|
||||
});
|
||||
|
||||
it("should call state event runner with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", user2);
|
||||
});
|
||||
|
||||
it("should call messaging service locked message with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: user2 });
|
||||
});
|
||||
|
||||
it("should call locked callback with user passed into lock", async () => {
|
||||
setupLock();
|
||||
|
||||
const user2 = "user2" as UserId;
|
||||
|
||||
await vaultTimeoutService.lock(user2);
|
||||
|
||||
expect(lockedCallback).toHaveBeenCalledWith(user2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,32 +1,18 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
|
||||
import { combineLatest, concatMap, firstValueFrom } from "rxjs";
|
||||
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutService } from "@bitwarden/auth/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { BiometricsService } from "@bitwarden/key-management";
|
||||
import { LockService, LogoutService } from "@bitwarden/auth/common";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { MessagingService } from "../../../platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../../platform/abstractions/state.service";
|
||||
import { TaskSchedulerService, ScheduledTaskNames } from "../../../platform/scheduling";
|
||||
import { StateEventRunnerService } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { CipherService } from "../../../vault/abstractions/cipher.service";
|
||||
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
|
||||
import { SearchService } from "../../../vault/abstractions/search.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
|
||||
import { VaultTimeoutSettingsService } from "../abstractions/vault-timeout-settings.service";
|
||||
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../abstractions/vault-timeout.service";
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
@@ -36,22 +22,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private cipherService: CipherService,
|
||||
private folderService: FolderService,
|
||||
private collectionService: CollectionService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
private messagingService: MessagingService,
|
||||
private searchService: SearchService,
|
||||
private stateService: StateService,
|
||||
private tokenService: TokenService,
|
||||
private authService: AuthService,
|
||||
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
|
||||
private stateEventRunnerService: StateEventRunnerService,
|
||||
private taskSchedulerService: TaskSchedulerService,
|
||||
protected logService: LogService,
|
||||
private biometricService: BiometricsService,
|
||||
private lockedCallback: (userId: UserId) => Promise<void> = null,
|
||||
private lockService: LockService,
|
||||
private logoutService: LogoutService,
|
||||
) {
|
||||
this.taskSchedulerService.registerTaskHandler(
|
||||
@@ -104,64 +80,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
);
|
||||
}
|
||||
|
||||
async lock(userId?: UserId): Promise<void> {
|
||||
await this.biometricService.setShouldAutopromptNow(false);
|
||||
|
||||
const lockingUserId =
|
||||
userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
|
||||
|
||||
const authed = await firstValueFrom(this.tokenService.hasAccessToken$(lockingUserId));
|
||||
if (!authed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const availableActions = await firstValueFrom(
|
||||
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
|
||||
);
|
||||
const supportsLock = availableActions.includes(VaultTimeoutAction.Lock);
|
||||
if (!supportsLock) {
|
||||
await this.logoutService.logout(userId, "vaultTimeout");
|
||||
}
|
||||
|
||||
// HACK: Start listening for the transition of the locking user from something to the locked state.
|
||||
// This is very much a hack to ensure that the authentication status to retrievable right after
|
||||
// it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead
|
||||
// lockedCallback should be deprecated and people should subscribe and react to `authStatusFor$` themselves.
|
||||
const lockPromise = firstValueFrom(
|
||||
this.authService.authStatusFor$(lockingUserId).pipe(
|
||||
filter((authStatus) => authStatus === AuthenticationStatus.Locked),
|
||||
timeout({
|
||||
first: 5_000,
|
||||
with: () => {
|
||||
throw new Error("The lock process did not complete in a reasonable amount of time.");
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
await this.searchService.clearIndex(lockingUserId);
|
||||
|
||||
await this.folderService.clearDecryptedFolderState(lockingUserId);
|
||||
await this.masterPasswordService.clearMasterKey(lockingUserId);
|
||||
|
||||
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
|
||||
|
||||
await this.cipherService.clearCache(lockingUserId);
|
||||
|
||||
await this.stateEventRunnerService.handleEvent("lock", lockingUserId);
|
||||
|
||||
// HACK: Sit here and wait for the the auth status to transition to `Locked`
|
||||
// to ensure the message and lockedCallback will get the correct status
|
||||
// if/when they call it.
|
||||
await lockPromise;
|
||||
|
||||
this.messagingService.send("locked", { userId: lockingUserId });
|
||||
|
||||
if (this.lockedCallback != null) {
|
||||
await this.lockedCallback(lockingUserId);
|
||||
}
|
||||
}
|
||||
|
||||
private async shouldLock(
|
||||
userId: string,
|
||||
lastActive: Date,
|
||||
@@ -206,6 +124,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
);
|
||||
timeoutAction === VaultTimeoutAction.LogOut
|
||||
? await this.logoutService.logout(userId, "vaultTimeout")
|
||||
: await this.lock(userId);
|
||||
: await this.lockService.lock(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
|
||||
|
||||
export interface MaximumVaultTimeoutPolicyData {
|
||||
minutes: number;
|
||||
action?: VaultTimeoutAction;
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.ex
|
||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
|
||||
import { SshKeyExport } from "./ssh-key.export";
|
||||
|
||||
describe("Cipher Export", () => {
|
||||
describe("toView", () => {
|
||||
it.each([[null], [undefined]])(
|
||||
@@ -41,4 +43,36 @@ describe("Cipher Export", () => {
|
||||
expect(resultView.deletedDate).toEqual(request.deletedDate);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SshKeyExport.toView", () => {
|
||||
const validSshKey = {
|
||||
privateKey: "PRIVATE_KEY",
|
||||
publicKey: "PUBLIC_KEY",
|
||||
keyFingerprint: "FINGERPRINT",
|
||||
};
|
||||
|
||||
it.each([null, undefined, "", " "])("should throw when privateKey is %p", (value) => {
|
||||
const sshKey = { ...validSshKey, privateKey: value } as any;
|
||||
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key private key is required.");
|
||||
});
|
||||
|
||||
it.each([null, undefined, "", " "])("should throw when publicKey is %p", (value) => {
|
||||
const sshKey = { ...validSshKey, publicKey: value } as any;
|
||||
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key public key is required.");
|
||||
});
|
||||
|
||||
it.each([null, undefined, "", " "])("should throw when keyFingerprint is %p", (value) => {
|
||||
const sshKey = { ...validSshKey, keyFingerprint: value } as any;
|
||||
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key fingerprint is required.");
|
||||
});
|
||||
|
||||
it("should succeed with valid inputs", () => {
|
||||
const sshKey = { ...validSshKey };
|
||||
const result = SshKeyExport.toView(sshKey);
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.privateKey).toBe(validSshKey.privateKey);
|
||||
expect(result?.publicKey).toBe(validSshKey.publicKey);
|
||||
expect(result?.keyFingerprint).toBe(validSshKey.keyFingerprint);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,22 @@ export class SshKeyExport {
|
||||
return req;
|
||||
}
|
||||
|
||||
static toView(req: SshKeyExport, view = new SshKeyView()) {
|
||||
static toView(req?: SshKeyExport, view = new SshKeyView()): SshKeyView | undefined {
|
||||
if (req == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if (!req.privateKey || req.privateKey.trim() === "") {
|
||||
throw new Error("SSH key private key is required.");
|
||||
}
|
||||
if (!req.publicKey || req.publicKey.trim() === "") {
|
||||
throw new Error("SSH key public key is required.");
|
||||
}
|
||||
if (!req.keyFingerprint || req.keyFingerprint.trim() === "") {
|
||||
throw new Error("SSH key fingerprint is required.");
|
||||
}
|
||||
|
||||
view.privateKey = req.privateKey;
|
||||
view.publicKey = req.publicKey;
|
||||
view.keyFingerprint = req.keyFingerprint;
|
||||
|
||||
@@ -5,6 +5,6 @@ import { TranslationService } from "./translation.service";
|
||||
export abstract class I18nService extends TranslationService {
|
||||
abstract userSetLocale$: Observable<string | undefined>;
|
||||
abstract locale$: Observable<string>;
|
||||
abstract setLocale(locale: string): Promise<void>;
|
||||
abstract setLocale(locale: string | null): Promise<void>;
|
||||
abstract init(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ export interface IpcMessage {
|
||||
message: SerializedOutgoingMessage;
|
||||
}
|
||||
|
||||
export interface SerializedOutgoingMessage extends Omit<OutgoingMessage, "free" | "payload"> {
|
||||
export interface SerializedOutgoingMessage
|
||||
extends Omit<OutgoingMessage, typeof Symbol.dispose | "free" | "payload"> {
|
||||
payload: number[];
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ export class I18nService extends TranslationService implements I18nServiceAbstra
|
||||
this.locale$ = this.userSetLocale$.pipe(map((locale) => locale ?? this.translationLocale));
|
||||
}
|
||||
|
||||
async setLocale(locale: string): Promise<void> {
|
||||
async setLocale(locale: string | null): Promise<void> {
|
||||
await this.translationLocaleState.update(() => locale);
|
||||
}
|
||||
|
||||
|
||||
@@ -249,6 +249,7 @@ function createMockClient(): MockProxy<BitwardenClient> {
|
||||
state: jest.fn().mockReturnValue(mock()),
|
||||
load_flags: jest.fn().mockReturnValue(mock()),
|
||||
free: mock(),
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -80,9 +80,4 @@ export class AuditService implements AuditServiceAbstraction {
|
||||
throw new Error();
|
||||
}
|
||||
}
|
||||
|
||||
async getKnownPhishingDomains(): Promise<string[]> {
|
||||
const response = await this.apiService.send("GET", "/phishing-domains", null, true, true);
|
||||
return response as string[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { CipherRiskResult, CipherId } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { isPasswordAtRisk } from "./cipher-risk.service";
|
||||
|
||||
describe("isPasswordAtRisk", () => {
|
||||
const mockId = "00000000-0000-0000-0000-000000000000" as unknown as CipherId;
|
||||
|
||||
const createRisk = (overrides: Partial<CipherRiskResult> = {}): CipherRiskResult => ({
|
||||
id: mockId,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("exposed password risk", () => {
|
||||
it.each([
|
||||
{ value: 5, expected: true, desc: "found with value > 0" },
|
||||
{ value: 0, expected: false, desc: "found but value is 0" },
|
||||
])("should return $expected when password is $desc", ({ value, expected }) => {
|
||||
const risk = createRisk({ exposed_result: { type: "Found", value } });
|
||||
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should return false when password is not checked", () => {
|
||||
expect(isPasswordAtRisk(createRisk())).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password reuse risk", () => {
|
||||
it.each([
|
||||
{ count: 2, expected: true, desc: "reused (reuse_count > 1)" },
|
||||
{ count: 1, expected: false, desc: "not reused" },
|
||||
{ count: undefined, expected: false, desc: "undefined" },
|
||||
])("should return $expected when reuse_count is $desc", ({ count, expected }) => {
|
||||
const risk = createRisk({ reuse_count: count });
|
||||
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("password strength risk", () => {
|
||||
it.each([
|
||||
{ strength: 0, expected: true },
|
||||
{ strength: 1, expected: true },
|
||||
{ strength: 2, expected: true },
|
||||
{ strength: 3, expected: false },
|
||||
{ strength: 4, expected: false },
|
||||
])("should return $expected when password strength is $strength", ({ strength, expected }) => {
|
||||
const risk = createRisk({ password_strength: strength });
|
||||
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple risk factors", () => {
|
||||
it.each<{ desc: string; overrides: Partial<CipherRiskResult>; expected: boolean }>([
|
||||
{
|
||||
desc: "exposed and reused",
|
||||
overrides: {
|
||||
exposed_result: { type: "Found" as const, value: 3 },
|
||||
reuse_count: 2,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "reused and weak strength",
|
||||
overrides: { password_strength: 2, reuse_count: 2 },
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "all three risk factors",
|
||||
overrides: {
|
||||
password_strength: 1,
|
||||
exposed_result: { type: "Found" as const, value: 10 },
|
||||
reuse_count: 3,
|
||||
},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
desc: "no risk factors",
|
||||
overrides: { reuse_count: undefined },
|
||||
expected: false,
|
||||
},
|
||||
])("should return $expected when $desc present", ({ overrides, expected }) => {
|
||||
const risk = createRisk(overrides);
|
||||
expect(isPasswordAtRisk(risk)).toBe(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
import type {
|
||||
CipherRiskResult,
|
||||
CipherRiskOptions,
|
||||
ExposedPasswordResult,
|
||||
PasswordReuseMap,
|
||||
CipherId,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserId, CipherId } from "../../types/guid";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
|
||||
export abstract class CipherRiskService {
|
||||
@@ -51,5 +49,21 @@ export abstract class CipherRiskService {
|
||||
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
|
||||
}
|
||||
|
||||
// Re-export SDK types for convenience
|
||||
export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap };
|
||||
/**
|
||||
* Evaluates if a password represented by a CipherRiskResult is considered at risk.
|
||||
*
|
||||
* A password is considered at risk if any of the following conditions are true:
|
||||
* - The password has been exposed in data breaches
|
||||
* - The password is reused across multiple ciphers
|
||||
* - The password has weak strength (password_strength < 3)
|
||||
*
|
||||
* @param risk - The CipherRiskResult to evaluate
|
||||
* @returns true if the password is at risk, false otherwise
|
||||
*/
|
||||
export function isPasswordAtRisk(risk: CipherRiskResult): boolean {
|
||||
return (
|
||||
(risk.exposed_result.type === "Found" && risk.exposed_result.value > 0) ||
|
||||
(risk.reuse_count ?? 1) > 1 ||
|
||||
risk.password_strength < 3
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AttachmentResponse } from "../response/attachment.response";
|
||||
|
||||
export class AttachmentData {
|
||||
id: string;
|
||||
url: string;
|
||||
fileName: string;
|
||||
key: string;
|
||||
size: string;
|
||||
sizeName: string;
|
||||
id?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
key?: string;
|
||||
size?: string;
|
||||
sizeName?: string;
|
||||
|
||||
constructor(response?: AttachmentResponse) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CardApi } from "../api/card.api";
|
||||
|
||||
export class CardData {
|
||||
cardholderName: string;
|
||||
brand: string;
|
||||
number: string;
|
||||
expMonth: string;
|
||||
expYear: string;
|
||||
code: string;
|
||||
cardholderName?: string;
|
||||
brand?: string;
|
||||
number?: string;
|
||||
expMonth?: string;
|
||||
expYear?: string;
|
||||
code?: string;
|
||||
|
||||
constructor(data?: CardApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { CipherRepromptType } from "../../enums/cipher-reprompt-type";
|
||||
@@ -17,18 +15,18 @@ import { SecureNoteData } from "./secure-note.data";
|
||||
import { SshKeyData } from "./ssh-key.data";
|
||||
|
||||
export class CipherData {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
folderId: string;
|
||||
edit: boolean;
|
||||
viewPassword: boolean;
|
||||
permissions: CipherPermissionsApi;
|
||||
organizationUseTotp: boolean;
|
||||
favorite: boolean;
|
||||
id: string = "";
|
||||
organizationId?: string;
|
||||
folderId?: string;
|
||||
edit: boolean = false;
|
||||
viewPassword: boolean = true;
|
||||
permissions?: CipherPermissionsApi;
|
||||
organizationUseTotp: boolean = false;
|
||||
favorite: boolean = false;
|
||||
revisionDate: string;
|
||||
type: CipherType;
|
||||
name: string;
|
||||
notes: string;
|
||||
type: CipherType = CipherType.Login;
|
||||
name: string = "";
|
||||
notes?: string;
|
||||
login?: LoginData;
|
||||
secureNote?: SecureNoteData;
|
||||
card?: CardData;
|
||||
@@ -39,13 +37,14 @@ export class CipherData {
|
||||
passwordHistory?: PasswordHistoryData[];
|
||||
collectionIds?: string[];
|
||||
creationDate: string;
|
||||
deletedDate: string | undefined;
|
||||
archivedDate: string | undefined;
|
||||
reprompt: CipherRepromptType;
|
||||
key: string;
|
||||
deletedDate?: string;
|
||||
archivedDate?: string;
|
||||
reprompt: CipherRepromptType = CipherRepromptType.None;
|
||||
key?: string;
|
||||
|
||||
constructor(response?: CipherResponse, collectionIds?: string[]) {
|
||||
if (response == null) {
|
||||
this.creationDate = this.revisionDate = new Date().toISOString();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,7 +100,9 @@ export class CipherData {
|
||||
|
||||
static fromJSON(obj: Jsonify<CipherData>) {
|
||||
const result = Object.assign(new CipherData(), obj);
|
||||
result.permissions = CipherPermissionsApi.fromJSON(obj.permissions);
|
||||
if (obj.permissions != null) {
|
||||
result.permissions = CipherPermissionsApi.fromJSON(obj.permissions);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Fido2CredentialApi } from "../api/fido2-credential.api";
|
||||
|
||||
export class Fido2CredentialData {
|
||||
credentialId: string;
|
||||
keyType: "public-key";
|
||||
keyAlgorithm: "ECDSA";
|
||||
keyCurve: "P-256";
|
||||
keyValue: string;
|
||||
rpId: string;
|
||||
userHandle: string;
|
||||
userName: string;
|
||||
counter: string;
|
||||
rpName: string;
|
||||
userDisplayName: string;
|
||||
discoverable: string;
|
||||
creationDate: string;
|
||||
credentialId!: string;
|
||||
keyType!: string;
|
||||
keyAlgorithm!: string;
|
||||
keyCurve!: string;
|
||||
keyValue!: string;
|
||||
rpId!: string;
|
||||
userHandle?: string;
|
||||
userName?: string;
|
||||
counter!: string;
|
||||
rpName?: string;
|
||||
userDisplayName?: string;
|
||||
discoverable!: string;
|
||||
creationDate!: string;
|
||||
|
||||
constructor(data?: Fido2CredentialApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { FieldType, LinkedIdType } from "../../enums";
|
||||
import { FieldApi } from "../api/field.api";
|
||||
|
||||
export class FieldData {
|
||||
type: FieldType;
|
||||
name: string;
|
||||
value: string;
|
||||
linkedId: LinkedIdType | null;
|
||||
type: FieldType = FieldType.Text;
|
||||
name?: string;
|
||||
value?: string;
|
||||
linkedId?: LinkedIdType;
|
||||
|
||||
constructor(response?: FieldApi) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { IdentityApi } from "../api/identity.api";
|
||||
|
||||
export class IdentityData {
|
||||
title: string;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
address1: string;
|
||||
address2: string;
|
||||
address3: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
company: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
ssn: string;
|
||||
username: string;
|
||||
passportNumber: string;
|
||||
licenseNumber: string;
|
||||
title?: string;
|
||||
firstName?: string;
|
||||
middleName?: string;
|
||||
lastName?: string;
|
||||
address1?: string;
|
||||
address2?: string;
|
||||
address3?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
company?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
ssn?: string;
|
||||
username?: string;
|
||||
passportNumber?: string;
|
||||
licenseNumber?: string;
|
||||
|
||||
constructor(data?: IdentityApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { UriMatchStrategySetting } from "../../../models/domain/domain-service";
|
||||
import { LoginUriApi } from "../api/login-uri.api";
|
||||
|
||||
export class LoginUriData {
|
||||
uri: string;
|
||||
uriChecksum: string;
|
||||
match: UriMatchStrategySetting = null;
|
||||
uri?: string;
|
||||
uriChecksum?: string;
|
||||
match?: UriMatchStrategySetting;
|
||||
|
||||
constructor(data?: LoginUriApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { LoginApi } from "../api/login.api";
|
||||
|
||||
import { Fido2CredentialData } from "./fido2-credential.data";
|
||||
import { LoginUriData } from "./login-uri.data";
|
||||
|
||||
export class LoginData {
|
||||
uris: LoginUriData[];
|
||||
username: string;
|
||||
password: string;
|
||||
passwordRevisionDate: string;
|
||||
totp: string;
|
||||
autofillOnPageLoad: boolean;
|
||||
uris?: LoginUriData[];
|
||||
username?: string;
|
||||
password?: string;
|
||||
passwordRevisionDate?: string;
|
||||
totp?: string;
|
||||
autofillOnPageLoad?: boolean;
|
||||
fido2Credentials?: Fido2CredentialData[];
|
||||
|
||||
constructor(data?: LoginApi) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { PasswordHistoryResponse } from "../response/password-history.response";
|
||||
|
||||
export class PasswordHistoryData {
|
||||
password: string;
|
||||
lastUsedDate: string;
|
||||
password!: string;
|
||||
lastUsedDate!: string;
|
||||
|
||||
constructor(response?: PasswordHistoryResponse) {
|
||||
if (response == null) {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SecureNoteType } from "../../enums";
|
||||
import { SecureNoteApi } from "../api/secure-note.api";
|
||||
|
||||
export class SecureNoteData {
|
||||
type: SecureNoteType;
|
||||
type: SecureNoteType = SecureNoteType.Generic;
|
||||
|
||||
constructor(data?: SecureNoteApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
|
||||
export class SshKeyData {
|
||||
privateKey: string;
|
||||
publicKey: string;
|
||||
keyFingerprint: string;
|
||||
privateKey!: string;
|
||||
publicKey!: string;
|
||||
keyFingerprint!: string;
|
||||
|
||||
constructor(data?: SshKeyApi) {
|
||||
if (data == null) {
|
||||
|
||||
@@ -39,6 +39,12 @@ describe("Attachment", () => {
|
||||
key: undefined,
|
||||
fileName: undefined,
|
||||
});
|
||||
expect(data.id).toBeUndefined();
|
||||
expect(data.url).toBeUndefined();
|
||||
expect(data.fileName).toBeUndefined();
|
||||
expect(data.key).toBeUndefined();
|
||||
expect(data.size).toBeUndefined();
|
||||
expect(data.sizeName).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -29,6 +29,13 @@ describe("Card", () => {
|
||||
expYear: undefined,
|
||||
code: undefined,
|
||||
});
|
||||
|
||||
expect(data.cardholderName).toBeUndefined();
|
||||
expect(data.brand).toBeUndefined();
|
||||
expect(data.number).toBeUndefined();
|
||||
expect(data.expMonth).toBeUndefined();
|
||||
expect(data.expYear).toBeUndefined();
|
||||
expect(data.code).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -44,22 +44,22 @@ describe("Cipher DTO", () => {
|
||||
const data = new CipherData();
|
||||
const cipher = new Cipher(data);
|
||||
|
||||
expect(cipher.id).toBeUndefined();
|
||||
expect(cipher.id).toEqual("");
|
||||
expect(cipher.organizationId).toBeUndefined();
|
||||
expect(cipher.folderId).toBeUndefined();
|
||||
expect(cipher.name).toBeInstanceOf(EncString);
|
||||
expect(cipher.notes).toBeUndefined();
|
||||
expect(cipher.type).toBeUndefined();
|
||||
expect(cipher.favorite).toBeUndefined();
|
||||
expect(cipher.organizationUseTotp).toBeUndefined();
|
||||
expect(cipher.edit).toBeUndefined();
|
||||
expect(cipher.viewPassword).toBeUndefined();
|
||||
expect(cipher.type).toEqual(CipherType.Login);
|
||||
expect(cipher.favorite).toEqual(false);
|
||||
expect(cipher.organizationUseTotp).toEqual(false);
|
||||
expect(cipher.edit).toEqual(false);
|
||||
expect(cipher.viewPassword).toEqual(true);
|
||||
expect(cipher.revisionDate).toBeInstanceOf(Date);
|
||||
expect(cipher.collectionIds).toEqual([]);
|
||||
expect(cipher.localData).toBeUndefined();
|
||||
expect(cipher.creationDate).toBeInstanceOf(Date);
|
||||
expect(cipher.deletedDate).toBeUndefined();
|
||||
expect(cipher.reprompt).toBeUndefined();
|
||||
expect(cipher.reprompt).toEqual(CipherRepromptType.None);
|
||||
expect(cipher.attachments).toBeUndefined();
|
||||
expect(cipher.fields).toBeUndefined();
|
||||
expect(cipher.passwordHistory).toBeUndefined();
|
||||
@@ -836,6 +836,38 @@ describe("Cipher DTO", () => {
|
||||
expect(actual).toBeInstanceOf(Cipher);
|
||||
});
|
||||
|
||||
it("handles null permissions correctly without calling CipherPermissionsApi constructor", () => {
|
||||
const spy = jest.spyOn(CipherPermissionsApi.prototype, "constructor" as any);
|
||||
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
|
||||
const actual = Cipher.fromJSON({
|
||||
name: "myName",
|
||||
revisionDate: revisionDate.toISOString(),
|
||||
permissions: null,
|
||||
} as Jsonify<Cipher>);
|
||||
|
||||
expect(actual.permissions).toBeUndefined();
|
||||
expect(actual).toBeInstanceOf(Cipher);
|
||||
// Verify that CipherPermissionsApi constructor was not called for null permissions
|
||||
expect(spy).not.toHaveBeenCalledWith(null);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it("calls CipherPermissionsApi constructor when permissions are provided", () => {
|
||||
const spy = jest.spyOn(CipherPermissionsApi.prototype, "constructor" as any);
|
||||
const revisionDate = new Date("2022-08-04T01:06:40.441Z");
|
||||
const permissionsObj = { delete: true, restore: false };
|
||||
const actual = Cipher.fromJSON({
|
||||
name: "myName",
|
||||
revisionDate: revisionDate.toISOString(),
|
||||
permissions: permissionsObj,
|
||||
} as Jsonify<Cipher>);
|
||||
|
||||
expect(actual.permissions).toBeInstanceOf(CipherPermissionsApi);
|
||||
expect(actual.permissions.delete).toBe(true);
|
||||
expect(actual.permissions.restore).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test.each([
|
||||
// Test description, CipherType, expected output
|
||||
["LoginView", CipherType.Login, { login: "myLogin_fromJSON" }],
|
||||
@@ -1056,6 +1088,7 @@ describe("Cipher DTO", () => {
|
||||
card: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
data: undefined,
|
||||
favorite: false,
|
||||
reprompt: SdkCipherRepromptType.None,
|
||||
organizationUseTotp: true,
|
||||
|
||||
@@ -421,6 +421,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
card: undefined,
|
||||
secureNote: undefined,
|
||||
sshKey: undefined,
|
||||
data: undefined,
|
||||
};
|
||||
|
||||
switch (this.type) {
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("Field", () => {
|
||||
const field = new Field(data);
|
||||
|
||||
expect(field).toEqual({
|
||||
type: undefined,
|
||||
type: FieldType.Text,
|
||||
name: undefined,
|
||||
value: undefined,
|
||||
linkedId: undefined,
|
||||
|
||||
@@ -53,6 +53,27 @@ describe("Identity", () => {
|
||||
title: undefined,
|
||||
username: undefined,
|
||||
});
|
||||
|
||||
expect(data).toEqual({
|
||||
title: undefined,
|
||||
firstName: undefined,
|
||||
middleName: undefined,
|
||||
lastName: undefined,
|
||||
address1: undefined,
|
||||
address2: undefined,
|
||||
address3: undefined,
|
||||
city: undefined,
|
||||
state: undefined,
|
||||
postalCode: undefined,
|
||||
country: undefined,
|
||||
company: undefined,
|
||||
email: undefined,
|
||||
phone: undefined,
|
||||
ssn: undefined,
|
||||
username: undefined,
|
||||
passportNumber: undefined,
|
||||
licenseNumber: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mockEnc, mockFromJson } from "../../../../spec";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { UriMatchStrategy } from "../../../models/domain/domain-service";
|
||||
import { LoginUriApi } from "../api/login-uri.api";
|
||||
import { LoginUriData } from "../data/login-uri.data";
|
||||
|
||||
import { LoginUri } from "./login-uri";
|
||||
@@ -31,6 +32,9 @@ describe("LoginUri", () => {
|
||||
uri: undefined,
|
||||
uriChecksum: undefined,
|
||||
});
|
||||
expect(data.uri).toBeUndefined();
|
||||
expect(data.uriChecksum).toBeUndefined();
|
||||
expect(data.match).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
@@ -61,6 +65,23 @@ describe("LoginUri", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("handle null match", () => {
|
||||
const apiData = Object.assign(new LoginUriApi(), {
|
||||
uri: "testUri",
|
||||
uriChecksum: "testChecksum",
|
||||
match: null,
|
||||
});
|
||||
|
||||
const loginUriData = new LoginUriData(apiData);
|
||||
|
||||
// The data model stores it as-is (null or undefined)
|
||||
expect(loginUriData.match).toBeNull();
|
||||
|
||||
// But the domain model converts null to undefined
|
||||
const loginUri = new LoginUri(loginUriData);
|
||||
expect(loginUri.match).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("validateChecksum", () => {
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
|
||||
@@ -118,7 +139,7 @@ describe("LoginUri", () => {
|
||||
});
|
||||
|
||||
describe("SDK Login Uri Mapping", () => {
|
||||
it("should map to SDK login uri", () => {
|
||||
it("maps to SDK login uri", () => {
|
||||
const loginUri = new LoginUri(data);
|
||||
const sdkLoginUri = loginUri.toSdkLoginUri();
|
||||
|
||||
|
||||
@@ -25,6 +25,14 @@ describe("Login DTO", () => {
|
||||
password: undefined,
|
||||
totp: undefined,
|
||||
});
|
||||
|
||||
expect(data.username).toBeUndefined();
|
||||
expect(data.password).toBeUndefined();
|
||||
expect(data.passwordRevisionDate).toBeUndefined();
|
||||
expect(data.totp).toBeUndefined();
|
||||
expect(data.autofillOnPageLoad).toBeUndefined();
|
||||
expect(data.uris).toBeUndefined();
|
||||
expect(data.fido2Credentials).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert from full LoginData", () => {
|
||||
|
||||
@@ -111,10 +111,7 @@ export class Login extends Domain {
|
||||
});
|
||||
|
||||
if (this.uris != null && this.uris.length > 0) {
|
||||
l.uris = [];
|
||||
this.uris.forEach((u) => {
|
||||
l.uris.push(u.toLoginUriData());
|
||||
});
|
||||
l.uris = this.uris.map((u) => u.toLoginUriData());
|
||||
}
|
||||
|
||||
if (this.fido2Credentials != null && this.fido2Credentials.length > 0) {
|
||||
|
||||
@@ -20,6 +20,9 @@ describe("Password", () => {
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password.password).toBeInstanceOf(EncString);
|
||||
expect(password.lastUsedDate).toBeInstanceOf(Date);
|
||||
|
||||
expect(data.password).toBeUndefined();
|
||||
expect(data.lastUsedDate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
@@ -83,4 +86,47 @@ describe("Password", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkPasswordHistory", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("creates Password from SDK object", () => {
|
||||
const sdkPasswordHistory = {
|
||||
password: "2.encPassword|encryptedData" as EncryptedString,
|
||||
lastUsedDate: "2022-01-31T12:00:00.000Z",
|
||||
};
|
||||
|
||||
const password = Password.fromSdkPasswordHistory(sdkPasswordHistory);
|
||||
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password?.password).toBeInstanceOf(EncString);
|
||||
expect(password?.password.encryptedString).toBe("2.encPassword|encryptedData");
|
||||
expect(password?.lastUsedDate).toEqual(new Date("2022-01-31T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("returns undefined for null input", () => {
|
||||
const result = Password.fromSdkPasswordHistory(null as any);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for undefined input", () => {
|
||||
const result = Password.fromSdkPasswordHistory(undefined);
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles empty SDK object", () => {
|
||||
const sdkPasswordHistory = {
|
||||
password: "" as EncryptedString,
|
||||
lastUsedDate: "",
|
||||
};
|
||||
|
||||
const password = Password.fromSdkPasswordHistory(sdkPasswordHistory);
|
||||
|
||||
expect(password).toBeInstanceOf(Password);
|
||||
expect(password?.password).toBeInstanceOf(EncString);
|
||||
expect(password?.lastUsedDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,22 +16,27 @@ describe("SecureNote", () => {
|
||||
const data = new SecureNoteData();
|
||||
const secureNote = new SecureNote(data);
|
||||
|
||||
expect(secureNote).toEqual({
|
||||
type: undefined,
|
||||
});
|
||||
expect(data).toBeDefined();
|
||||
expect(secureNote).toEqual({ type: SecureNoteType.Generic });
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Convert from undefined", () => {
|
||||
const data = new SecureNoteData(undefined);
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Convert", () => {
|
||||
const secureNote = new SecureNote(data);
|
||||
|
||||
expect(secureNote).toEqual({
|
||||
type: 0,
|
||||
});
|
||||
expect(secureNote).toEqual({ type: 0 });
|
||||
expect(data.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("toSecureNoteData", () => {
|
||||
const secureNote = new SecureNote(data);
|
||||
expect(secureNote.toSecureNoteData()).toEqual(data);
|
||||
expect(secureNote.toSecureNoteData().type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("Decrypt", async () => {
|
||||
@@ -49,6 +54,14 @@ describe("SecureNote", () => {
|
||||
it("returns undefined if object is null", () => {
|
||||
expect(SecureNote.fromJSON(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SecureNote instance from JSON object", () => {
|
||||
const jsonObj = { type: SecureNoteType.Generic };
|
||||
const result = SecureNote.fromJSON(jsonObj);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSdkSecureNote", () => {
|
||||
@@ -63,4 +76,71 @@ describe("SecureNote", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkSecureNote", () => {
|
||||
it("returns undefined when null is provided", () => {
|
||||
const result = SecureNote.fromSdkSecureNote(null);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when undefined is provided", () => {
|
||||
const result = SecureNote.fromSdkSecureNote(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SecureNote with Generic type from SDK object", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBe(SecureNoteType.Generic);
|
||||
});
|
||||
|
||||
it("preserves the type value from SDK object", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result.type).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a new SecureNote instance", () => {
|
||||
const sdkSecureNote = {
|
||||
type: SecureNoteType.Generic,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).not.toBe(sdkSecureNote);
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
});
|
||||
|
||||
it("handles SDK object with undefined type", () => {
|
||||
const sdkSecureNote = {
|
||||
type: undefined as SecureNoteType,
|
||||
};
|
||||
|
||||
const result = SecureNote.fromSdkSecureNote(sdkSecureNote);
|
||||
|
||||
expect(result).toBeInstanceOf(SecureNote);
|
||||
expect(result.type).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns symmetric with toSdkSecureNote", () => {
|
||||
const original = new SecureNote();
|
||||
original.type = SecureNoteType.Generic;
|
||||
|
||||
const sdkFormat = original.toSdkSecureNote();
|
||||
const reconstructed = SecureNote.fromSdkSecureNote(sdkFormat);
|
||||
|
||||
expect(reconstructed.type).toBe(original.type);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { EncString as SdkEncString, SshKey as SdkSshKey } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { mockEnc } from "../../../../spec";
|
||||
import { SshKeyApi } from "../api/ssh-key.api";
|
||||
@@ -37,6 +38,9 @@ describe("Sshkey", () => {
|
||||
expect(sshKey.privateKey).toBeInstanceOf(EncString);
|
||||
expect(sshKey.publicKey).toBeInstanceOf(EncString);
|
||||
expect(sshKey.keyFingerprint).toBeInstanceOf(EncString);
|
||||
expect(data.privateKey).toBeUndefined();
|
||||
expect(data.publicKey).toBeUndefined();
|
||||
expect(data.keyFingerprint).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toSshKeyData", () => {
|
||||
@@ -64,6 +68,21 @@ describe("Sshkey", () => {
|
||||
it("returns undefined if object is null", () => {
|
||||
expect(SshKey.fromJSON(null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SshKey instance from JSON object", () => {
|
||||
const jsonObj = {
|
||||
privateKey: "2.privateKey|encryptedData",
|
||||
publicKey: "2.publicKey|encryptedData",
|
||||
keyFingerprint: "2.keyFingerprint|encryptedData",
|
||||
};
|
||||
|
||||
const result = SshKey.fromJSON(jsonObj);
|
||||
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
expect(result.privateKey).toBeDefined();
|
||||
expect(result.publicKey).toBeDefined();
|
||||
expect(result.keyFingerprint).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSdkSshKey", () => {
|
||||
@@ -78,4 +97,58 @@ describe("Sshkey", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromSdkSshKey", () => {
|
||||
it("returns undefined when null is provided", () => {
|
||||
const result = SshKey.fromSdkSshKey(null);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when undefined is provided", () => {
|
||||
const result = SshKey.fromSdkSshKey(undefined);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("creates SshKey from SDK object", () => {
|
||||
const sdkSshKey: SdkSshKey = {
|
||||
privateKey: "2.privateKey|encryptedData" as SdkEncString,
|
||||
publicKey: "2.publicKey|encryptedData" as SdkEncString,
|
||||
fingerprint: "2.keyFingerprint|encryptedData" as SdkEncString,
|
||||
};
|
||||
|
||||
const result = SshKey.fromSdkSshKey(sdkSshKey);
|
||||
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
expect(result.privateKey).toBeDefined();
|
||||
expect(result.publicKey).toBeDefined();
|
||||
expect(result.keyFingerprint).toBeDefined();
|
||||
});
|
||||
|
||||
it("creates a new SshKey instance", () => {
|
||||
const sdkSshKey: SdkSshKey = {
|
||||
privateKey: "2.privateKey|encryptedData" as SdkEncString,
|
||||
publicKey: "2.publicKey|encryptedData" as SdkEncString,
|
||||
fingerprint: "2.keyFingerprint|encryptedData" as SdkEncString,
|
||||
};
|
||||
|
||||
const result = SshKey.fromSdkSshKey(sdkSshKey);
|
||||
|
||||
expect(result).not.toBe(sdkSshKey);
|
||||
expect(result).toBeInstanceOf(SshKey);
|
||||
});
|
||||
|
||||
it("is symmetric with toSdkSshKey", () => {
|
||||
const original = new SshKey(data);
|
||||
const sdkFormat = original.toSdkSshKey();
|
||||
const reconstructed = SshKey.fromSdkSshKey(sdkFormat);
|
||||
|
||||
expect(reconstructed.privateKey.encryptedString).toBe(original.privateKey.encryptedString);
|
||||
expect(reconstructed.publicKey.encryptedString).toBe(original.publicKey.encryptedString);
|
||||
expect(reconstructed.keyFingerprint.encryptedString).toBe(
|
||||
original.keyFingerprint.encryptedString,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,6 +113,12 @@ export class CipherView implements View, InitializerMetadata {
|
||||
return this.passwordHistory && this.passwordHistory.length > 0;
|
||||
}
|
||||
|
||||
get hasLoginPassword(): boolean {
|
||||
return (
|
||||
this.type === CipherType.Login && this.login?.password != null && this.login.password !== ""
|
||||
);
|
||||
}
|
||||
|
||||
get hasAttachments(): boolean {
|
||||
return !!this.attachments && this.attachments.length > 0;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { BehaviorSubject, Observable } from "rxjs";
|
||||
|
||||
import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal";
|
||||
import type { CipherRiskOptions, CipherRiskResult } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserId, CipherId } from "../../types/guid";
|
||||
import { CipherService } from "../abstractions/cipher.service";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
@@ -19,9 +19,9 @@ describe("DefaultCipherRiskService", () => {
|
||||
let mockCipherService: jest.Mocked<CipherService>;
|
||||
|
||||
const mockUserId = "test-user-id" as UserId;
|
||||
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
|
||||
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3";
|
||||
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4";
|
||||
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2" as CipherId;
|
||||
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3" as CipherId;
|
||||
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4" as CipherId;
|
||||
|
||||
beforeEach(() => {
|
||||
sdkService = new MockSdkService();
|
||||
@@ -534,5 +534,56 @@ describe("DefaultCipherRiskService", () => {
|
||||
// Verify password_reuse_map was called twice (fresh computation each time)
|
||||
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should wait for a decrypted vault before computing risk", async () => {
|
||||
const mockClient = sdkService.simulate.userLogin(mockUserId);
|
||||
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
|
||||
|
||||
const cipher = new CipherView();
|
||||
cipher.id = mockCipherId1;
|
||||
cipher.type = CipherType.Login;
|
||||
cipher.login = new LoginView();
|
||||
cipher.login.password = "password1";
|
||||
|
||||
// Simulate the observable emitting null (undecrypted vault) first, then the decrypted ciphers
|
||||
const cipherViewsSubject = new BehaviorSubject<CipherView[] | null>(null);
|
||||
mockCipherService.cipherViews$.mockReturnValue(
|
||||
cipherViewsSubject as Observable<CipherView[]>,
|
||||
);
|
||||
|
||||
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
|
||||
mockCipherRiskClient.compute_risk.mockResolvedValue([
|
||||
{
|
||||
id: mockCipherId1 as any,
|
||||
password_strength: 4,
|
||||
exposed_result: { type: "NotChecked" },
|
||||
reuse_count: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// Initiate the async call but don't await yet
|
||||
const computePromise = cipherRiskService.computeCipherRiskForUser(
|
||||
asUuid<CipherId>(mockCipherId1),
|
||||
mockUserId,
|
||||
true,
|
||||
);
|
||||
|
||||
// Simulate a tick to allow the service to process the null emission
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
// Now emit the actual decrypted ciphers
|
||||
cipherViewsSubject.next([cipher]);
|
||||
|
||||
const result = await computePromise;
|
||||
|
||||
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ password: "password1" })],
|
||||
{
|
||||
passwordMap: expect.any(Object),
|
||||
checkExposed: true,
|
||||
},
|
||||
);
|
||||
expect(result).toEqual(expect.objectContaining({ id: expect.anything() }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import {
|
||||
CipherLoginDetails,
|
||||
CipherRiskOptions,
|
||||
PasswordReuseMap,
|
||||
CipherId,
|
||||
CipherRiskResult,
|
||||
CipherId as SdkCipherId,
|
||||
} from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserId, CipherId } from "../../types/guid";
|
||||
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
|
||||
import { CipherType } from "../enums/cipher-type";
|
||||
import { CipherView } from "../models/view/cipher.view";
|
||||
@@ -52,7 +53,9 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
checkExposed: boolean = true,
|
||||
): Promise<CipherRiskResult> {
|
||||
// Get all ciphers for the user
|
||||
const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId));
|
||||
const allCiphers = await firstValueFrom(
|
||||
this.cipherService.cipherViews$(userId).pipe(filterOutNullish()),
|
||||
);
|
||||
|
||||
// Find the specific cipher
|
||||
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
|
||||
@@ -106,7 +109,7 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
|
||||
.map(
|
||||
(cipher) =>
|
||||
({
|
||||
id: asUuid<CipherId>(cipher.id),
|
||||
id: asUuid<SdkCipherId>(cipher.id),
|
||||
password: cipher.login.password!,
|
||||
username: cipher.login.username,
|
||||
}) satisfies CipherLoginDetails,
|
||||
|
||||
@@ -51,10 +51,10 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
{
|
||||
useRiskInsights: true,
|
||||
useAccessIntelligence: true,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -70,10 +70,10 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -91,7 +91,7 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: true,
|
||||
useAccessIntelligence: true,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -101,7 +101,7 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -163,7 +163,7 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: true,
|
||||
useAccessIntelligence: true,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
@@ -173,7 +173,7 @@ describe("Default task service", () => {
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
useAccessIntelligence: false,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user