1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 18:09:17 +00:00

Merge branch 'main' into km/auto-kdf

This commit is contained in:
Bernd Schoolmann
2025-12-01 14:08:37 +01:00
1027 changed files with 43220 additions and 14561 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

@@ -24,6 +24,8 @@ import { KeyService } from "@bitwarden/key-management";
selector: "app-user-verification",
standalone: false,
})
// FIXME(https://bitwarden.atlassian.net/browse/PM-28232): Use Directive suffix
// eslint-disable-next-line @angular-eslint/directive-class-suffix
export class UserVerificationComponent implements ControlValueAccessor, OnInit, OnDestroy {
private _invalidSecret = false;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals

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

@@ -182,7 +182,10 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
if (userKey == null) {
masterKeyEncryptedUserKey = await this.keyService.makeUserKey(masterKey);
} else {
masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(masterKey);
masterKeyEncryptedUserKey = await this.keyService.encryptUserKeyWithMasterKey(
masterKey,
userKey,
);
}
return masterKeyEncryptedUserKey;
@@ -195,10 +198,13 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
userId: UserId,
) {
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
userDecryptionOpts.hasMasterPassword = true;
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
userDecryptionOpts,
);
await this.kdfConfigService.setKdfConfig(userId, kdfConfig);
await this.masterPasswordService.setMasterKey(masterKey, userId);
await this.keyService.setUserKey(masterKeyEncryptedUserKey[0], userId);

View File

