1
0
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:
Bernd Schoolmann
2025-11-12 15:22:58 +01:00
730 changed files with 23463 additions and 8142 deletions

View File

@@ -16,6 +16,6 @@ export const AUTO_CONFIRM_STATE = UserKeyDefinition.record<AutoConfirmState>(
"autoConfirm",
{
deserializer: (autoConfirmState) => autoConfirmState,
clearOn: ["logout"],
clearOn: [],
},
);

View File

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

View File

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

View File

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

View File

@@ -1 +1,2 @@
export * from "./premium.component";
export * from "./premium-upgrade-dialog/premium-upgrade-dialog.component";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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) &mdash; authenticate with a one-time access code
3. [Login with Single Sign-On](https://bitwarden.com/help/about-sso/) &mdash; 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/) &mdash; 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"` &rarr; user stays on the `LoginComponent` and enters a Master Password
- `"Log in with device"` &rarr; navigates user to `LoginViaAuthRequestComponent`
- `"Use single sign-on"` &rarr; navigates user to `SsoComponent`
- `"Log in with passkey"` &rarr; 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.
> &nbsp;
> - 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` &mdash; 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` &rarr; 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` &rarr; 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` &rarr; 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 &mdash; 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>
![A Diagram of our Authentication Flows](./overview-of-authentication.svg)

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 215 KiB

View File

@@ -119,7 +119,7 @@ describe("PasswordLoginStrategy", () => {
sub: userId,
});
loginStrategyService.makePreloginKey.mockResolvedValue(masterKey);
loginStrategyService.makePasswordPreLoginMasterKey.mockResolvedValue(masterKey);
keyService.hashMasterKey
.calledWith(masterPassword, expect.anything(), undefined)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,7 @@ describe("ORGANIZATIONS state", () => {
allowAdminAccessToAllCollectionItems: false,
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,
useRiskInsights: false,
useAccessIntelligence: false,
useOrganizationDomains: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,

View File

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

View File

@@ -79,7 +79,7 @@ describe("Organization", () => {
limitItemDeletion: false,
allowAdminAccessToAllCollectionItems: true,
userIsManagedByOrganization: false,
useRiskInsights: false,
useAccessIntelligence: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,
ssoEnabled: false,

View File

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

View File

@@ -1,7 +1,4 @@
import { PolicyType } from "../../enums";
export type PolicyRequest = {
type: PolicyType;
enabled: boolean;
data: any;
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
export abstract class VaultTimeoutService {
abstract checkVaultTimeout(): Promise<void>;
abstract lock(userId?: string): Promise<void>;
}

View File

@@ -8,3 +8,4 @@ export {
VaultTimeoutOption,
VaultTimeoutStringType,
} from "./types/vault-timeout.type";
export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type";

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
export interface MaximumVaultTimeoutPolicyData {
minutes: number;
action?: VaultTimeoutAction;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -421,6 +421,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
card: undefined,
secureNote: undefined,
sshKey: undefined,
data: undefined,
};
switch (this.type) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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