@@ -149,7 +149,9 @@ describe("DefaultSetInitialPasswordService", () => {
userDecryptionOptions = new UserDecryptionOptions({ hasMasterPassword: true });
userDecryptionOptionsSubject = new BehaviorSubject(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
);
setPasswordRequest = new SetPasswordRequest(
credentials.newServerMasterKeyHash,
@@ -362,7 +364,8 @@ describe("DefaultSetInitialPasswordService", () => {
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
userDecryptionOptions,
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);
@@ -560,7 +563,8 @@ describe("DefaultSetInitialPasswordService", () => {
// Assert
expect(masterPasswordApiService.setPassword).toHaveBeenCalledWith(setPasswordRequest);
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
userDecryptionOptions,
);
expect(kdfConfigService.setKdfConfig).toHaveBeenCalledWith(userId, credentials.kdfConfig);

View File

@@ -36,7 +36,7 @@
<!-- 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-bold tw-leading-none tw-m-0">{{
<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">

View File

@@ -158,12 +158,7 @@ describe("PremiumUpgradeDialogComponent", () => {
});
describe("upgrade()", () => {
it("should launch URI with query parameter for cloud-hosted environments", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://vault.bitwarden.com",
getRegion: () => Region.US,
} as any);
it("should launch URI with query parameter", async () => {
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
@@ -171,34 +166,6 @@ describe("PremiumUpgradeDialogComponent", () => {
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
it("should launch URI without query parameter for self-hosted environments", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://self-hosted.example.com",
getRegion: () => Region.SelfHosted,
} as any);
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
"https://self-hosted.example.com/#/settings/subscription/premium",
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
it("should launch URI with query parameter for EU cloud region", async () => {
mockEnvironmentService.environment$ = of({
getWebVaultUrl: () => "https://vault.bitwarden.eu",
getRegion: () => Region.EU,
} as any);
await component["upgrade"]();
expect(mockPlatformUtilsService.launchUri).toHaveBeenCalledWith(
"https://vault.bitwarden.eu/#/settings/subscription/premium?callToAction=upgradeToPremium",
);
expect(mockDialogRef.close).toHaveBeenCalled();
});
});
it("should close dialog when close button clicked", () => {

View File

@@ -11,15 +11,13 @@ import {
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
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,
CenterPositionStrategy,
DialogModule,
DialogRef,
DialogService,
@@ -82,10 +80,9 @@ export class PremiumUpgradeDialogComponent {
protected async upgrade(): Promise<void> {
const environment = await firstValueFrom(this.environmentService.environment$);
let vaultUrl = environment.getWebVaultUrl() + "/#/settings/subscription/premium";
if (environment.getRegion() !== Region.SelfHosted) {
vaultUrl += "?callToAction=upgradeToPremium";
}
const vaultUrl =
environment.getWebVaultUrl() +
"/#/settings/subscription/premium?callToAction=upgradeToPremium";
this.platformUtilsService.launchUri(vaultUrl);
this.dialogRef.close();
}
@@ -118,6 +115,8 @@ export class PremiumUpgradeDialogComponent {
* @returns A dialog reference object
*/
static open(dialogService: DialogService): DialogRef<PremiumUpgradeDialogComponent> {
return dialogService.open(PremiumUpgradeDialogComponent);
return dialogService.open(PremiumUpgradeDialogComponent, {
positionStrategy: new CenterPositionStrategy(),
});
}
}

View File

@@ -45,6 +45,8 @@ export function _cipherListVirtualScrollStrategyFactory(cipherListDir: CipherLis
},
],
})
// FIXME(https://bitwarden.atlassian.net/browse/PM-28232): Use Directive suffix
// eslint-disable-next-line @angular-eslint/directive-class-suffix
export class CipherListVirtualScroll extends CdkFixedSizeVirtualScroll {
_scrollStrategy: CipherListVirtualScrollStrategy;

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,
@@ -101,7 +103,6 @@ import { MasterPasswordApiService as MasterPasswordApiServiceAbstraction } from
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService as TwoFactorServiceAbstraction } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { UserVerificationApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification-api.service.abstraction";
import { UserVerificationService as UserVerificationServiceAbstraction } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { WebAuthnLoginApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-api.service.abstraction";
@@ -124,13 +125,17 @@ import { OrganizationInviteService } from "@bitwarden/common/auth/services/organ
import { PasswordResetEnrollmentServiceImplementation } from "@bitwarden/common/auth/services/password-reset-enrollment.service.implementation";
import { SsoLoginService } from "@bitwarden/common/auth/services/sso-login.service";
import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-api.service";
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import { TwoFactorApiService, DefaultTwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import {
TwoFactorApiService,
DefaultTwoFactorApiService,
TwoFactorService,
DefaultTwoFactorService,
} from "@bitwarden/common/auth/two-factor";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
@@ -162,6 +167,7 @@ import { OrganizationSponsorshipApiService } from "@bitwarden/common/billing/ser
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,
@@ -181,7 +187,9 @@ import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kd
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service";
import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service";
import { DefaultRotateableKeySetService } from "@bitwarden/common/key-management/keys/services/default-rotateable-key-set.service";
import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service";
import {
InternalMasterPasswordServiceAbstraction,
@@ -222,9 +230,11 @@ 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";
import { IpcSessionRepository } from "@bitwarden/common/platform/ipc";
import { Message, MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
// eslint-disable-next-line no-restricted-imports -- Used for dependency injection
import { SubjectMessageSender } from "@bitwarden/common/platform/messaging/internal";
@@ -285,6 +295,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";
@@ -306,6 +317,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";
@@ -407,7 +419,6 @@ import {
HTTP_OPERATIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
LOCALES_DIRECTORY,
LOCKED_CALLBACK,
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
OBSERVABLE_DISK_STORAGE,
@@ -463,10 +474,6 @@ const safeProviders: SafeProvider[] = [
},
deps: [MessagingServiceAbstraction],
}),
safeProvider({
provide: LOCKED_CALLBACK,
useValue: null,
}),
safeProvider({
provide: LOG_MAC_FAILURES,
useValue: true,
@@ -546,7 +553,7 @@ const safeProviders: SafeProvider[] = [
KeyConnectorServiceAbstraction,
EnvironmentService,
StateServiceAbstraction,
TwoFactorServiceAbstraction,
TwoFactorService,
I18nServiceAbstraction,
EncryptService,
PasswordStrengthServiceAbstraction,
@@ -628,6 +635,11 @@ const safeProviders: SafeProvider[] = [
MessagingServiceAbstraction,
],
}),
safeProvider({
provide: CipherRiskService,
useClass: DefaultCipherRiskService,
deps: [SdkService, CipherServiceAbstraction],
}),
safeProvider({
provide: InternalFolderService,
useClass: FolderService,
@@ -695,7 +707,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: InternalUserDecryptionOptionsServiceAbstraction,
useClass: UserDecryptionOptionsService,
deps: [StateProvider],
deps: [SingleUserStateProvider],
}),
safeProvider({
provide: UserDecryptionOptionsServiceAbstraction,
@@ -905,22 +917,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,
],
}),
@@ -1112,7 +1114,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: MasterPasswordUnlockService,
useClass: DefaultMasterPasswordUnlockService,
deps: [InternalMasterPasswordServiceAbstraction, KeyService],
deps: [InternalMasterPasswordServiceAbstraction, KeyService, LogService],
}),
safeProvider({
provide: KeyConnectorServiceAbstraction,
@@ -1189,9 +1191,14 @@ const safeProviders: SafeProvider[] = [
deps: [StateProvider],
}),
safeProvider({
provide: TwoFactorServiceAbstraction,
useClass: TwoFactorService,
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction, GlobalStateProvider],
provide: TwoFactorService,
useClass: DefaultTwoFactorService,
deps: [
I18nServiceAbstraction,
PlatformUtilsServiceAbstraction,
GlobalStateProvider,
TwoFactorApiService,
],
}),
safeProvider({
provide: FormValidationErrorsServiceAbstraction,
@@ -1308,6 +1315,7 @@ const safeProviders: SafeProvider[] = [
UserDecryptionOptionsServiceAbstraction,
LogService,
ConfigService,
AccountServiceAbstraction,
],
}),
safeProvider({
@@ -1475,7 +1483,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: OrganizationMetadataServiceAbstraction,
useClass: DefaultOrganizationMetadataService,
deps: [BillingApiServiceAbstraction, ConfigService],
deps: [BillingApiServiceAbstraction, ConfigService, PlatformUtilsServiceAbstraction],
}),
safeProvider({
provide: BillingAccountProfileStateService,
@@ -1757,6 +1765,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,
@@ -1767,11 +1796,21 @@ const safeProviders: SafeProvider[] = [
ConfigService,
],
}),
safeProvider({
provide: RotateableKeySetService,
useClass: DefaultRotateableKeySetService,
deps: [KeyService, EncryptService],
}),
safeProvider({
provide: NewDeviceVerificationComponentService,
useClass: DefaultNewDeviceVerificationComponentService,
deps: [],
}),
safeProvider({
provide: IpcSessionRepository,
useClass: IpcSessionRepository,
deps: [StateProvider],
}),
safeProvider({
provide: PremiumInterestStateService,
useClass: NoopPremiumInterestStateService,

View File

@@ -3,20 +3,20 @@
>
<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 *ngIf="title()" bitTypography="h4" class="tw-font-medium !tw-mb-1">{{ title() }}</h2>
<p
*ngIf="subtitle"
*ngIf="subtitle()"
class="tw-text-main tw-mb-0"
bitTypography="body2"
[innerHTML]="subtitle"
[innerHTML]="subtitle()"
></p>
<ng-content *ngIf="!subtitle"></ng-content>
<ng-content *ngIf="!subtitle()"></ng-content>
</div>
<button
type="button"
bitIconButton="bwi-close"
size="small"
*ngIf="!persistent"
*ngIf="!persistent()"
(click)="handleDismiss()"
class="-tw-me-2"
[label]="'close' | i18n"
@@ -28,10 +28,10 @@
bitButton
type="button"
buttonType="primary"
*ngIf="buttonText"
*ngIf="buttonText()"
(click)="handleButtonClick($event)"
>
{{ buttonText }}
<i *ngIf="buttonIcon" [ngClass]="buttonIcon" class="bwi tw-ml-1" aria-hidden="true"></i>
{{ buttonText() }}
<i *ngIf="buttonIcon()" [ngClass]="buttonIcon()" class="bwi tw-ml-1" aria-hidden="true"></i>
</button>
</div>

View File

@@ -0,0 +1,208 @@
import { CommonModule } from "@angular/common";
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SpotlightComponent } from "./spotlight.component";
describe("SpotlightComponent", () => {
let fixture: ComponentFixture<SpotlightComponent>;
let component: SpotlightComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [SpotlightComponent],
providers: [{ provide: I18nService, useValue: { t: (key: string) => key } }],
}).compileComponents();
fixture = TestBed.createComponent(SpotlightComponent);
component = fixture.componentInstance;
});
function detect(): void {
fixture.detectChanges();
}
it("should create", () => {
expect(component).toBeTruthy();
});
describe("rendering when inputs are null", () => {
it("should render without crashing when inputs are null/undefined", () => {
// Explicitly drive the inputs to null to exercise template null branches
fixture.componentRef.setInput("title", null);
fixture.componentRef.setInput("subtitle", null);
fixture.componentRef.setInput("buttonText", null);
fixture.componentRef.setInput("buttonIcon", null);
// persistent has a default, but drive it as well for coverage sanity
fixture.componentRef.setInput("persistent", false);
expect(() => detect()).not.toThrow();
const root = fixture.debugElement.nativeElement as HTMLElement;
expect(root).toBeTruthy();
});
});
describe("close button visibility based on persistent", () => {
it("should show the close button when persistent is false", () => {
fixture.componentRef.setInput("persistent", false);
detect();
// Assumes dismiss uses bitIconButton
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
expect(dismissButton).toBeTruthy();
});
it("should hide the close button when persistent is true", () => {
fixture.componentRef.setInput("persistent", true);
detect();
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
expect(dismissButton).toBeNull();
});
});
describe("event emission", () => {
it("should emit onButtonClick when CTA button is clicked", () => {
const clickSpy = jest.fn();
component.onButtonClick.subscribe(clickSpy);
fixture.componentRef.setInput("buttonText", "Click me");
detect();
const buttonDe = fixture.debugElement.query(By.css("button[bitButton]"));
expect(buttonDe).toBeTruthy();
const event = new MouseEvent("click");
buttonDe.triggerEventHandler("click", event);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(clickSpy.mock.calls[0][0]).toBeInstanceOf(MouseEvent);
});
it("should emit onDismiss when close button is clicked", () => {
const dismissSpy = jest.fn();
component.onDismiss.subscribe(dismissSpy);
fixture.componentRef.setInput("persistent", false);
detect();
const dismissButton = fixture.debugElement.query(By.css("button[bitIconButton]"));
expect(dismissButton).toBeTruthy();
dismissButton.triggerEventHandler("click", new MouseEvent("click"));
expect(dismissSpy).toHaveBeenCalledTimes(1);
});
it("handleButtonClick should emit via onButtonClick()", () => {
const clickSpy = jest.fn();
component.onButtonClick.subscribe(clickSpy);
const event = new MouseEvent("click");
component.handleButtonClick(event);
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(clickSpy.mock.calls[0][0]).toBe(event);
});
it("handleDismiss should emit via onDismiss()", () => {
const dismissSpy = jest.fn();
component.onDismiss.subscribe(dismissSpy);
component.handleDismiss();
expect(dismissSpy).toHaveBeenCalledTimes(1);
});
});
describe("content projection behavior", () => {
@Component({
standalone: true,
imports: [SpotlightComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<bit-spotlight>
<span class="tw-text-sm">Projected content</span>
</bit-spotlight>
`,
})
class HostWithProjectionComponent {}
let hostFixture: ComponentFixture<HostWithProjectionComponent>;
beforeEach(async () => {
hostFixture = TestBed.createComponent(HostWithProjectionComponent);
});
it("should render projected content inside the spotlight", () => {
hostFixture.detectChanges();
const projected = hostFixture.debugElement.query(By.css(".tw-text-sm"));
expect(projected).toBeTruthy();
expect(projected.nativeElement.textContent.trim()).toBe("Projected content");
});
});
describe("boolean attribute transform for persistent", () => {
@Component({
standalone: true,
imports: [CommonModule, SpotlightComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<!-- bare persistent attribute -->
<bit-spotlight *ngIf="mode === 'bare'" persistent></bit-spotlight>
<!-- no persistent attribute -->
<bit-spotlight *ngIf="mode === 'none'"></bit-spotlight>
<!-- explicit persistent="false" -->
<bit-spotlight *ngIf="mode === 'falseStr'" persistent="false"></bit-spotlight>
`,
})
class BooleanHostComponent {
mode: "bare" | "none" | "falseStr" = "bare";
}
let boolFixture: ComponentFixture<BooleanHostComponent>;
let boolHost: BooleanHostComponent;
beforeEach(async () => {
boolFixture = TestBed.createComponent(BooleanHostComponent);
boolHost = boolFixture.componentInstance;
});
function getSpotlight(): SpotlightComponent {
const de = boolFixture.debugElement.query(By.directive(SpotlightComponent));
return de.componentInstance as SpotlightComponent;
}
it("treats bare 'persistent' attribute as true via booleanAttribute", () => {
boolHost.mode = "bare";
boolFixture.detectChanges();
const spotlight = getSpotlight();
expect(spotlight.persistent()).toBe(true);
});
it("uses default false when 'persistent' is omitted", () => {
boolHost.mode = "none";
boolFixture.detectChanges();
const spotlight = getSpotlight();
expect(spotlight.persistent()).toBe(false);
});
it('treats persistent="false" as false', () => {
boolHost.mode = "falseStr";
boolFixture.detectChanges();
const spotlight = getSpotlight();
expect(spotlight.persistent()).toBe(false);
});
});
});

View File

@@ -1,43 +1,28 @@
import { CommonModule } from "@angular/common";
import { Component, EventEmitter, Input, Output } from "@angular/core";
import { booleanAttribute, ChangeDetectionStrategy, Component, input, output } from "@angular/core";
import { ButtonModule, IconButtonModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-spotlight",
templateUrl: "spotlight.component.html",
imports: [ButtonModule, CommonModule, IconButtonModule, I18nPipe, TypographyModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SpotlightComponent {
// The title of the component
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ required: true }) title: string | null = null;
readonly title = input<string>();
// The subtitle of the component
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() subtitle?: string | null = null;
readonly subtitle = input<string>();
// The text to display on the button
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() buttonText?: string;
// Wheter the component can be dismissed, if true, the component will not show a close button
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() persistent = false;
readonly buttonText = input<string>();
// Whether the component can be dismissed, if true, the component will not show a close button
readonly persistent = input(false, { transform: booleanAttribute });
// Optional icon to display on the button
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() buttonIcon: string | null = null;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onDismiss = new EventEmitter<void>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onButtonClick = new EventEmitter();
readonly buttonIcon = input<string>();
readonly onDismiss = output<void>();
readonly onButtonClick = output<MouseEvent>();
handleButtonClick(event: MouseEvent): void {
this.onButtonClick.emit(event);

View File

@@ -37,6 +37,7 @@ export const NudgeType = {
NewNoteItemStatus: "new-note-item-status",
NewSshItemStatus: "new-ssh-item-status",
GeneratorNudgeStatus: "generator-nudge-status",
PremiumUpgrade: "premium-upgrade",
} as const;
export type NudgeType = UnionOfValues<typeof NudgeType>;

View File

@@ -88,14 +88,10 @@ export class VaultFilterComponent implements OnInit {
this.folders$ = await this.vaultFilterService.buildNestedFolders();
this.collections = await this.initCollections();
const userCanArchive = await firstValueFrom(
this.cipherArchiveService.userCanArchive$(this.activeUserId),
);
const showArchiveVault = await firstValueFrom(
this.cipherArchiveService.showArchiveVault$(this.activeUserId),
this.showArchiveVaultFilter = await firstValueFrom(
this.cipherArchiveService.hasArchiveFlagEnabled$(),
);
this.showArchiveVaultFilter = userCanArchive || showArchiveVault;
this.isLoaded = true;
}

View File

@@ -3,7 +3,13 @@ import { Component, Inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
// 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 { DIALOG_DATA, ButtonModule, DialogModule, DialogService } from "@bitwarden/components";
import {
DIALOG_DATA,
ButtonModule,
DialogModule,
DialogService,
CenterPositionStrategy,
} from "@bitwarden/components";
export type FingerprintDialogData = {
fingerprint: string[];
@@ -19,6 +25,9 @@ export class FingerprintDialogComponent {
constructor(@Inject(DIALOG_DATA) protected data: FingerprintDialogData) {}
static open(dialogService: DialogService, data: FingerprintDialogData) {
return dialogService.open(FingerprintDialogComponent, { data });
return dialogService.open(FingerprintDialogComponent, {
data,
positionStrategy: new CenterPositionStrategy(),
});
}
}

View File

@@ -43,9 +43,6 @@ export * from "./user-verification/user-verification-dialog.component";
export * from "./user-verification/user-verification-dialog.types";
export * from "./user-verification/user-verification-form-input.component";
// vault timeout
export * from "./vault-timeout-input/vault-timeout-input.component";
// sso
export * from "./sso/sso.component";
export * from "./sso/sso-component.service";

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

@@ -135,7 +135,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
try {
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(this.activeAccountId),
);
if (

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

@@ -205,14 +205,9 @@ export class LoginComponent implements OnInit, OnDestroy {
await this.loadRememberedEmail();
}
const disableAlternateLoginMethodsFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM22110_DisableAlternateLoginMethods,
);
if (disableAlternateLoginMethodsFlagEnabled) {
// This SSO required check should come after email has had a chance to be pre-filled (if it
// was found in query params or was the remembered email)
await this.determineIfSsoRequired();
}
// This SSO required check should come after email has had a chance to be pre-filled (if it
// was found in query params or was the remembered email)
await this.determineIfSsoRequired();
}
private async desktopOnInit(): Promise<void> {
@@ -550,6 +545,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 +649,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

@@ -5,6 +5,7 @@ import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Params, Router, RouterModule } from "@angular/router";
import { Subject, firstValueFrom } from "rxjs";
import { PremiumInterestStateService } from "@bitwarden/angular/billing/services/premium-interest/premium-interest-state.service.abstraction";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { AccountApiService } from "@bitwarden/common/auth/abstractions/account-api.service";
@@ -31,6 +32,12 @@ import { PasswordInputResult } from "../../input-password/password-input-result"
import { RegistrationFinishService } from "./registration-finish.service";
const MarketingInitiative = Object.freeze({
Premium: "premium",
} as const);
type MarketingInitiative = (typeof MarketingInitiative)[keyof typeof MarketingInitiative];
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -46,6 +53,12 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
submitting = false;
email: string;
/**
* Indicates that the user is coming from a marketing page designed to streamline
* users who intend to setup a premium subscription after registration.
*/
premiumInterest = false;
// Note: this token is the email verification token. When it is supplied as a query param,
// it either comes from the email verification email or, if email verification is disabled server side
// via global settings, it comes directly from the registration-start component directly.
@@ -79,6 +92,7 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
private logService: LogService,
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private premiumInterestStateService: PremiumInterestStateService,
) {}
async ngOnInit() {
@@ -126,6 +140,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
this.providerInviteToken = qParams.providerInviteToken;
this.providerUserId = qParams.providerUserId;
}
if (qParams.fromMarketing != null && qParams.fromMarketing === MarketingInitiative.Premium) {
this.premiumInterest = true;
}
}
private async initOrgInviteFlowIfPresent(): Promise<boolean> {
@@ -193,6 +211,13 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
authenticationResult.masterPassword ?? null,
);
if (this.premiumInterest) {
await this.premiumInterestStateService.setPremiumInterest(
authenticationResult.userId,
true,
);
}
await this.router.navigate(["/vault"]);
} catch (e) {
// If login errors, redirect to login page per product. Don't show error

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

@@ -460,7 +460,7 @@ export class SsoComponent implements OnInit {
// must come after 2fa check since user decryption options aren't available if 2fa is required
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(authResult.userId),
);
const tdeEnabled = userDecryptionOpts.trustedDeviceOption

View File

@@ -4,10 +4,9 @@ import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
@@ -68,7 +67,6 @@ export class TwoFactorAuthEmailComponent implements OnInit {
protected loginStrategyService: LoginStrategyServiceAbstraction,
protected platformUtilsService: PlatformUtilsService,
protected logService: LogService,
protected twoFactorApiService: TwoFactorApiService,
protected appIdService: AppIdService,
private toastService: ToastService,
private cacheService: TwoFactorAuthEmailComponentCacheService,
@@ -137,7 +135,7 @@ export class TwoFactorAuthEmailComponent implements OnInit {
request.deviceIdentifier = await this.appIdService.getAppId();
request.authRequestAccessCode = (await this.loginStrategyService.getAccessCode()) ?? "";
request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? "";
this.emailPromise = this.twoFactorApiService.postTwoFactorEmail(request);
this.emailPromise = this.twoFactorService.postTwoFactorEmail(request);
await this.emailPromise;
this.emailSent = true;

View File

@@ -6,8 +6,8 @@ import { firstValueFrom } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

View File

@@ -18,12 +18,12 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import {
InternalMasterPasswordServiceAbstraction,
@@ -176,7 +176,9 @@ describe("TwoFactorAuthComponent", () => {
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
mockUserDecryptionOpts.withMasterPassword,
);
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
mockUserDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
selectedUserDecryptionOptions,
);
TestBed.configureTestingModule({
declarations: [TestTwoFactorComponent],

View File

@@ -32,12 +32,12 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
@@ -473,7 +473,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
}
const userDecryptionOpts = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(authResult.userId),
);
const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption);

View File

@@ -4,8 +4,8 @@ import { provideRouter, Router } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { LoginStrategyServiceAbstraction } from "../../common";

View File

@@ -8,7 +8,7 @@ import {
} from "@angular/router";
import { firstValueFrom } from "rxjs";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { LoginStrategyServiceAbstraction } from "../../common";

View File

@@ -9,11 +9,8 @@ import {
TwoFactorAuthWebAuthnIcon,
TwoFactorAuthYubicoIcon,
} from "@bitwarden/assets/svg";
import {
TwoFactorProviderDetails,
TwoFactorService,
} from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { TwoFactorProviderDetails, TwoFactorService } from "@bitwarden/common/auth/two-factor";
// 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 {

View File

@@ -277,13 +277,13 @@ export class UserVerificationDialogComponent {
});
}
}
} catch (e) {
} catch {
// Catch handles OTP and MP verification scenarios as those throw errors on verification failure instead of returning false like PIN and biometrics.
this.invalidSecret = true;
this.toastService.showToast({
variant: "error",
title: this.i18nService.t("error"),
message: e.message,
message: this.i18nService.t("userVerificationFailed"),
});
return;
}

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

@@ -1,34 +1,45 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { UserDecryptionOptions } from "../models";
/**
* Public service for reading user decryption options.
* For use in components and services that need to evaluate user decryption settings.
*/
export abstract class UserDecryptionOptionsServiceAbstraction {
/**
* Returns what decryption options are available for the current user.
* @remark This is sent from the server on authentication.
* Returns the user decryption options for the given user id.
* Will only emit when options are set (does not emit null/undefined
* for an unpopulated state), and should not be called in an unauthenticated context.
* @param userId The user id to check.
*/
abstract userDecryptionOptions$: Observable<UserDecryptionOptions>;
abstract userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions>;
/**
* Uses user decryption options to determine if current user has a master password.
* @remark This is sent from the server, and does not indicate if the master password
* was used to login and/or if a master key is saved locally.
*/
abstract hasMasterPassword$: Observable<boolean>;
/**
* Returns the user decryption options for the given user id.
* @param userId The user id to check.
*/
abstract userDecryptionOptionsById$(userId: string): Observable<UserDecryptionOptions>;
abstract hasMasterPasswordById$(userId: UserId): Observable<boolean>;
}
/**
* Internal service for managing user decryption options.
* For use only in authentication flows that need to update decryption options
* (e.g., login strategies). Extends consumer methods from {@link UserDecryptionOptionsServiceAbstraction}.
* @remarks Most consumers should use UserDecryptionOptionsServiceAbstraction instead.
*/
export abstract class InternalUserDecryptionOptionsServiceAbstraction extends UserDecryptionOptionsServiceAbstraction {
/**
* Sets the current decryption options for the user, contains the current configuration
* Sets the current decryption options for the user. Contains the current configuration
* of the users account related to how they can decrypt their vault.
* @remark Intended to be used when user decryption options are received from server, does
* not update the server. Consider syncing instead of updating locally.
* @param userDecryptionOptions Current user decryption options received from server.
*/
abstract setUserDecryptionOptions(userDecryptionOptions: UserDecryptionOptions): Promise<void>;
abstract setUserDecryptionOptionsById(
userId: UserId,
userDecryptionOptions: UserDecryptionOptions,
): Promise<void>;
}

View File

@@ -3,8 +3,8 @@ import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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";

View File

@@ -5,7 +5,6 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
@@ -16,6 +15,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
@@ -257,7 +257,8 @@ describe("LoginStrategy", () => {
expect(environmentService.seedUserEnvironment).toHaveBeenCalled();
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
userId,
UserDecryptionOptions.fromResponse(idTokenResponse),
);
expect(masterPasswordService.mock.setMasterPasswordUnlockData).toHaveBeenCalledWith(

View File

@@ -3,7 +3,6 @@ import { BehaviorSubject, filter, firstValueFrom, timeout, Observable } from "rx
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
@@ -16,6 +15,7 @@ import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
@@ -197,7 +197,8 @@ export abstract class LoginStrategy {
// We must set user decryption options before retrieving vault timeout settings
// as the user decryption options help determine the available timeout actions.
await this.userDecryptionOptionsService.setUserDecryptionOptions(
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
userId,
UserDecryptionOptions.fromResponse(tokenResponse),
);

View File

@@ -5,12 +5,12 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
@@ -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

@@ -3,12 +3,12 @@ import { BehaviorSubject, of } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";
@@ -134,7 +134,9 @@ describe("SsoLoginStrategy", () => {
);
const userDecryptionOptions = new UserDecryptionOptions();
userDecryptionOptionsService.userDecryptionOptions$ = of(userDecryptionOptions);
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of(userDecryptionOptions),
);
ssoLoginStrategy = new SsoLoginStrategy(
{} as SsoLoginStrategyData,

View File

@@ -393,7 +393,7 @@ export class SsoLoginStrategy extends LoginStrategy {
// Check for TDE-related conditions
const userDecryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptions$,
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
if (!userDecryptionOptions) {

View File

@@ -3,7 +3,7 @@ import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";

View File

@@ -3,11 +3,11 @@ import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";

View File

@@ -1,3 +1,2 @@
export * from "./rotateable-key-set";
export * from "./login-credentials";
export * from "./user-decryption-options";

View File

@@ -1,36 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { PrfKey } from "@bitwarden/common/types/key";
declare const tag: unique symbol;
/**
* A set of keys where a `UserKey` is protected by an encrypted public/private key-pair.
* The `UserKey` is used to encrypt/decrypt data, while the public/private key-pair is
* used to rotate the `UserKey`.
*
* The `PrivateKey` is protected by an `ExternalKey`, such as a `DeviceKey`, or `PrfKey`,
* and the `PublicKey` is protected by the `UserKey`. This setup allows:
*
* - Access to `UserKey` by knowing the `ExternalKey`
* - Rotation to a `NewUserKey` by knowing the current `UserKey`,
* without needing access to the `ExternalKey`
*/
export class RotateableKeySet<ExternalKey extends SymmetricCryptoKey = SymmetricCryptoKey> {
private readonly [tag]: ExternalKey;
constructor(
/** PublicKey encrypted UserKey */
readonly encryptedUserKey: EncString,
/** UserKey encrypted PublicKey */
readonly encryptedPublicKey: EncString,
/** ExternalKey encrypted PrivateKey */
readonly encryptedPrivateKey?: EncString,
) {}
}
export type PrfKeySet = RotateableKeySet<PrfKey>;

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

@@ -4,13 +4,13 @@ import { BehaviorSubject } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
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 { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
import { PreloginResponse } from "@bitwarden/common/auth/models/response/prelogin.response";
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
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";
@@ -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

@@ -13,11 +13,12 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
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 { TwoFactorService } from "@bitwarden/common/auth/two-factor";
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

@@ -1,7 +1,6 @@
import { MockProxy, mock } from "jest-mock-extended";
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -62,62 +61,35 @@ describe("DefaultLoginSuccessHandlerService", () => {
expect(loginEmailService.clearLoginEmail).toHaveBeenCalled();
});
describe("when PM22110_DisableAlternateLoginMethods flag is disabled", () => {
it("should get SSO email", async () => {
await service.run(userId, null);
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
});
describe("given SSO email is not found", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
ssoLoginService.getSsoEmail.mockResolvedValue(null);
});
it("should not check SSO requirements", async () => {
await service.run(userId, null);
expect(ssoLoginService.getSsoEmail).not.toHaveBeenCalled();
expect(logService.error).toHaveBeenCalledWith("SSO login email not found.");
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
});
});
describe("given PM22110_DisableAlternateLoginMethods flag is enabled", () => {
describe("given SSO email is found", () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
ssoLoginService.getSsoEmail.mockResolvedValue(testEmail);
});
it("should check feature flag", async () => {
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
await service.run(userId, null);
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM22110_DisableAlternateLoginMethods,
);
});
it("should get SSO email", async () => {
await service.run(userId, null);
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
});
describe("given SSO email is not found", () => {
beforeEach(() => {
ssoLoginService.getSsoEmail.mockResolvedValue(null);
});
it("should log error and return early", async () => {
await service.run(userId, null);
expect(logService.error).toHaveBeenCalledWith("SSO login email not found.");
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
});
});
describe("given SSO email is found", () => {
beforeEach(() => {
ssoLoginService.getSsoEmail.mockResolvedValue(testEmail);
});
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
await service.run(userId, null);
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();
});
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();
});
});
});

View File

@@ -1,5 +1,4 @@
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { SyncService } from "@bitwarden/common/platform/sync";
@@ -31,20 +30,14 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer
// Don't block login success on migration failure
}
const disableAlternateLoginMethodsFlagEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM22110_DisableAlternateLoginMethods,
);
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();
if (disableAlternateLoginMethodsFlagEnabled) {
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();
if (!ssoLoginEmail) {
this.logService.error("SSO login email not found.");
return;
}
await this.ssoLoginService.updateSsoRequiredCache(ssoLoginEmail, userId);
await this.ssoLoginService.clearSsoEmail();
if (!ssoLoginEmail) {
this.logService.error("SSO login email not found.");
return;
}
await this.ssoLoginService.updateSsoRequiredCache(ssoLoginEmail, userId);
await this.ssoLoginService.clearSsoEmail();
}
}

View File

@@ -1,12 +1,8 @@
import { firstValueFrom } from "rxjs";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import {
FakeAccountService,
FakeStateProvider,
mockAccountServiceWith,
} from "@bitwarden/common/spec";
import { FakeSingleUserStateProvider } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { newGuid } from "@bitwarden/guid";
import { UserDecryptionOptions } from "../../models/domain/user-decryption-options";
@@ -17,15 +13,10 @@ import {
describe("UserDecryptionOptionsService", () => {
let sut: UserDecryptionOptionsService;
const fakeUserId = Utils.newGuid() as UserId;
let fakeAccountService: FakeAccountService;
let fakeStateProvider: FakeStateProvider;
let fakeStateProvider: FakeSingleUserStateProvider;
beforeEach(() => {
fakeAccountService = mockAccountServiceWith(fakeUserId);
fakeStateProvider = new FakeStateProvider(fakeAccountService);
fakeStateProvider = new FakeSingleUserStateProvider();
sut = new UserDecryptionOptionsService(fakeStateProvider);
});
@@ -42,55 +33,71 @@ describe("UserDecryptionOptionsService", () => {
},
};
describe("userDecryptionOptions$", () => {
it("should return the active user's decryption options", async () => {
await fakeStateProvider.setUserState(USER_DECRYPTION_OPTIONS, userDecryptionOptions);
describe("userDecryptionOptionsById$", () => {
it("should return user decryption options for a specific user", async () => {
const userId = newGuid() as UserId;
const result = await firstValueFrom(sut.userDecryptionOptions$);
fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS).nextState(userDecryptionOptions);
const result = await firstValueFrom(sut.userDecryptionOptionsById$(userId));
expect(result).toEqual(userDecryptionOptions);
});
});
describe("hasMasterPassword$", () => {
it("should return the hasMasterPassword property of the active user's decryption options", async () => {
await fakeStateProvider.setUserState(USER_DECRYPTION_OPTIONS, userDecryptionOptions);
describe("hasMasterPasswordById$", () => {
it("should return true when user has a master password", async () => {
const userId = newGuid() as UserId;
const result = await firstValueFrom(sut.hasMasterPassword$);
fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS).nextState(userDecryptionOptions);
const result = await firstValueFrom(sut.hasMasterPasswordById$(userId));
expect(result).toBe(true);
});
});
describe("userDecryptionOptionsById$", () => {
it("should return the user decryption options for the given user", async () => {
const givenUser = Utils.newGuid() as UserId;
await fakeAccountService.addAccount(givenUser, {
name: "Test User 1",
email: "test1@email.com",
emailVerified: false,
});
await fakeStateProvider.setUserState(
USER_DECRYPTION_OPTIONS,
userDecryptionOptions,
givenUser,
);
it("should return false when user does not have a master password", async () => {
const userId = newGuid() as UserId;
const optionsWithoutMasterPassword = {
...userDecryptionOptions,
hasMasterPassword: false,
};
const result = await firstValueFrom(sut.userDecryptionOptionsById$(givenUser));
fakeStateProvider
.getFake(userId, USER_DECRYPTION_OPTIONS)
.nextState(optionsWithoutMasterPassword);
expect(result).toEqual(userDecryptionOptions);
const result = await firstValueFrom(sut.hasMasterPasswordById$(userId));
expect(result).toBe(false);
});
});
describe("setUserDecryptionOptions", () => {
it("should set the active user's decryption options", async () => {
await sut.setUserDecryptionOptions(userDecryptionOptions);
describe("setUserDecryptionOptionsById", () => {
it("should set user decryption options for a specific user", async () => {
const userId = newGuid() as UserId;
const result = await firstValueFrom(
fakeStateProvider.getActive(USER_DECRYPTION_OPTIONS).state$,
);
await sut.setUserDecryptionOptionsById(userId, userDecryptionOptions);
const fakeState = fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS);
const result = await firstValueFrom(fakeState.state$);
expect(result).toEqual(userDecryptionOptions);
});
it("should overwrite existing user decryption options", async () => {
const userId = newGuid() as UserId;
const initialOptions = { ...userDecryptionOptions, hasMasterPassword: false };
const updatedOptions = { ...userDecryptionOptions, hasMasterPassword: true };
const fakeState = fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS);
fakeState.nextState(initialOptions);
await sut.setUserDecryptionOptionsById(userId, updatedOptions);
const result = await firstValueFrom(fakeState.state$);
expect(result).toEqual(updatedOptions);
});
});
});

View File

@@ -1,16 +1,11 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable, map } from "rxjs";
import { Observable, filter, map } from "rxjs";
import {
ActiveUserState,
StateProvider,
SingleUserStateProvider,
USER_DECRYPTION_OPTIONS_DISK,
UserKeyDefinition,
} from "@bitwarden/common/platform/state";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { UserId } from "@bitwarden/common/src/types/guid";
import { UserId } from "@bitwarden/common/types/guid";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction";
import { UserDecryptionOptions } from "../../models";
@@ -27,25 +22,26 @@ export const USER_DECRYPTION_OPTIONS = new UserKeyDefinition<UserDecryptionOptio
export class UserDecryptionOptionsService
implements InternalUserDecryptionOptionsServiceAbstraction
{
private userDecryptionOptionsState: ActiveUserState<UserDecryptionOptions>;
constructor(private singleUserStateProvider: SingleUserStateProvider) {}
userDecryptionOptions$: Observable<UserDecryptionOptions>;
hasMasterPassword$: Observable<boolean>;
userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions> {
return this.singleUserStateProvider
.get(userId, USER_DECRYPTION_OPTIONS)
.state$.pipe(filter((options): options is UserDecryptionOptions => options != null));
}
constructor(private stateProvider: StateProvider) {
this.userDecryptionOptionsState = this.stateProvider.getActive(USER_DECRYPTION_OPTIONS);
this.userDecryptionOptions$ = this.userDecryptionOptionsState.state$;
this.hasMasterPassword$ = this.userDecryptionOptions$.pipe(
map((options) => options?.hasMasterPassword ?? false),
hasMasterPasswordById$(userId: UserId): Observable<boolean> {
return this.userDecryptionOptionsById$(userId).pipe(
map((options) => options.hasMasterPassword ?? false),
);
}
userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions> {
return this.stateProvider.getUser(userId, USER_DECRYPTION_OPTIONS).state$;
}
async setUserDecryptionOptions(userDecryptionOptions: UserDecryptionOptions): Promise<void> {
await this.userDecryptionOptionsState.update((_) => userDecryptionOptions);
async setUserDecryptionOptionsById(
userId: UserId,
userDecryptionOptions: UserDecryptionOptions,
): Promise<void> {
await this.singleUserStateProvider
.get(userId, USER_DECRYPTION_OPTIONS)
.update((_) => userDecryptionOptions);
}
}

View File

@@ -1,11 +1,11 @@
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { PasswordManagerClient } from "@bitwarden/sdk-internal";
import { SdkClientFactory } from "../src/platform/abstractions/sdk/sdk-client-factory";
export class DefaultSdkClientFactory implements SdkClientFactory {
createSdkClient(
...args: ConstructorParameters<typeof BitwardenClient>
): Promise<BitwardenClient> {
...args: ConstructorParameters<typeof PasswordManagerClient>
): Promise<PasswordManagerClient> {
throw new Error("Method not implemented.");
}
}

View File

@@ -20,4 +20,5 @@ export enum PolicyType {
UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
AutoConfirm = 18, // Enables the auto confirmation feature for admins to enable in their client
BlockClaimedDomainAccountCreation = 19, // Prevents users from creating personal accounts using email addresses from verified domains
}

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

@@ -32,6 +32,7 @@ describe("Organization", () => {
useSecretsManager: true,
usePasswordManager: true,
useActivateAutofillPolicy: false,
useAutomaticUserConfirmation: false,
selfHost: false,
usersGetPremium: false,
seats: 10,
@@ -79,7 +80,7 @@ describe("Organization", () => {
limitItemDeletion: false,
allowAdminAccessToAllCollectionItems: true,
userIsManagedByOrganization: false,
useRiskInsights: false,
useAccessIntelligence: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,
ssoEnabled: false,
@@ -179,4 +180,118 @@ describe("Organization", () => {
expect(organization.canManageDeviceApprovals).toBe(true);
});
});
describe("canEnableAutoConfirmPolicy", () => {
it("should return false when user cannot manage users or policies", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = false;
data.permissions.managePolicies = false;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user can manage users but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.Admin;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user has manageUsers permission but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user can manage policies but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.Admin;
data.usePolicies = true;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user has managePolicies permission but usePolicies is false", () => {
data.type = OrganizationUserType.User;
data.permissions.managePolicies = true;
data.usePolicies = false;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return true when admin has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Admin;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when owner has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Owner;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has manageUsers permission and useAutomaticUserConfirmation is enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has managePolicies permission, usePolicies is true, and useAutomaticUserConfirmation is enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.managePolicies = true;
data.usePolicies = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has both manageUsers and managePolicies permissions with useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.permissions.managePolicies = true;
data.usePolicies = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return false when provider user has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Owner;
data.isProviderUser = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(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;
@@ -310,6 +310,14 @@ export class Organization {
return this.isAdmin || this.permissions.manageResetPassword;
}
get canEnableAutoConfirmPolicy() {
return (
(this.canManageUsers || this.canManagePolicies) &&
this.useAutomaticUserConfirmation &&
!this.isProviderUser
);
}
get canManageDeviceApprovals() {
return (
(this.isAdmin || this.permissions.manageResetPassword) &&

View File

@@ -1,10 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { OrganizationKeysRequest } from "./organization-keys.request";
export class OrganizationUpdateRequest {
name: string;
businessName: string;
billingEmail: string;
keys: OrganizationKeysRequest;
export interface OrganizationUpdateRequest {
name?: string;
billingEmail?: string;
keys?: OrganizationKeysRequest;
}

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

@@ -285,6 +285,8 @@ 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;

View File

@@ -1,60 +0,0 @@
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
export interface TwoFactorProviderDetails {
type: TwoFactorProviderType;
name: string;
description: string;
priority: number;
sort: number;
premium: boolean;
}
export abstract class TwoFactorService {
/**
* Initializes the client-side's TwoFactorProviders const with translations.
*/
abstract init(): void;
/**
* Gets a list of two-factor providers from state that are supported on the current client.
* E.g., WebAuthn and Duo are not available on all clients.
* @returns A list of supported two-factor providers or an empty list if none are stored in state.
*/
abstract getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]>;
/**
* Gets the previously selected two-factor provider or the default two factor provider based on priority.
* @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false.
*/
abstract getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType>;
/**
* Sets the selected two-factor provider in state.
* @param type - The type of two-factor provider to set as the selected provider.
*/
abstract setSelectedProvider(type: TwoFactorProviderType): Promise<void>;
/**
* Clears the selected two-factor provider from state.
*/
abstract clearSelectedProvider(): Promise<void>;
/**
* Sets the list of available two-factor providers in state.
* @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers.
*/
abstract setProviders(response: IdentityTwoFactorResponse): Promise<void>;
/**
* Clears the list of available two-factor providers from state.
*/
abstract clearProviders(): Promise<void>;
/**
* Gets the list of two-factor providers from state.
* Note: no filtering is done here, so this will return all providers, including potentially
* unsupported ones for the current client.
* @returns A list of two-factor providers or null if none are stored in state.
*/
abstract getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null>;
}

View File

@@ -48,6 +48,9 @@ export abstract class UserVerificationService {
* @param userId The user id to check. If not provided, the current user is used
* @returns True if the user has a master password
* @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead
* @remark To facilitate deprecation, many call sites were removed as part of PM-26413.
* Those remaining are blocked by currently-disallowed imports of auth/common.
* PM-27009 has been filed to track completion of this deprecation.
*/
abstract hasMasterPassword(userId?: string): Promise<boolean>;
/**

View File

@@ -11,7 +11,7 @@ export abstract class WebAuthnLoginPrfKeyServiceAbstraction {
/**
* Create a symmetric key from the PRF-output by stretching it.
* This should be used as `ExternalKey` with `RotateableKeySet`.
* This should be used as `UpstreamKey` with `RotateableKeySet`.
*/
abstract createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise<PrfKey>;
}

View File

@@ -1,10 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { RotateableKeySet } from "../../../../../auth/src/common/models";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { RotateableKeySet } from "../../../key-management/keys/models/rotateable-key-set";
export class WebauthnRotateCredentialRequest {
id: string;

View File

@@ -2,12 +2,9 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
// 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 { RotateableKeySet } from "@bitwarden/auth/common";
import { DeviceType } from "../../../enums";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { RotateableKeySet } from "../../../key-management/keys/models/rotateable-key-set";
import { BaseResponse } from "../../../models/response/base.response";
export class ProtectedDeviceResponse extends BaseResponse {

View File

@@ -13,7 +13,7 @@ export abstract class SendTokenService {
/**
* Attempts to retrieve a {@link SendAccessToken} for the given sendId.
* If the access token is found in session storage and is not expired, then it returns the token.
* If the access token is expired, then it returns a {@link TryGetSendAccessTokenError} expired error.
* If the access token found in session storage is expired, then it returns a {@link TryGetSendAccessTokenError} expired error and clears the token from storage so that a subsequent call can attempt to retrieve a new token.
* If an access token is not found in storage, then it attempts to retrieve it from the server (will succeed for sends that don't require any credentials to view).
* If the access token is successfully retrieved from the server, then it stores the token in session storage and returns it.
* If an access token cannot be granted b/c the send requires credentials, then it returns a {@link TryGetSendAccessTokenError} indicating which credentials are required.

View File

@@ -1,212 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { Utils } from "../../platform/misc/utils";
import { GlobalStateProvider, KeyDefinition, TWO_FACTOR_MEMORY } from "../../platform/state";
import {
TwoFactorProviderDetails,
TwoFactorService as TwoFactorServiceAbstraction,
} from "../abstractions/two-factor.service";
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactorProviderDetails>> =
{
[TwoFactorProviderType.Authenticator]: {
type: TwoFactorProviderType.Authenticator,
name: null as string,
description: null as string,
priority: 1,
sort: 2,
premium: false,
},
[TwoFactorProviderType.Yubikey]: {
type: TwoFactorProviderType.Yubikey,
name: null as string,
description: null as string,
priority: 3,
sort: 4,
premium: true,
},
[TwoFactorProviderType.Duo]: {
type: TwoFactorProviderType.Duo,
name: "Duo",
description: null as string,
priority: 2,
sort: 5,
premium: true,
},
[TwoFactorProviderType.OrganizationDuo]: {
type: TwoFactorProviderType.OrganizationDuo,
name: "Duo (Organization)",
description: null as string,
priority: 10,
sort: 6,
premium: false,
},
[TwoFactorProviderType.Email]: {
type: TwoFactorProviderType.Email,
name: null as string,
description: null as string,
priority: 0,
sort: 1,
premium: false,
},
[TwoFactorProviderType.WebAuthn]: {
type: TwoFactorProviderType.WebAuthn,
name: null as string,
description: null as string,
priority: 4,
sort: 3,
premium: false,
},
};
// Memory storage as only required during authentication process
export const PROVIDERS = KeyDefinition.record<Record<string, string>, TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"providers",
{
deserializer: (obj) => obj,
},
);
// Memory storage as only required during authentication process
export const SELECTED_PROVIDER = new KeyDefinition<TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"selected",
{
deserializer: (obj) => obj,
},
);
export class TwoFactorService implements TwoFactorServiceAbstraction {
private providersState = this.globalStateProvider.get(PROVIDERS);
private selectedState = this.globalStateProvider.get(SELECTED_PROVIDER);
readonly providers$ = this.providersState.state$.pipe(
map((providers) => Utils.recordToMap(providers)),
);
readonly selected$ = this.selectedState.state$;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private globalStateProvider: GlobalStateProvider,
) {}
init() {
TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t("emailTitle");
TwoFactorProviders[TwoFactorProviderType.Email].description = this.i18nService.t("emailDescV2");
TwoFactorProviders[TwoFactorProviderType.Authenticator].name =
this.i18nService.t("authenticatorAppTitle");
TwoFactorProviders[TwoFactorProviderType.Authenticator].description =
this.i18nService.t("authenticatorAppDescV2");
TwoFactorProviders[TwoFactorProviderType.Duo].description = this.i18nService.t("duoDescV2");
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].name =
"Duo (" + this.i18nService.t("organization") + ")";
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].description =
this.i18nService.t("duoOrganizationDesc");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].name = this.i18nService.t("webAuthnTitle");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].description =
this.i18nService.t("webAuthnDesc");
TwoFactorProviders[TwoFactorProviderType.Yubikey].name = this.i18nService.t("yubiKeyTitleV2");
TwoFactorProviders[TwoFactorProviderType.Yubikey].description =
this.i18nService.t("yubiKeyDesc");
}
async getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]> {
const data = await firstValueFrom(this.providers$);
const providers: any[] = [];
if (data == null) {
return providers;
}
if (
data.has(TwoFactorProviderType.OrganizationDuo) &&
this.platformUtilsService.supportsDuo()
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]);
}
if (data.has(TwoFactorProviderType.Authenticator)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]);
}
if (data.has(TwoFactorProviderType.Yubikey)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]);
}
if (data.has(TwoFactorProviderType.Duo) && this.platformUtilsService.supportsDuo()) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]);
}
if (
data.has(TwoFactorProviderType.WebAuthn) &&
this.platformUtilsService.supportsWebAuthn(win)
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]);
}
if (data.has(TwoFactorProviderType.Email)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Email]);
}
return providers;
}
async getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType> {
const data = await firstValueFrom(this.providers$);
const selected = await firstValueFrom(this.selected$);
if (data == null) {
return null;
}
if (selected != null && data.has(selected)) {
return selected;
}
let providerType: TwoFactorProviderType = null;
let providerPriority = -1;
data.forEach((_value, type) => {
const provider = (TwoFactorProviders as any)[type];
if (provider != null && provider.priority > providerPriority) {
if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) {
return;
}
providerType = type;
providerPriority = provider.priority;
}
});
return providerType;
}
async setSelectedProvider(type: TwoFactorProviderType): Promise<void> {
await this.selectedState.update(() => type);
}
async clearSelectedProvider(): Promise<void> {
await this.selectedState.update(() => null);
}
async setProviders(response: IdentityTwoFactorResponse): Promise<void> {
await this.providersState.update(() => response.twoFactorProviders2);
}
async clearProviders(): Promise<void> {
await this.providersState.update(() => null);
}
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null> {
return firstValueFrom(this.providers$);
}
}

View File

@@ -3,10 +3,7 @@ import { 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 {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { UserDecryptionOptionsServiceAbstraction } 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 {
@@ -146,11 +143,7 @@ describe("UserVerificationService", () => {
describe("server verification type", () => {
it("correctly returns master password availability", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: true,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
const result = await sut.getAvailableVerificationOptions("server");
@@ -168,11 +161,7 @@ describe("UserVerificationService", () => {
});
it("correctly returns OTP availability", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: false,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
const result = await sut.getAvailableVerificationOptions("server");
@@ -191,6 +180,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 +351,6 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: null,
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});
@@ -288,7 +410,6 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: "MasterPasswordPolicyOptions",
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});
@@ -394,11 +515,7 @@ describe("UserVerificationService", () => {
// Helpers
function setMasterPasswordAvailability(hasMasterPassword: boolean) {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: hasMasterPassword,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(hasMasterPassword));
masterPasswordService.masterKeyHash$.mockReturnValue(
of(hasMasterPassword ? "masterKeyHash" : null),
);

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> {
@@ -261,16 +258,19 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
}
async hasMasterPassword(userId?: string): Promise<boolean> {
if (userId) {
const decryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
const resolvedUserId = userId ?? (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (decryptionOptions?.hasMasterPassword != undefined) {
return decryptionOptions.hasMasterPassword;
}
if (!resolvedUserId) {
return false;
}
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
// Ideally, this method would accept a UserId over string. To avoid scope creep in PM-26413, we are
// doing the cast here. Future work should be done to make this type-safe, and should be considered
// as part of PM-27009.
return await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId as UserId),
);
}
async hasMasterPasswordAndMasterKeyHash(userId?: string): Promise<boolean> {

View File

@@ -0,0 +1,2 @@
export * from "./two-factor-api.service";
export * from "./two-factor.service";

View File

@@ -0,0 +1,497 @@
import { ListResponse } from "../../../models/response/list.response";
import { KeyDefinition, TWO_FACTOR_MEMORY } from "../../../platform/state";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "../../models/request/disable-two-factor-authenticator.request";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { TwoFactorEmailRequest } from "../../models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../../models/request/two-factor-provider.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../../models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../../models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../../models/request/update-two-factor-email.request";
import { UpdateTwoFactorWebAuthnDeleteRequest } from "../../models/request/update-two-factor-web-authn-delete.request";
import { UpdateTwoFactorWebAuthnRequest } from "../../models/request/update-two-factor-web-authn.request";
import { UpdateTwoFactorYubikeyOtpRequest } from "../../models/request/update-two-factor-yubikey-otp.request";
import { IdentityTwoFactorResponse } from "../../models/response/identity-two-factor.response";
import { TwoFactorAuthenticatorResponse } from "../../models/response/two-factor-authenticator.response";
import { TwoFactorDuoResponse } from "../../models/response/two-factor-duo.response";
import { TwoFactorEmailResponse } from "../../models/response/two-factor-email.response";
import { TwoFactorProviderResponse } from "../../models/response/two-factor-provider.response";
import { TwoFactorRecoverResponse } from "../../models/response/two-factor-recover.response";
import {
ChallengeResponse,
TwoFactorWebAuthnResponse,
} from "../../models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../../models/response/two-factor-yubi-key.response";
/**
* Metadata and display information for a two-factor authentication provider.
* Used by UI components to render provider selection and configuration screens.
*/
export interface TwoFactorProviderDetails {
/** The unique identifier for this provider type. */
type: TwoFactorProviderType;
/**
* Display name for the provider, localized via {@link TwoFactorService.init}.
* Examples: "Authenticator App", "Email", "YubiKey".
*/
name: string | null;
/**
* User-facing description explaining what this provider is and how it works.
* Localized via {@link TwoFactorService.init}.
*/
description: string | null;
/**
* Selection priority during login when multiple providers are available.
* Higher values are preferred. Used to determine the default provider.
* Range: 0 (lowest) to 10 (highest).
*/
priority: number;
/**
* Display order in provider lists within settings UI.
* Lower values appear first (1 = first position).
*/
sort: number;
/**
* Whether this provider requires an active premium subscription.
* Premium providers: Duo (personal), YubiKey.
* Organization providers (e.g., OrganizationDuo) do not require personal premium.
*/
premium: boolean;
}
/**
* Registry of all supported two-factor authentication providers with their metadata.
* Strings (name, description) are initialized as null and populated with localized
* translations when {@link TwoFactorService.init} is called during application startup.
*
* @remarks
* This constant is mutated during initialization. Components should not access it before
* the service's init() method has been called.
*
* @example
* ```typescript
* // During app init
* twoFactorService.init();
*
* // In components
* const authenticator = TwoFactorProviders[TwoFactorProviderType.Authenticator];
* console.log(authenticator.name); // "Authenticator App" (localized)
* ```
*/
export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactorProviderDetails>> =
{
[TwoFactorProviderType.Authenticator]: {
type: TwoFactorProviderType.Authenticator,
name: null,
description: null,
priority: 1,
sort: 2,
premium: false,
},
[TwoFactorProviderType.Yubikey]: {
type: TwoFactorProviderType.Yubikey,
name: null,
description: null,
priority: 3,
sort: 4,
premium: true,
},
[TwoFactorProviderType.Duo]: {
type: TwoFactorProviderType.Duo,
name: "Duo",
description: null,
priority: 2,
sort: 5,
premium: true,
},
[TwoFactorProviderType.OrganizationDuo]: {
type: TwoFactorProviderType.OrganizationDuo,
name: "Duo (Organization)",
description: null,
priority: 10,
sort: 6,
premium: false,
},
[TwoFactorProviderType.Email]: {
type: TwoFactorProviderType.Email,
name: null,
description: null,
priority: 0,
sort: 1,
premium: false,
},
[TwoFactorProviderType.WebAuthn]: {
type: TwoFactorProviderType.WebAuthn,
name: null,
description: null,
priority: 4,
sort: 3,
premium: false,
},
};
// Memory storage as only required during authentication process
export const PROVIDERS = KeyDefinition.record<Record<string, string>, TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"providers",
{
deserializer: (obj) => obj,
},
);
// Memory storage as only required during authentication process
export const SELECTED_PROVIDER = new KeyDefinition<TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"selected",
{
deserializer: (obj) => obj,
},
);
export abstract class TwoFactorService {
/**
* Initializes the client-side's TwoFactorProviders const with translations.
*/
abstract init(): void;
/**
* Gets a list of two-factor providers from state that are supported on the current client.
* E.g., WebAuthn and Duo are not available on all clients.
* @returns A list of supported two-factor providers or an empty list if none are stored in state.
*/
abstract getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]>;
/**
* Gets the previously selected two-factor provider or the default two factor provider based on priority.
* @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false.
*/
abstract getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType>;
/**
* Sets the selected two-factor provider in state.
* @param type - The type of two-factor provider to set as the selected provider.
*/
abstract setSelectedProvider(type: TwoFactorProviderType): Promise<void>;
/**
* Clears the selected two-factor provider from state.
*/
abstract clearSelectedProvider(): Promise<void>;
/**
* Sets the list of available two-factor providers in state.
* @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers.
*/
abstract setProviders(response: IdentityTwoFactorResponse): Promise<void>;
/**
* Clears the list of available two-factor providers from state.
*/
abstract clearProviders(): Promise<void>;
/**
* Gets the list of two-factor providers from state.
* Note: no filtering is done here, so this will return all providers, including potentially
* unsupported ones for the current client.
* @returns A list of two-factor providers or null if none are stored in state.
*/
abstract getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null>;
/**
* Gets the enabled two-factor providers for the current user from the API.
* Used for settings management.
* @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
*/
abstract getEnabledTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>>;
/**
* Gets the enabled two-factor providers for an organization from the API.
* Requires organization administrator permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
*/
abstract getTwoFactorOrganizationProviders(
organizationId: string,
): Promise<ListResponse<TwoFactorProviderResponse>>;
/**
* Gets the authenticator (TOTP) two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the authenticator configuration including the secret key.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorAuthenticator(
request: SecretVerificationRequest,
): Promise<TwoFactorAuthenticatorResponse>;
/**
* Gets the email two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the email two-factor configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse>;
/**
* Gets the Duo two-factor configuration for the current user from the API.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse>;
/**
* Gets the Duo two-factor configuration for an organization from the API.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the organization Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorOrganizationDuo(
organizationId: string,
request: SecretVerificationRequest,
): Promise<TwoFactorDuoResponse>;
/**
* Gets the YubiKey OTP two-factor configuration for the current user from the API.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the YubiKey configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorYubiKey(
request: SecretVerificationRequest,
): Promise<TwoFactorYubiKeyResponse>;
/**
* Gets the WebAuthn (FIDO2) two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to authentication.
* @returns A promise that resolves to the WebAuthn configuration including registered credentials.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorWebAuthn(
request: SecretVerificationRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Gets a WebAuthn challenge for registering a new WebAuthn credential from the API.
* This must be called before putTwoFactorWebAuthn to obtain the cryptographic challenge
* required for credential creation. The challenge is used by the browser's WebAuthn API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the credential creation options containing the challenge.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorWebAuthnChallenge(
request: SecretVerificationRequest,
): Promise<ChallengeResponse>;
/**
* Gets the recovery code configuration for the current user from the API.
* The recovery code should be stored securely by the user.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param verification The verification information to prove authentication.
* @returns A promise that resolves to the recovery code configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorRecover(
request: SecretVerificationRequest,
): Promise<TwoFactorRecoverResponse>;
/**
* Enables or updates the authenticator (TOTP) two-factor provider.
* Validates the provided token against the shared secret before enabling.
* The token must be generated by an authenticator app using the secret key.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorAuthenticatorRequest} to prove authentication.
* @returns A promise that resolves to the updated authenticator configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorAuthenticator(
request: UpdateTwoFactorAuthenticatorRequest,
): Promise<TwoFactorAuthenticatorResponse>;
/**
* Disables the authenticator (TOTP) two-factor provider for the current user.
* Requires user verification token to confirm the operation.
* Used for settings management.
*
* @param request The {@link DisableTwoFactorAuthenticatorRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract deleteTwoFactorAuthenticator(
request: DisableTwoFactorAuthenticatorRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Enables or updates the email two-factor provider for the current user.
* Validates the email verification token sent via postTwoFactorEmailSetup before enabling.
* The token must match the code sent to the specified email address.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves to the updated email two-factor configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse>;
/**
* Enables or updates the Duo two-factor provider for the current user.
* Validates the Duo configuration (client ID, client secret, and host) before enabling.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorDuoRequest} to prove authentication.
* @returns A promise that resolves to the updated Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse>;
/**
* Enables or updates the Duo two-factor provider for an organization.
* Validates the Duo configuration (client ID, client secret, and host) before enabling.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link UpdateTwoFactorDuoRequest} to prove authentication.
* @returns A promise that resolves to the updated organization Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorOrganizationDuo(
organizationId: string,
request: UpdateTwoFactorDuoRequest,
): Promise<TwoFactorDuoResponse>;
/**
* Enables or updates the YubiKey OTP two-factor provider for the current user.
* Validates each provided YubiKey by testing an OTP from the device.
* Supports up to 5 YubiKey devices. Empty key slots are allowed.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorYubikeyOtpRequest} to prove authentication.
* @returns A promise that resolves to the updated YubiKey configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorYubiKey(
request: UpdateTwoFactorYubikeyOtpRequest,
): Promise<TwoFactorYubiKeyResponse>;
/**
* Registers a new WebAuthn (FIDO2) credential for two-factor authentication for the current user.
* Must be called after getTwoFactorWebAuthnChallenge to complete the registration flow.
* The device response contains the signed challenge from the authenticator device.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorWebAuthnRequest} to prove authentication.
* @returns A promise that resolves to the updated WebAuthn configuration with the new credential.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Removes a specific WebAuthn (FIDO2) credential from the user's account.
* The credential will no longer be usable for two-factor authentication.
* Other registered WebAuthn credentials remain active.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorWebAuthnDeleteRequest} to prove authentication.
* @returns A promise that resolves to the updated WebAuthn configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract deleteTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnDeleteRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Disables a specific two-factor provider for the current user.
* The provider will no longer be required or usable for authentication.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link TwoFactorProviderRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorDisable(
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Disables a specific two-factor provider for an organization.
* The provider will no longer be available for organization members.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link TwoFactorProviderRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorOrganizationDisable(
organizationId: string,
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Initiates email two-factor setup by sending a verification code to the specified email address.
* This is the first step in enabling email two-factor authentication.
* The verification code must be provided to putTwoFactorEmail to complete setup.
* Only used during initial configuration, not during login flows.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link TwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves when the verification email has been sent.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any>;
/**
* Sends a two-factor authentication code via email during the login flow.
* Supports multiple authentication contexts including standard login, SSO, and passwordless flows.
* This is used to deliver codes during authentication, not during initial setup.
* May be called without authentication for login scenarios.
* Used during authentication flows.
*
* @param request The {@link TwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves when the authentication email has been sent.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any>;
}

View File

@@ -1,2 +1,2 @@
export { TwoFactorApiService } from "./two-factor-api.service";
export { DefaultTwoFactorApiService } from "./default-two-factor-api.service";
export * from "./abstractions";
export * from "./services";

View File

@@ -22,7 +22,7 @@ import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { TwoFactorApiService } from "./two-factor-api.service";
import { TwoFactorApiService } from "../abstractions/two-factor-api.service";
export class DefaultTwoFactorApiService implements TwoFactorApiService {
constructor(private apiService: ApiService) {}

View File

@@ -0,0 +1,279 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { TwoFactorApiService } from "..";
import { ListResponse } from "../../../models/response/list.response";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { Utils } from "../../../platform/misc/utils";
import { GlobalStateProvider } from "../../../platform/state";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "../../models/request/disable-two-factor-authenticator.request";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { TwoFactorEmailRequest } from "../../models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../../models/request/two-factor-provider.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../../models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../../models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../../models/request/update-two-factor-email.request";
import { UpdateTwoFactorWebAuthnDeleteRequest } from "../../models/request/update-two-factor-web-authn-delete.request";
import { UpdateTwoFactorWebAuthnRequest } from "../../models/request/update-two-factor-web-authn.request";
import { UpdateTwoFactorYubikeyOtpRequest } from "../../models/request/update-two-factor-yubikey-otp.request";
import { IdentityTwoFactorResponse } from "../../models/response/identity-two-factor.response";
import { TwoFactorAuthenticatorResponse } from "../../models/response/two-factor-authenticator.response";
import { TwoFactorDuoResponse } from "../../models/response/two-factor-duo.response";
import { TwoFactorEmailResponse } from "../../models/response/two-factor-email.response";
import { TwoFactorProviderResponse } from "../../models/response/two-factor-provider.response";
import { TwoFactorRecoverResponse } from "../../models/response/two-factor-recover.response";
import {
TwoFactorWebAuthnResponse,
ChallengeResponse,
} from "../../models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../../models/response/two-factor-yubi-key.response";
import {
PROVIDERS,
SELECTED_PROVIDER,
TwoFactorProviderDetails,
TwoFactorProviders,
TwoFactorService as TwoFactorServiceAbstraction,
} from "../abstractions/two-factor.service";
export class DefaultTwoFactorService implements TwoFactorServiceAbstraction {
private providersState = this.globalStateProvider.get(PROVIDERS);
private selectedState = this.globalStateProvider.get(SELECTED_PROVIDER);
readonly providers$ = this.providersState.state$.pipe(
map((providers) => Utils.recordToMap(providers)),
);
readonly selected$ = this.selectedState.state$;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private globalStateProvider: GlobalStateProvider,
private twoFactorApiService: TwoFactorApiService,
) {}
init() {
TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t("emailTitle");
TwoFactorProviders[TwoFactorProviderType.Email].description = this.i18nService.t("emailDescV2");
TwoFactorProviders[TwoFactorProviderType.Authenticator].name =
this.i18nService.t("authenticatorAppTitle");
TwoFactorProviders[TwoFactorProviderType.Authenticator].description =
this.i18nService.t("authenticatorAppDescV2");
TwoFactorProviders[TwoFactorProviderType.Duo].description = this.i18nService.t("duoDescV2");
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].name =
"Duo (" + this.i18nService.t("organization") + ")";
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].description =
this.i18nService.t("duoOrganizationDesc");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].name = this.i18nService.t("webAuthnTitle");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].description =
this.i18nService.t("webAuthnDesc");
TwoFactorProviders[TwoFactorProviderType.Yubikey].name = this.i18nService.t("yubiKeyTitleV2");
TwoFactorProviders[TwoFactorProviderType.Yubikey].description =
this.i18nService.t("yubiKeyDesc");
}
async getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]> {
const data = await firstValueFrom(this.providers$);
const providers: any[] = [];
if (data == null) {
return providers;
}
if (
data.has(TwoFactorProviderType.OrganizationDuo) &&
this.platformUtilsService.supportsDuo()
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]);
}
if (data.has(TwoFactorProviderType.Authenticator)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]);
}
if (data.has(TwoFactorProviderType.Yubikey)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]);
}
if (data.has(TwoFactorProviderType.Duo) && this.platformUtilsService.supportsDuo()) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]);
}
if (
data.has(TwoFactorProviderType.WebAuthn) &&
this.platformUtilsService.supportsWebAuthn(win)
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]);
}
if (data.has(TwoFactorProviderType.Email)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Email]);
}
return providers;
}
async getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType> {
const data = await firstValueFrom(this.providers$);
const selected = await firstValueFrom(this.selected$);
if (data == null) {
return null;
}
if (selected != null && data.has(selected)) {
return selected;
}
let providerType: TwoFactorProviderType = null;
let providerPriority = -1;
data.forEach((_value, type) => {
const provider = (TwoFactorProviders as any)[type];
if (provider != null && provider.priority > providerPriority) {
if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) {
return;
}
providerType = type;
providerPriority = provider.priority;
}
});
return providerType;
}
async setSelectedProvider(type: TwoFactorProviderType): Promise<void> {
await this.selectedState.update(() => type);
}
async clearSelectedProvider(): Promise<void> {
await this.selectedState.update(() => null);
}
async setProviders(response: IdentityTwoFactorResponse): Promise<void> {
await this.providersState.update(() => response.twoFactorProviders2);
}
async clearProviders(): Promise<void> {
await this.providersState.update(() => null);
}
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null> {
return firstValueFrom(this.providers$);
}
getEnabledTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>> {
return this.twoFactorApiService.getTwoFactorProviders();
}
getTwoFactorOrganizationProviders(
organizationId: string,
): Promise<ListResponse<TwoFactorProviderResponse>> {
return this.twoFactorApiService.getTwoFactorOrganizationProviders(organizationId);
}
getTwoFactorAuthenticator(
request: SecretVerificationRequest,
): Promise<TwoFactorAuthenticatorResponse> {
return this.twoFactorApiService.getTwoFactorAuthenticator(request);
}
getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse> {
return this.twoFactorApiService.getTwoFactorEmail(request);
}
getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.getTwoFactorDuo(request);
}
getTwoFactorOrganizationDuo(
organizationId: string,
request: SecretVerificationRequest,
): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.getTwoFactorOrganizationDuo(organizationId, request);
}
getTwoFactorYubiKey(request: SecretVerificationRequest): Promise<TwoFactorYubiKeyResponse> {
return this.twoFactorApiService.getTwoFactorYubiKey(request);
}
getTwoFactorWebAuthn(request: SecretVerificationRequest): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.getTwoFactorWebAuthn(request);
}
getTwoFactorWebAuthnChallenge(request: SecretVerificationRequest): Promise<ChallengeResponse> {
return this.twoFactorApiService.getTwoFactorWebAuthnChallenge(request);
}
getTwoFactorRecover(request: SecretVerificationRequest): Promise<TwoFactorRecoverResponse> {
return this.twoFactorApiService.getTwoFactorRecover(request);
}
putTwoFactorAuthenticator(
request: UpdateTwoFactorAuthenticatorRequest,
): Promise<TwoFactorAuthenticatorResponse> {
return this.twoFactorApiService.putTwoFactorAuthenticator(request);
}
deleteTwoFactorAuthenticator(
request: DisableTwoFactorAuthenticatorRequest,
): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.deleteTwoFactorAuthenticator(request);
}
putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse> {
return this.twoFactorApiService.putTwoFactorEmail(request);
}
putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.putTwoFactorDuo(request);
}
putTwoFactorOrganizationDuo(
organizationId: string,
request: UpdateTwoFactorDuoRequest,
): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.putTwoFactorOrganizationDuo(organizationId, request);
}
putTwoFactorYubiKey(
request: UpdateTwoFactorYubikeyOtpRequest,
): Promise<TwoFactorYubiKeyResponse> {
return this.twoFactorApiService.putTwoFactorYubiKey(request);
}
putTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnRequest,
): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.putTwoFactorWebAuthn(request);
}
deleteTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnDeleteRequest,
): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.deleteTwoFactorWebAuthn(request);
}
putTwoFactorDisable(request: TwoFactorProviderRequest): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.putTwoFactorDisable(request);
}
putTwoFactorOrganizationDisable(
organizationId: string,
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.putTwoFactorOrganizationDisable(organizationId, request);
}
postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any> {
return this.twoFactorApiService.postTwoFactorEmailSetup(request);
}
postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any> {
return this.twoFactorApiService.postTwoFactorEmail(request);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./default-two-factor-api.service";
export * from "./default-two-factor.service";

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

@@ -25,6 +25,10 @@ export abstract class BillingApiServiceAbstraction {
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationBillingMetadataVNextSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
abstract getPremiumPlan(): Promise<PremiumPlanResponse>;

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

@@ -40,6 +40,7 @@ export class BillingCustomerDiscount extends BaseResponse {
id: string;
active: boolean;
percentOff?: number;
amountOff?: number;
appliesTo: string[];
constructor(response: any) {
@@ -47,6 +48,7 @@ export class BillingCustomerDiscount extends BaseResponse {
this.id = this.getResponseProperty("Id");
this.active = this.getResponseProperty("Active");
this.percentOff = this.getResponseProperty("PercentOff");
this.appliesTo = this.getResponseProperty("AppliesTo");
this.amountOff = this.getResponseProperty("AmountOff");
this.appliesTo = this.getResponseProperty("AppliesTo") || [];
}
}

View File

@@ -2,12 +2,15 @@
// @ts-strict-ignore
import { BaseResponse } from "../../../models/response/base.response";
import { BillingCustomerDiscount } from "./organization-subscription.response";
export class SubscriptionResponse extends BaseResponse {
storageName: string;
storageGb: number;
maxStorageGb: number;
subscription: BillingSubscriptionResponse;
upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse;
customerDiscount: BillingCustomerDiscount;
license: any;
expiration: string;
@@ -20,11 +23,14 @@ export class SubscriptionResponse extends BaseResponse {
this.expiration = this.getResponseProperty("Expiration");
const subscription = this.getResponseProperty("Subscription");
const upcomingInvoice = this.getResponseProperty("UpcomingInvoice");
const customerDiscount = this.getResponseProperty("CustomerDiscount");
this.subscription = subscription == null ? null : new BillingSubscriptionResponse(subscription);
this.upcomingInvoice =
upcomingInvoice == null
? null
: new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice);
this.customerDiscount =
customerDiscount == null ? null : new BillingCustomerDiscount(customerDiscount);
}
}

View File

@@ -62,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationBillingMetadataVNextSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/vnext/self-host/metadata",
null,
true,
true,
);
return new OrganizationBillingMetadataResponse(r);
}
async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.apiService.send("GET", "/plans", null, true, true);
return new ListResponse(r, PlanResponse);

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;

View File

@@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { newGuid } from "@bitwarden/guid";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
@@ -15,6 +16,7 @@ describe("DefaultOrganizationMetadataService", () => {
let service: DefaultOrganizationMetadataService;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let configService: jest.Mocked<ConfigService>;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let featureFlagSubject: BehaviorSubject<boolean>;
const mockOrganizationId = newGuid() as OrganizationId;
@@ -33,11 +35,17 @@ describe("DefaultOrganizationMetadataService", () => {
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
platformUtilsService = mock<PlatformUtilsService>();
featureFlagSubject = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
platformUtilsService.isSelfHost.mockReturnValue(false);
service = new DefaultOrganizationMetadataService(billingApiService, configService);
service = new DefaultOrganizationMetadataService(
billingApiService,
configService,
platformUtilsService,
);
});
afterEach(() => {
@@ -142,6 +150,24 @@ describe("DefaultOrganizationMetadataService", () => {
expect(result3).toEqual(mockResponse1);
expect(result4).toEqual(mockResponse2);
});
it("calls getOrganizationBillingMetadataVNextSelfHost when feature flag is on and isSelfHost is true", async () => {
platformUtilsService.isSelfHost.mockReturnValue(true);
const mockResponse = createMockMetadataResponse(true, 25);
billingApiService.getOrganizationBillingMetadataVNextSelfHost.mockResolvedValue(
mockResponse,
);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadataVNextSelfHost).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
});
describe("shareReplay behavior", () => {

View File

@@ -1,6 +1,7 @@
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
@@ -17,6 +18,7 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
) {}
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
@@ -67,7 +69,9 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
featureFlagEnabled: boolean,
): Promise<OrganizationBillingMetadataResponse> {
return featureFlagEnabled
? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
? this.platformUtilsService.isSelfHost()
? await this.billingApiService.getOrganizationBillingMetadataVNextSelfHost(organizationId)
: await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
}
}

View File

@@ -25,7 +25,7 @@ describe("DefaultSubscriptionPricingService", () => {
let logService: MockProxy<LogService>;
const mockFamiliesPlan = {
type: PlanType.FamiliesAnnually,
type: PlanType.FamiliesAnnually2025,
productTier: ProductTierType.Families,
name: "Families (Annually)",
isAnnual: true,

View File

@@ -1,5 +1,6 @@
import {
combineLatest,
combineLatestWith,
from,
map,
Observable,
@@ -141,8 +142,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;
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,

View File

@@ -13,9 +13,10 @@ export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
/* Auth */
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
/* Autofill */
MacOsNativeCredentialSync = "macos-native-credential-sync",
@@ -31,6 +32,8 @@ export enum FeatureFlag {
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",
PM23341_Milestone_2 = "pm-23341-milestone-2",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -41,6 +44,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",
@@ -58,6 +62,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",
@@ -85,6 +91,7 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
@@ -106,9 +113,11 @@ 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,
@@ -120,6 +129,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
[FeatureFlag.PM26462_Milestone_3]: FALSE,
[FeatureFlag.PM23341_Milestone_2]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
@@ -130,6 +141,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;
}

Some files were not shown because too many files have changed in this diff Show More