mirror of
https://github.com/bitwarden/browser
synced 2026-02-27 10:03:23 +00:00
Merge branch 'km/auto-kdf' into km/auto-kdf-qa
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import {
|
||||
ButtonModule,
|
||||
ButtonType,
|
||||
CenterPositionStrategy,
|
||||
DialogModule,
|
||||
DialogRef,
|
||||
DialogService,
|
||||
@@ -114,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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
|
||||
import {
|
||||
LinkModule,
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogModule,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
templateUrl: "prompt-migration-password.component.html",
|
||||
imports: [
|
||||
DialogModule,
|
||||
LinkModule,
|
||||
CommonModule,
|
||||
JslibModule,
|
||||
ButtonModule,
|
||||
|
||||
@@ -103,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";
|
||||
@@ -126,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,
|
||||
@@ -184,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,
|
||||
@@ -229,6 +234,7 @@ import { SystemService } from "@bitwarden/common/platform/abstractions/system.se
|
||||
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";
|
||||
@@ -547,7 +553,7 @@ const safeProviders: SafeProvider[] = [
|
||||
KeyConnectorServiceAbstraction,
|
||||
EnvironmentService,
|
||||
StateServiceAbstraction,
|
||||
TwoFactorServiceAbstraction,
|
||||
TwoFactorService,
|
||||
I18nServiceAbstraction,
|
||||
EncryptService,
|
||||
PasswordStrengthServiceAbstraction,
|
||||
@@ -701,7 +707,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
useClass: UserDecryptionOptionsService,
|
||||
deps: [StateProvider],
|
||||
deps: [SingleUserStateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
@@ -1108,7 +1114,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: MasterPasswordUnlockService,
|
||||
useClass: DefaultMasterPasswordUnlockService,
|
||||
deps: [InternalMasterPasswordServiceAbstraction, KeyService],
|
||||
deps: [InternalMasterPasswordServiceAbstraction, KeyService, LogService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyConnectorServiceAbstraction,
|
||||
@@ -1185,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,
|
||||
@@ -1304,6 +1315,7 @@ const safeProviders: SafeProvider[] = [
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
LogService,
|
||||
ConfigService,
|
||||
AccountServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -1471,7 +1483,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: OrganizationMetadataServiceAbstraction,
|
||||
useClass: DefaultOrganizationMetadataService,
|
||||
deps: [BillingApiServiceAbstraction, ConfigService],
|
||||
deps: [BillingApiServiceAbstraction, ConfigService, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: BillingAccountProfileStateService,
|
||||
@@ -1784,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,
|
||||
|
||||
@@ -3,20 +3,20 @@
|
||||
>
|
||||
<div class="tw-flex tw-justify-between tw-items-start tw-flex-grow">
|
||||
<div>
|
||||
<h2 bitTypography="h4" class="tw-font-medium !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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
|
||||
|
||||
try {
|
||||
const userDecryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(this.activeAccountId),
|
||||
);
|
||||
|
||||
if (
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export * from "./rotateable-key-set";
|
||||
export * from "./login-credentials";
|
||||
export * from "./user-decryption-options";
|
||||
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
@@ -13,10 +13,10 @@ 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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("Organization", () => {
|
||||
useSecretsManager: true,
|
||||
usePasswordManager: true,
|
||||
useActivateAutofillPolicy: false,
|
||||
useAutomaticUserConfirmation: false,
|
||||
selfHost: false,
|
||||
usersGetPremium: false,
|
||||
seats: 10,
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
/**
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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$);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -526,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),
|
||||
);
|
||||
|
||||
@@ -258,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> {
|
||||
|
||||
2
libs/common/src/auth/two-factor/abstractions/index.ts
Normal file
2
libs/common/src/auth/two-factor/abstractions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./two-factor-api.service";
|
||||
export * from "./two-factor.service";
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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) {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
2
libs/common/src/auth/two-factor/services/index.ts
Normal file
2
libs/common/src/auth/two-factor/services/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./default-two-factor-api.service";
|
||||
export * from "./default-two-factor.service";
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ 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 */
|
||||
@@ -91,6 +91,7 @@ export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.CreateDefaultLocation]: FALSE,
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
@@ -116,7 +117,6 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map, Observable, Subject } from "rxjs";
|
||||
import { firstValueFrom, map, Observable, Subject, switchMap } 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 { RotateableKeySet, 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 { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
|
||||
import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices-api.service.abstraction";
|
||||
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
|
||||
@@ -33,6 +34,7 @@ import { KeyGenerationService } from "../../crypto";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
import { RotateableKeySet } from "../../keys/models/rotateable-key-set";
|
||||
import { DeviceTrustServiceAbstraction } from "../abstractions/device-trust.service.abstraction";
|
||||
|
||||
/** Uses disk storage so that the device key can persist after log out and tab removal. */
|
||||
@@ -86,10 +88,18 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private configService: ConfigService,
|
||||
private accountService: AccountService,
|
||||
) {
|
||||
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
|
||||
map((options) => {
|
||||
return options?.trustedDeviceOption != null;
|
||||
this.supportsDeviceTrust$ = this.accountService.activeAccount$.pipe(
|
||||
switchMap((account) => {
|
||||
if (account == null) {
|
||||
return [false];
|
||||
}
|
||||
return this.userDecryptionOptionsService.userDecryptionOptionsById$(account.id).pipe(
|
||||
map((options) => {
|
||||
return options?.trustedDeviceOption != null;
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -145,7 +155,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
}
|
||||
|
||||
// Attempt to get user key
|
||||
const userKey: UserKey = await this.keyService.getUserKey(userId);
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
|
||||
// If user key is not found, throw error
|
||||
if (!userKey) {
|
||||
@@ -240,7 +250,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
|
||||
const request = new OtherDeviceKeysUpdateRequest();
|
||||
request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString;
|
||||
request.encryptedUserKey = newRotateableKeySet.encryptedUserKey.encryptedString;
|
||||
request.encryptedUserKey = newRotateableKeySet.encapsulatedDownstreamKey.encryptedString;
|
||||
request.deviceId = device.id;
|
||||
return request;
|
||||
})
|
||||
|
||||
@@ -366,7 +366,6 @@ describe("deviceTrustService", () => {
|
||||
|
||||
let makeDeviceKeySpy: jest.SpyInstance;
|
||||
let rsaGenerateKeyPairSpy: jest.SpyInstance;
|
||||
let cryptoSvcGetUserKeySpy: jest.SpyInstance;
|
||||
let cryptoSvcRsaEncryptSpy: jest.SpyInstance;
|
||||
let encryptServiceWrapDecapsulationKeySpy: jest.SpyInstance;
|
||||
let encryptServiceWrapEncapsulationKeySpy: jest.SpyInstance;
|
||||
@@ -402,6 +401,8 @@ describe("deviceTrustService", () => {
|
||||
"mockDeviceKeyEncryptedDevicePrivateKey",
|
||||
);
|
||||
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
// TypeScript will allow calling private methods if the object is of type 'any'
|
||||
makeDeviceKeySpy = jest
|
||||
.spyOn(deviceTrustService as any, "makeDeviceKey")
|
||||
@@ -411,10 +412,6 @@ describe("deviceTrustService", () => {
|
||||
.spyOn(cryptoFunctionService, "rsaGenerateKeyPair")
|
||||
.mockResolvedValue(mockDeviceRsaKeyPair);
|
||||
|
||||
cryptoSvcGetUserKeySpy = jest
|
||||
.spyOn(keyService, "getUserKey")
|
||||
.mockResolvedValue(mockUserKey);
|
||||
|
||||
cryptoSvcRsaEncryptSpy = jest
|
||||
.spyOn(encryptService, "encapsulateKeyUnsigned")
|
||||
.mockResolvedValue(mockDevicePublicKeyEncryptedUserKey);
|
||||
@@ -448,7 +445,7 @@ describe("deviceTrustService", () => {
|
||||
|
||||
expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1);
|
||||
expect(cryptoSvcGetUserKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(keyService.userKey$).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -473,18 +470,13 @@ describe("deviceTrustService", () => {
|
||||
});
|
||||
|
||||
it("throws specific error if user key is not found", async () => {
|
||||
// setup the spy to return null
|
||||
cryptoSvcGetUserKeySpy.mockResolvedValue(null);
|
||||
keyService.userKey$.mockReturnValueOnce(of(null));
|
||||
// check if the expected error is thrown
|
||||
await expect(deviceTrustService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
"User symmetric key not found",
|
||||
);
|
||||
|
||||
// reset the spy
|
||||
cryptoSvcGetUserKeySpy.mockReset();
|
||||
|
||||
// setup the spy to return undefined
|
||||
cryptoSvcGetUserKeySpy.mockResolvedValue(undefined);
|
||||
keyService.userKey$.mockReturnValueOnce(of(undefined));
|
||||
// check if the expected error is thrown
|
||||
await expect(deviceTrustService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
"User symmetric key not found",
|
||||
@@ -502,11 +494,6 @@ describe("deviceTrustService", () => {
|
||||
spy: () => rsaGenerateKeyPairSpy,
|
||||
errorText: "rsaGenerateKeyPair error",
|
||||
},
|
||||
{
|
||||
method: "getUserKey",
|
||||
spy: () => cryptoSvcGetUserKeySpy,
|
||||
errorText: "getUserKey error",
|
||||
},
|
||||
{
|
||||
method: "rsaEncrypt",
|
||||
spy: () => cryptoSvcRsaEncryptSpy,
|
||||
@@ -927,7 +914,7 @@ describe("deviceTrustService", () => {
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage);
|
||||
|
||||
decryptionOptions.next({} as any);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(decryptionOptions);
|
||||
|
||||
return new DeviceTrustService(
|
||||
keyGenerationService,
|
||||
@@ -943,6 +930,7 @@ describe("deviceTrustService", () => {
|
||||
userDecryptionOptionsService,
|
||||
logService,
|
||||
configService,
|
||||
accountService,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ export class DefaultEncryptedMigrator implements EncryptedMigrator {
|
||||
}
|
||||
}
|
||||
this.logService.mark("[Encrypted Migrator] Finish");
|
||||
this.logService.info(`[Encrypted Miigrator] Completed migrations for user: ${userId}`);
|
||||
this.logService.info(`[Encrypted Migrator] Completed migrations for user: ${userId}`);
|
||||
if (ranMigration) {
|
||||
await this.syncService.fullSync(true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { PrfKey } from "../../../types/key";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
|
||||
declare const tag: unique symbol;
|
||||
|
||||
/**
|
||||
* A set of keys where a symmetric `DownstreamKey` is protected by an encrypted public/private key-pair.
|
||||
* The `DownstreamKey` is used to encrypt/decrypt data, while the public/private key-pair is
|
||||
* used to rotate the `DownstreamKey`.
|
||||
*
|
||||
* The `PrivateKey` is protected by an `UpstreamKey`, such as a `DeviceKey`, or `PrfKey`,
|
||||
* and the `PublicKey` is protected by the `DownstreamKey`. This setup allows:
|
||||
*
|
||||
* - Access to `DownstreamKey` by knowing the `UpstreamKey`
|
||||
* - Rotation to a new `DownstreamKey` by knowing the current `DownstreamKey`,
|
||||
* without needing access to the `UpstreamKey`
|
||||
*/
|
||||
export class RotateableKeySet<UpstreamKey extends SymmetricCryptoKey = SymmetricCryptoKey> {
|
||||
private readonly [tag]!: UpstreamKey;
|
||||
|
||||
constructor(
|
||||
/** `DownstreamKey` protected by publicKey */
|
||||
readonly encapsulatedDownstreamKey: EncString,
|
||||
|
||||
/** DownstreamKey encrypted PublicKey */
|
||||
readonly encryptedPublicKey: EncString,
|
||||
|
||||
/** UpstreamKey encrypted PrivateKey */
|
||||
readonly encryptedPrivateKey?: EncString,
|
||||
) {}
|
||||
}
|
||||
|
||||
export type PrfKeySet = RotateableKeySet<PrfKey>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { RotateableKeySet } from "../../models/rotateable-key-set";
|
||||
|
||||
export abstract class RotateableKeySetService {
|
||||
/**
|
||||
* Create a new rotatable key set for the provided downstreamKey, using the provided upstream key.
|
||||
* For more information on rotatable key sets, see {@link RotateableKeySet}
|
||||
* @param upstreamKey The `UpstreamKey` used to encrypt {@link RotateableKeySet.encryptedPrivateKey}
|
||||
* @param downstreamKey The symmetric key to be contained within the `RotateableKeySet`.
|
||||
* @returns RotateableKeySet containing the provided symmetric downstreamKey.
|
||||
*/
|
||||
abstract createKeySet<UpstreamKey extends SymmetricCryptoKey>(
|
||||
upstreamKey: UpstreamKey,
|
||||
downstreamKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<UpstreamKey>>;
|
||||
|
||||
/**
|
||||
* Rotates the provided `RotateableKeySet` with the new key.
|
||||
*
|
||||
* @param keySet The current `RotateableKeySet` to be rotated.
|
||||
* @param oldDownstreamKey The current downstreamKey used to decrypt the `PublicKey`.
|
||||
* @param newDownstreamKey The new downstreamKey to encrypt the `PublicKey`.
|
||||
* @returns The updated `RotateableKeySet` that contains the new downstreamKey.
|
||||
*/
|
||||
abstract rotateKeySet<UpstreamKey extends SymmetricCryptoKey>(
|
||||
keySet: RotateableKeySet<UpstreamKey>,
|
||||
oldDownstreamKey: SymmetricCryptoKey,
|
||||
newDownstreamKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<UpstreamKey>>;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
// 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 { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../crypto/models/enc-string";
|
||||
import { RotateableKeySet } from "../models/rotateable-key-set";
|
||||
|
||||
import { DefaultRotateableKeySetService } from "./default-rotateable-key-set.service";
|
||||
|
||||
describe("DefaultRotateableKeySetService", () => {
|
||||
let keyService!: MockProxy<KeyService>;
|
||||
let encryptService!: MockProxy<EncryptService>;
|
||||
let service!: DefaultRotateableKeySetService;
|
||||
|
||||
beforeEach(() => {
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
service = new DefaultRotateableKeySetService(keyService, encryptService);
|
||||
});
|
||||
|
||||
describe("createKeySet", () => {
|
||||
test.each([null, undefined])(
|
||||
"throws error when downstreamKey parameter is %s",
|
||||
async (downstreamKey) => {
|
||||
const externalKey = createSymmetricKey();
|
||||
await expect(service.createKeySet(externalKey, downstreamKey as any)).rejects.toThrow(
|
||||
"failed to create key set: downstreamKey is required",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([null, undefined])(
|
||||
"throws error when upstreamKey parameter is %s",
|
||||
async (upstreamKey) => {
|
||||
const userKey = createSymmetricKey();
|
||||
await expect(service.createKeySet(upstreamKey as any, userKey)).rejects.toThrow(
|
||||
"failed to create key set: upstreamKey is required",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("should create a new key set", async () => {
|
||||
const externalKey = createSymmetricKey();
|
||||
const userKey = createSymmetricKey();
|
||||
const encryptedUserKey = new EncString("encryptedUserKey");
|
||||
const encryptedPublicKey = new EncString("encryptedPublicKey");
|
||||
const encryptedPrivateKey = new EncString("encryptedPrivateKey");
|
||||
keyService.makeKeyPair.mockResolvedValue(["publicKey", encryptedPrivateKey]);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedUserKey);
|
||||
encryptService.wrapEncapsulationKey.mockResolvedValue(encryptedPublicKey);
|
||||
|
||||
const result = await service.createKeySet(externalKey, userKey);
|
||||
|
||||
expect(result).toEqual(
|
||||
new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey),
|
||||
);
|
||||
expect(keyService.makeKeyPair).toHaveBeenCalledWith(externalKey);
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
userKey,
|
||||
Utils.fromB64ToArray("publicKey"),
|
||||
);
|
||||
expect(encryptService.wrapEncapsulationKey).toHaveBeenCalledWith(
|
||||
Utils.fromB64ToArray("publicKey"),
|
||||
userKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotateKeySet", () => {
|
||||
const keySet = new RotateableKeySet(
|
||||
new EncString("encUserKey"),
|
||||
new EncString("encPublicKey"),
|
||||
new EncString("encPrivateKey"),
|
||||
);
|
||||
const dataValidationTests = [
|
||||
{
|
||||
keySet: null as any as RotateableKeySet,
|
||||
oldDownstreamKey: createSymmetricKey(),
|
||||
newDownstreamKey: createSymmetricKey(),
|
||||
expectedError: "failed to rotate key set: keySet is required",
|
||||
},
|
||||
{
|
||||
keySet: undefined as any as RotateableKeySet,
|
||||
oldDownstreamKey: createSymmetricKey(),
|
||||
newDownstreamKey: createSymmetricKey(),
|
||||
expectedError: "failed to rotate key set: keySet is required",
|
||||
},
|
||||
{
|
||||
keySet: keySet,
|
||||
oldDownstreamKey: null,
|
||||
newDownstreamKey: createSymmetricKey(),
|
||||
expectedError: "failed to rotate key set: oldDownstreamKey is required",
|
||||
},
|
||||
{
|
||||
keySet: keySet,
|
||||
oldDownstreamKey: undefined,
|
||||
newDownstreamKey: createSymmetricKey(),
|
||||
expectedError: "failed to rotate key set: oldDownstreamKey is required",
|
||||
},
|
||||
{
|
||||
keySet: keySet,
|
||||
oldDownstreamKey: createSymmetricKey(),
|
||||
newDownstreamKey: null,
|
||||
expectedError: "failed to rotate key set: newDownstreamKey is required",
|
||||
},
|
||||
{
|
||||
keySet: keySet,
|
||||
oldDownstreamKey: createSymmetricKey(),
|
||||
newDownstreamKey: undefined,
|
||||
expectedError: "failed to rotate key set: newDownstreamKey is required",
|
||||
},
|
||||
];
|
||||
|
||||
test.each(dataValidationTests)(
|
||||
"should throw error when required parameter is missing",
|
||||
async ({ keySet, oldDownstreamKey, newDownstreamKey, expectedError }) => {
|
||||
await expect(
|
||||
service.rotateKeySet(keySet, oldDownstreamKey as any, newDownstreamKey as any),
|
||||
).rejects.toThrow(expectedError);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an error if the public key cannot be decrypted", async () => {
|
||||
const oldDownstreamKey = createSymmetricKey();
|
||||
const newDownstreamKey = createSymmetricKey();
|
||||
|
||||
encryptService.unwrapEncapsulationKey.mockResolvedValue(null as any);
|
||||
|
||||
await expect(
|
||||
service.rotateKeySet(keySet, oldDownstreamKey, newDownstreamKey),
|
||||
).rejects.toThrow("failed to rotate key set: could not decrypt public key");
|
||||
|
||||
expect(encryptService.unwrapEncapsulationKey).toHaveBeenCalledWith(
|
||||
keySet.encryptedPublicKey,
|
||||
oldDownstreamKey,
|
||||
);
|
||||
|
||||
expect(encryptService.wrapEncapsulationKey).not.toHaveBeenCalled();
|
||||
expect(encryptService.encapsulateKeyUnsigned).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rotates the key set", async () => {
|
||||
const oldDownstreamKey = createSymmetricKey();
|
||||
const newDownstreamKey = new SymmetricCryptoKey(new Uint8Array(64));
|
||||
const publicKey = Utils.fromB64ToArray("decryptedPublicKey");
|
||||
const newEncryptedPublicKey = new EncString("newEncPublicKey");
|
||||
const newEncryptedRotateableKey = new EncString("newEncUserKey");
|
||||
|
||||
encryptService.unwrapEncapsulationKey.mockResolvedValue(publicKey);
|
||||
encryptService.wrapEncapsulationKey.mockResolvedValue(newEncryptedPublicKey);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(newEncryptedRotateableKey);
|
||||
|
||||
const result = await service.rotateKeySet(keySet, oldDownstreamKey, newDownstreamKey);
|
||||
|
||||
expect(result).toEqual(
|
||||
new RotateableKeySet(
|
||||
newEncryptedRotateableKey,
|
||||
newEncryptedPublicKey,
|
||||
keySet.encryptedPrivateKey,
|
||||
),
|
||||
);
|
||||
expect(encryptService.unwrapEncapsulationKey).toHaveBeenCalledWith(
|
||||
keySet.encryptedPublicKey,
|
||||
oldDownstreamKey,
|
||||
);
|
||||
expect(encryptService.wrapEncapsulationKey).toHaveBeenCalledWith(publicKey, newDownstreamKey);
|
||||
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
|
||||
newDownstreamKey,
|
||||
publicKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createSymmetricKey() {
|
||||
const key = Utils.fromB64ToArray(
|
||||
"1h-TuPwSbX5qoX0aVgjmda_Lfq85qAcKssBlXZnPIsQC3HNDGIecunYqXhJnp55QpdXRh-egJiLH3a0wqlVQsQ",
|
||||
);
|
||||
return new SymmetricCryptoKey(key);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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 { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { RotateableKeySet } from "../models/rotateable-key-set";
|
||||
|
||||
import { RotateableKeySetService } from "./abstractions/rotateable-key-set.service";
|
||||
|
||||
export class DefaultRotateableKeySetService implements RotateableKeySetService {
|
||||
constructor(
|
||||
private keyService: KeyService,
|
||||
private encryptService: EncryptService,
|
||||
) {}
|
||||
|
||||
async createKeySet<UpstreamKey extends SymmetricCryptoKey>(
|
||||
upstreamKey: UpstreamKey,
|
||||
downstreamKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<UpstreamKey>> {
|
||||
if (!upstreamKey) {
|
||||
throw new Error("failed to create key set: upstreamKey is required");
|
||||
}
|
||||
if (!downstreamKey) {
|
||||
throw new Error("failed to create key set: downstreamKey is required");
|
||||
}
|
||||
|
||||
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(upstreamKey);
|
||||
|
||||
const rawPublicKey = Utils.fromB64ToArray(publicKey);
|
||||
const encryptedRotateableKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
downstreamKey,
|
||||
rawPublicKey,
|
||||
);
|
||||
const encryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
rawPublicKey,
|
||||
downstreamKey,
|
||||
);
|
||||
return new RotateableKeySet(encryptedRotateableKey, encryptedPublicKey, encryptedPrivateKey);
|
||||
}
|
||||
|
||||
async rotateKeySet<UpstreamKey extends SymmetricCryptoKey>(
|
||||
keySet: RotateableKeySet<UpstreamKey>,
|
||||
oldDownstreamKey: SymmetricCryptoKey,
|
||||
newDownstreamKey: SymmetricCryptoKey,
|
||||
): Promise<RotateableKeySet<UpstreamKey>> {
|
||||
// validate parameters
|
||||
if (!keySet) {
|
||||
throw new Error("failed to rotate key set: keySet is required");
|
||||
}
|
||||
if (!oldDownstreamKey) {
|
||||
throw new Error("failed to rotate key set: oldDownstreamKey is required");
|
||||
}
|
||||
if (!newDownstreamKey) {
|
||||
throw new Error("failed to rotate key set: newDownstreamKey is required");
|
||||
}
|
||||
|
||||
const publicKey = await this.encryptService.unwrapEncapsulationKey(
|
||||
keySet.encryptedPublicKey,
|
||||
oldDownstreamKey,
|
||||
);
|
||||
if (publicKey == null) {
|
||||
throw new Error("failed to rotate key set: could not decrypt public key");
|
||||
}
|
||||
const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
publicKey,
|
||||
newDownstreamKey,
|
||||
);
|
||||
const newEncryptedRotateableKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
newDownstreamKey,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
const newRotateableKeySet = new RotateableKeySet<UpstreamKey>(
|
||||
newEncryptedRotateableKey,
|
||||
newEncryptedPublicKey,
|
||||
keySet.encryptedPrivateKey,
|
||||
);
|
||||
|
||||
return newRotateableKeySet;
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,21 @@ export abstract class MasterPasswordUnlockService {
|
||||
* Unlocks the user's account using the master password.
|
||||
* @param masterPassword The master password provided by the user.
|
||||
* @param userId The ID of the active user.
|
||||
* @throws If the master password provided is null/undefined/empty.
|
||||
* @throws If the userId provided is null/undefined.
|
||||
* @throws if the masterPasswordUnlockData for the user is not found.
|
||||
* @throws If unwrapping the user key fails.
|
||||
* @returns the user's decrypted userKey.
|
||||
*/
|
||||
abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey>;
|
||||
|
||||
/**
|
||||
* For the given master password and user ID, verifies whether the user can decrypt their user key stored in state.
|
||||
* @param masterPassword The master password provided by the user.
|
||||
* @param userId The ID of the active user.
|
||||
* @throws If the master password provided is null/undefined/empty.
|
||||
* @throws If the userId provided is null/undefined.
|
||||
* @returns true if the userKey can be decrypted, false otherwise.
|
||||
*/
|
||||
abstract proofOfDecryption(masterPassword: string, userId: UserId): Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { of } from "rxjs";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { CryptoError } from "@bitwarden/sdk-internal";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
@@ -23,6 +25,7 @@ describe("DefaultMasterPasswordUnlockService", () => {
|
||||
|
||||
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const mockMasterPassword = "testExample";
|
||||
const mockUserId = newGuid() as UserId;
|
||||
@@ -41,8 +44,9 @@ describe("DefaultMasterPasswordUnlockService", () => {
|
||||
beforeEach(() => {
|
||||
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
||||
keyService = mock<KeyService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService);
|
||||
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService, logService);
|
||||
|
||||
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(
|
||||
of(mockMasterPasswordUnlockData),
|
||||
@@ -73,7 +77,7 @@ describe("DefaultMasterPasswordUnlockService", () => {
|
||||
);
|
||||
|
||||
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||
"throws when the provided master password is %s",
|
||||
"throws when the provided userID is %s",
|
||||
async (userId) => {
|
||||
await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow(
|
||||
"User ID is required",
|
||||
@@ -151,4 +155,90 @@ describe("DefaultMasterPasswordUnlockService", () => {
|
||||
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("proofOfDecryption", () => {
|
||||
test.each([null as unknown as string, undefined as unknown as string, ""])(
|
||||
"throws when the provided master password is %s",
|
||||
async (masterPassword) => {
|
||||
await expect(sut.proofOfDecryption(masterPassword, mockUserId)).rejects.toThrow(
|
||||
"Master password is required",
|
||||
);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled();
|
||||
expect(
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
|
||||
).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([null as unknown as UserId, undefined as unknown as UserId])(
|
||||
"throws when the provided userID is %s",
|
||||
async (userId) => {
|
||||
await expect(sut.proofOfDecryption(mockMasterPassword, userId)).rejects.toThrow(
|
||||
"User ID is required",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("returns false when the user doesn't have masterPasswordUnlockData", async () => {
|
||||
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null));
|
||||
|
||||
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(logService.warning).toHaveBeenCalledWith(
|
||||
`[DefaultMasterPasswordUnlockService] No master password unlock data found for user ${mockUserId} returning false.`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns true when the master password is correct", async () => {
|
||||
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when the master password is incorrect", async () => {
|
||||
const error = new Error("Incorrect password") as CryptoError;
|
||||
error.name = "CryptoError";
|
||||
error.variant = "InvalidKey";
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockRejectedValue(error);
|
||||
|
||||
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
expect(logService.debug).toHaveBeenCalledWith(
|
||||
`[DefaultMasterPasswordUnlockService] Error during proof of decryption for user ${mockUserId} returning false: ${error}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when a generic error occurs", async () => {
|
||||
const error = new Error("Generic error");
|
||||
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockRejectedValue(error);
|
||||
|
||||
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockMasterPasswordUnlockData,
|
||||
);
|
||||
expect(logService.error).toHaveBeenCalledWith(
|
||||
`[DefaultMasterPasswordUnlockService] Unexpected error during proof of decryption for user ${mockUserId} returning false: ${error}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { firstValueFrom } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
import { isCryptoError } from "@bitwarden/sdk-internal";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { HashPurpose } from "../../../platform/enums";
|
||||
@@ -14,6 +16,7 @@ export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockS
|
||||
constructor(
|
||||
private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction,
|
||||
private readonly keyService: KeyService,
|
||||
private readonly logService: LogService,
|
||||
) {}
|
||||
|
||||
async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey> {
|
||||
@@ -37,6 +40,43 @@ export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockS
|
||||
return userKey;
|
||||
}
|
||||
|
||||
async proofOfDecryption(masterPassword: string, userId: UserId): Promise<boolean> {
|
||||
this.validateInput(masterPassword, userId);
|
||||
|
||||
try {
|
||||
const masterPasswordUnlockData = await firstValueFrom(
|
||||
this.masterPasswordService.masterPasswordUnlockData$(userId),
|
||||
);
|
||||
|
||||
if (masterPasswordUnlockData == null) {
|
||||
this.logService.warning(
|
||||
`[DefaultMasterPasswordUnlockService] No master password unlock data found for user ${userId} returning false.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData(
|
||||
masterPassword,
|
||||
masterPasswordUnlockData,
|
||||
);
|
||||
|
||||
return userKey != null;
|
||||
} catch (error) {
|
||||
// masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData is expected to throw if the password is incorrect.
|
||||
// Currently this throws CryptoError:InvalidKey if decrypting the user key fails at all.
|
||||
if (isCryptoError(error) && error.variant === "InvalidKey") {
|
||||
this.logService.debug(
|
||||
`[DefaultMasterPasswordUnlockService] Error during proof of decryption for user ${userId} returning false: ${error}`,
|
||||
);
|
||||
} else {
|
||||
this.logService.error(
|
||||
`[DefaultMasterPasswordUnlockService] Unexpected error during proof of decryption for user ${userId} returning false: ${error}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private validateInput(masterPassword: string, userId: UserId): void {
|
||||
if (masterPassword == null || masterPassword === "") {
|
||||
throw new Error("Master password is required");
|
||||
|
||||
@@ -25,7 +25,10 @@ export type MasterPasswordSalt = Opaque<string, "MasterPasswordSalt">;
|
||||
export type MasterKeyWrappedUserKey = Opaque<EncString, "MasterKeyWrappedUserKey">;
|
||||
|
||||
/**
|
||||
* The data required to unlock with the master password.
|
||||
* Encapsulates the data needed to unlock a vault using a master password.
|
||||
* It contains the masterKeyWrappedUserKey along with the KDF settings and salt used to derive the master key.
|
||||
* It is currently backwards compatible to master-key based unlock, but this will not be the case in the future.
|
||||
* Features relating to master-password-based unlock should use this abstraction.
|
||||
*/
|
||||
export class MasterPasswordUnlockData {
|
||||
constructor(
|
||||
@@ -66,7 +69,9 @@ export class MasterPasswordUnlockData {
|
||||
}
|
||||
|
||||
/**
|
||||
* The data required to authenticate with the master password.
|
||||
* Encapsulates the data required to authenticate using a master password.
|
||||
* It contains the masterPasswordAuthenticationHash, along with the KDF settings and salt used to derive it.
|
||||
* The encapsulated abstraction prevents authentication issues resulting from unsynchronized state.
|
||||
*/
|
||||
export type MasterPasswordAuthenticationData = {
|
||||
salt: MasterPasswordSalt;
|
||||
|
||||
@@ -53,9 +53,11 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
policyService = mock<PolicyService>();
|
||||
|
||||
userDecryptionOptionsSubject = new BehaviorSubject(null);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
|
||||
userDecryptionOptionsService.hasMasterPassword$ = userDecryptionOptionsSubject.pipe(
|
||||
map((options) => options?.hasMasterPassword ?? false),
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
userDecryptionOptionsSubject,
|
||||
);
|
||||
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(
|
||||
userDecryptionOptionsSubject.pipe(map((options) => options?.hasMasterPassword ?? false)),
|
||||
);
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
userDecryptionOptionsSubject,
|
||||
@@ -127,6 +129,23 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
|
||||
it("should return only LogOut when userId is not provided and there is no active account", async () => {
|
||||
// Set up accountService to return null for activeAccount
|
||||
accountService.activeAccount$ = of(null);
|
||||
pinStateService.isPinSet.mockResolvedValue(false);
|
||||
biometricStateService.biometricUnlockEnabled$ = of(false);
|
||||
|
||||
// Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
|
||||
);
|
||||
|
||||
// Since there's no active account, userHasMasterPassword returns false,
|
||||
// meaning no master password is available, so Lock should not be available
|
||||
expect(result).toEqual([VaultTimeoutAction.LogOut]);
|
||||
expect(result).not.toContain(VaultTimeoutAction.Lock);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canLock", () => {
|
||||
|
||||
@@ -290,14 +290,19 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
}
|
||||
|
||||
private async userHasMasterPassword(userId: string): Promise<boolean> {
|
||||
let resolvedUserId: UserId;
|
||||
if (userId) {
|
||||
const decryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
);
|
||||
|
||||
return !!decryptionOptions?.hasMasterPassword;
|
||||
resolvedUserId = userId as UserId;
|
||||
} else {
|
||||
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (!activeAccount) {
|
||||
return false; // No account, can't have master password
|
||||
}
|
||||
resolvedUserId = activeAccount.id;
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { BitwardenClient } from "@bitwarden/sdk-internal";
|
||||
import type { PasswordManagerClient } from "@bitwarden/sdk-internal";
|
||||
|
||||
/**
|
||||
* Factory for creating SDK clients.
|
||||
*/
|
||||
export abstract class SdkClientFactory {
|
||||
/**
|
||||
* Creates a new BitwardenClient. Assumes the SDK is already loaded.
|
||||
* @param args Bitwarden client constructor parameters
|
||||
* Creates a new Password Manager client. Assumes the SDK is already loaded.
|
||||
* @param args Password Manager client constructor parameters
|
||||
*/
|
||||
abstract createSdkClient(
|
||||
...args: ConstructorParameters<typeof BitwardenClient>
|
||||
): Promise<BitwardenClient>;
|
||||
...args: ConstructorParameters<typeof PasswordManagerClient>
|
||||
): Promise<PasswordManagerClient>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { BitwardenClient, Uuid } from "@bitwarden/sdk-internal";
|
||||
import { PasswordManagerClient, Uuid } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { Rc } from "../../misc/reference-counting/rc";
|
||||
@@ -46,7 +46,7 @@ export abstract class SdkService {
|
||||
* Retrieve a client initialized without a user.
|
||||
* This client can only be used for operations that don't require a user context.
|
||||
*/
|
||||
abstract client$: Observable<BitwardenClient>;
|
||||
abstract client$: Observable<PasswordManagerClient>;
|
||||
|
||||
/**
|
||||
* Retrieve a client initialized for a specific user.
|
||||
@@ -64,7 +64,7 @@ export abstract class SdkService {
|
||||
*
|
||||
* @param userId The user id for which to retrieve the client
|
||||
*/
|
||||
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient>>;
|
||||
abstract userClient$(userId: UserId): Observable<Rc<PasswordManagerClient>>;
|
||||
|
||||
/**
|
||||
* This method is used during/after an authentication procedure to set a new client for a specific user.
|
||||
@@ -75,5 +75,5 @@ export abstract class SdkService {
|
||||
* @param userId The user id for which to set the client
|
||||
* @param client The client to set for the user. If undefined, the client will be unset.
|
||||
*/
|
||||
abstract setClient(userId: UserId, client: BitwardenClient | undefined): void;
|
||||
abstract setClient(userId: UserId, client: PasswordManagerClient | undefined): void;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./ipc-message";
|
||||
export * from "./ipc.service";
|
||||
export * from "./ipc-session-repository";
|
||||
|
||||
49
libs/common/src/platform/ipc/ipc-session-repository.spec.ts
Normal file
49
libs/common/src/platform/ipc/ipc-session-repository.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { FakeActiveUserAccessor, FakeStateProvider } from "../../../spec";
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
import { IpcSessionRepository } from "./ipc-session-repository";
|
||||
|
||||
describe("IpcSessionRepository", () => {
|
||||
const userId = "user-id" as UserId;
|
||||
let stateProvider!: FakeStateProvider;
|
||||
let repository!: IpcSessionRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
stateProvider = new FakeStateProvider(new FakeActiveUserAccessor(userId));
|
||||
repository = new IpcSessionRepository(stateProvider);
|
||||
});
|
||||
|
||||
it("returns undefined when empty", async () => {
|
||||
const result = await repository.get("BrowserBackground");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("saves and retrieves a session", async () => {
|
||||
const session = { some: "data" };
|
||||
await repository.save("BrowserBackground", session);
|
||||
|
||||
const result = await repository.get("BrowserBackground");
|
||||
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("saves and retrieves a web session", async () => {
|
||||
const session = { some: "data" };
|
||||
await repository.save({ Web: { id: 9001 } }, session);
|
||||
|
||||
const result = await repository.get({ Web: { id: 9001 } });
|
||||
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("removes a session", async () => {
|
||||
const session = { some: "data" };
|
||||
await repository.save("BrowserBackground", session);
|
||||
|
||||
await repository.remove("BrowserBackground");
|
||||
const result = await repository.get("BrowserBackground");
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
51
libs/common/src/platform/ipc/ipc-session-repository.ts
Normal file
51
libs/common/src/platform/ipc/ipc-session-repository.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
import { Endpoint, IpcSessionRepository as SdkIpcSessionRepository } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { GlobalState, IPC_MEMORY, KeyDefinition, StateProvider } from "../state";
|
||||
|
||||
const IPC_SESSIONS = KeyDefinition.record<object, string>(IPC_MEMORY, "ipcSessions", {
|
||||
deserializer: (value: object) => value,
|
||||
});
|
||||
|
||||
/**
|
||||
* Implementation of SDK-defined repository interface/trait. Do not use directly.
|
||||
* All error handling is done by the caller (the SDK).
|
||||
* For more information see IPC docs.
|
||||
*
|
||||
* Interface uses `any` type as defined by the SDK until we get a concrete session type.
|
||||
*/
|
||||
export class IpcSessionRepository implements SdkIpcSessionRepository {
|
||||
private states: GlobalState<Record<string, any>>;
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.states = this.stateProvider.getGlobal(IPC_SESSIONS);
|
||||
}
|
||||
|
||||
get(endpoint: Endpoint): Promise<any | undefined> {
|
||||
return firstValueFrom(this.states.state$.pipe(map((s) => s?.[endpointToString(endpoint)])));
|
||||
}
|
||||
|
||||
async save(endpoint: Endpoint, session: any): Promise<void> {
|
||||
await this.states.update((s) => ({
|
||||
...s,
|
||||
[endpointToString(endpoint)]: session,
|
||||
}));
|
||||
}
|
||||
|
||||
async remove(endpoint: Endpoint): Promise<void> {
|
||||
await this.states.update((s) => {
|
||||
const newState = { ...s };
|
||||
delete newState[endpointToString(endpoint)];
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function endpointToString(endpoint: Endpoint): string {
|
||||
if (typeof endpoint === "object" && "Web" in endpoint) {
|
||||
return `Web(${endpoint.Web.id})`;
|
||||
}
|
||||
|
||||
return endpoint;
|
||||
}
|
||||
@@ -689,6 +689,32 @@ describe("Utils Service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("invalidUrlPatterns", () => {
|
||||
it("should return false if no invalid patterns are found", () => {
|
||||
const urlString = "https://www.example.com/api/my/account/status";
|
||||
|
||||
const actual = Utils.invalidUrlPatterns(urlString);
|
||||
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if an invalid pattern is found", () => {
|
||||
const urlString = "https://www.example.com/api/%2e%2e/secret";
|
||||
|
||||
const actual = Utils.invalidUrlPatterns(urlString);
|
||||
|
||||
expect(actual).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if an invalid pattern is found in a param", () => {
|
||||
const urlString = "https://www.example.com/api/history?someToken=../secret";
|
||||
|
||||
const actual = Utils.invalidUrlPatterns(urlString);
|
||||
|
||||
expect(actual).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrl", () => {
|
||||
it("assumes a http protocol if no protocol is specified", () => {
|
||||
const urlString = "www.exampleapp.com.au:4000";
|
||||
|
||||
@@ -612,6 +612,55 @@ export class Utils {
|
||||
return path.normalize(decodeURIComponent(denormalizedPath)).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates an url checking against invalid patterns
|
||||
* @param url
|
||||
* @returns true if invalid patterns found, false if safe
|
||||
*/
|
||||
static invalidUrlPatterns(url: string): boolean {
|
||||
const invalidUrlPatterns = ["..", "%2e", "\\", "%5c"];
|
||||
|
||||
const decodedUrl = decodeURIComponent(url.toLocaleLowerCase());
|
||||
|
||||
// Check URL for invalidUrl patterns across entire URL
|
||||
if (invalidUrlPatterns.some((p) => decodedUrl.includes(p))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for additional invalid patterns inside URL params
|
||||
if (decodedUrl.includes("?")) {
|
||||
const hasInvalidParams = this.validateQueryParameters(decodedUrl);
|
||||
if (hasInvalidParams) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates query parameters for additional invalid patterns
|
||||
* @param url - The URL containing query parameters
|
||||
* @returns true if invalid patterns found, false if safe
|
||||
*/
|
||||
private static validateQueryParameters(url: string): boolean {
|
||||
try {
|
||||
let queryString: string;
|
||||
|
||||
if (url.includes("?")) {
|
||||
queryString = url.split("?")[1];
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
const paramInvalidPatterns = ["/", "%2f", "#", "%23"];
|
||||
|
||||
return paramInvalidPatterns.some((p) => queryString.includes(p));
|
||||
} catch (error) {
|
||||
throw new Error(`Error validating query parameters: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
private static isMobile(win: Window) {
|
||||
let mobile = false;
|
||||
((a) => {
|
||||
|
||||
@@ -7,13 +7,13 @@ import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
|
||||
*/
|
||||
export class DefaultSdkClientFactory implements SdkClientFactory {
|
||||
/**
|
||||
* Initializes a Bitwarden client. Assumes the SDK is already loaded.
|
||||
* @param args Bitwarden client constructor parameters
|
||||
* @returns A BitwardenClient
|
||||
* Initializes a Password Manager client. Assumes the SDK is already loaded.
|
||||
* @param args Password Manager client constructor parameters
|
||||
* @returns A PasswordManagerClient
|
||||
*/
|
||||
async createSdkClient(
|
||||
...args: ConstructorParameters<typeof sdk.BitwardenClient>
|
||||
): Promise<sdk.BitwardenClient> {
|
||||
return Promise.resolve(new sdk.BitwardenClient(...args));
|
||||
...args: ConstructorParameters<typeof sdk.PasswordManagerClient>
|
||||
): Promise<sdk.PasswordManagerClient> {
|
||||
return Promise.resolve(new sdk.PasswordManagerClient(...args));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security-
|
||||
// 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 { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
import { BitwardenClient } from "@bitwarden/sdk-internal";
|
||||
import { PasswordManagerClient } from "@bitwarden/sdk-internal";
|
||||
|
||||
import {
|
||||
ObservableTracker,
|
||||
@@ -109,7 +109,7 @@ describe("DefaultSdkService", () => {
|
||||
});
|
||||
|
||||
describe("given no client override has been set for the user", () => {
|
||||
let mockClient!: MockProxy<BitwardenClient>;
|
||||
let mockClient!: MockProxy<PasswordManagerClient>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockClient = createMockClient();
|
||||
@@ -123,8 +123,8 @@ describe("DefaultSdkService", () => {
|
||||
});
|
||||
|
||||
it("does not create an SDK client when called the second time with same userId", async () => {
|
||||
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
|
||||
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
|
||||
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
|
||||
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
|
||||
|
||||
// Use subjects to ensure the subscription is kept alive
|
||||
service.userClient$(userId).subscribe(subject_1);
|
||||
@@ -139,8 +139,8 @@ describe("DefaultSdkService", () => {
|
||||
});
|
||||
|
||||
it("destroys the internal SDK client when all subscriptions are closed", async () => {
|
||||
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
|
||||
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
|
||||
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
|
||||
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
|
||||
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
|
||||
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
|
||||
await new Promise(process.nextTick);
|
||||
@@ -170,7 +170,7 @@ describe("DefaultSdkService", () => {
|
||||
|
||||
describe("given overrides are used", () => {
|
||||
it("does not create a new client and emits the override client when a client override has already been set ", async () => {
|
||||
const mockClient = mock<BitwardenClient>();
|
||||
const mockClient = mock<PasswordManagerClient>();
|
||||
service.setClient(userId, mockClient);
|
||||
const userClientTracker = new ObservableTracker(service.userClient$(userId), false);
|
||||
await userClientTracker.pauseUntilReceived(1);
|
||||
@@ -242,8 +242,8 @@ describe("DefaultSdkService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function createMockClient(): MockProxy<BitwardenClient> {
|
||||
const client = mock<BitwardenClient>();
|
||||
function createMockClient(): MockProxy<PasswordManagerClient> {
|
||||
const client = mock<PasswordManagerClient>();
|
||||
client.crypto.mockReturnValue(mock());
|
||||
client.platform.mockReturnValue({
|
||||
state: jest.fn().mockReturnValue(mock()),
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||
import {
|
||||
BitwardenClient,
|
||||
PasswordManagerClient,
|
||||
ClientSettings,
|
||||
DeviceType as SdkDeviceType,
|
||||
TokenProvider,
|
||||
@@ -70,9 +70,9 @@ class JsTokenProvider implements TokenProvider {
|
||||
|
||||
export class DefaultSdkService implements SdkService {
|
||||
private sdkClientOverrides = new BehaviorSubject<{
|
||||
[userId: UserId]: Rc<BitwardenClient> | typeof UnsetClient;
|
||||
[userId: UserId]: Rc<PasswordManagerClient> | typeof UnsetClient;
|
||||
}>({});
|
||||
private sdkClientCache = new Map<UserId, Observable<Rc<BitwardenClient>>>();
|
||||
private sdkClientCache = new Map<UserId, Observable<Rc<PasswordManagerClient>>>();
|
||||
|
||||
client$ = this.environmentService.environment$.pipe(
|
||||
concatMap(async (env) => {
|
||||
@@ -107,14 +107,14 @@ export class DefaultSdkService implements SdkService {
|
||||
private userAgent: string | null = null,
|
||||
) {}
|
||||
|
||||
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
|
||||
userClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
|
||||
return this.sdkClientOverrides.pipe(
|
||||
takeWhile((clients) => clients[userId] !== UnsetClient, false),
|
||||
map((clients) => {
|
||||
if (clients[userId] === UnsetClient) {
|
||||
throw new Error("Encountered UnsetClient even though it should have been filtered out");
|
||||
}
|
||||
return clients[userId] as Rc<BitwardenClient>;
|
||||
return clients[userId] as Rc<PasswordManagerClient>;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
switchMap((clientOverride) => {
|
||||
@@ -129,7 +129,7 @@ export class DefaultSdkService implements SdkService {
|
||||
);
|
||||
}
|
||||
|
||||
setClient(userId: UserId, client: BitwardenClient | undefined) {
|
||||
setClient(userId: UserId, client: PasswordManagerClient | undefined) {
|
||||
const previousValue = this.sdkClientOverrides.value[userId];
|
||||
|
||||
this.sdkClientOverrides.next({
|
||||
@@ -149,7 +149,7 @@ export class DefaultSdkService implements SdkService {
|
||||
* @param userId The user id for which to create the client
|
||||
* @returns An observable that emits the client for the user
|
||||
*/
|
||||
private internalClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
|
||||
private internalClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
|
||||
const cached = this.sdkClientCache.get(userId);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
@@ -187,7 +187,7 @@ export class DefaultSdkService implements SdkService {
|
||||
switchMap(
|
||||
([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => {
|
||||
// Create our own observable to be able to implement clean-up logic
|
||||
return new Observable<Rc<BitwardenClient>>((subscriber) => {
|
||||
return new Observable<Rc<PasswordManagerClient>>((subscriber) => {
|
||||
const createAndInitializeClient = async () => {
|
||||
if (env == null || kdfParams == null || privateKey == null || userKey == null) {
|
||||
return undefined;
|
||||
@@ -214,7 +214,7 @@ export class DefaultSdkService implements SdkService {
|
||||
return client;
|
||||
};
|
||||
|
||||
let client: Rc<BitwardenClient> | undefined;
|
||||
let client: Rc<PasswordManagerClient> | undefined;
|
||||
createAndInitializeClient()
|
||||
.then((c) => {
|
||||
client = c === undefined ? undefined : new Rc(c);
|
||||
@@ -239,7 +239,7 @@ export class DefaultSdkService implements SdkService {
|
||||
|
||||
private async initializeClient(
|
||||
userId: UserId,
|
||||
client: BitwardenClient,
|
||||
client: PasswordManagerClient,
|
||||
account: AccountInfo,
|
||||
kdfParams: KdfConfig,
|
||||
privateKey: EncryptedString,
|
||||
@@ -281,7 +281,7 @@ export class DefaultSdkService implements SdkService {
|
||||
await this.loadFeatureFlags(client);
|
||||
}
|
||||
|
||||
private async loadFeatureFlags(client: BitwardenClient) {
|
||||
private async loadFeatureFlags(client: PasswordManagerClient) {
|
||||
const serverConfig = await firstValueFrom(this.configService.serverConfig$);
|
||||
|
||||
const featureFlagMap = new Map(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BitwardenClient } from "@bitwarden/sdk-internal";
|
||||
import type { PasswordManagerClient } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
|
||||
|
||||
@@ -9,8 +9,8 @@ import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
|
||||
*/
|
||||
export class NoopSdkClientFactory implements SdkClientFactory {
|
||||
createSdkClient(
|
||||
...args: ConstructorParameters<typeof BitwardenClient>
|
||||
): Promise<BitwardenClient> {
|
||||
...args: ConstructorParameters<typeof PasswordManagerClient>
|
||||
): Promise<PasswordManagerClient> {
|
||||
return Promise.reject(new Error("SDK not available"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
throwIfEmpty,
|
||||
} from "rxjs";
|
||||
|
||||
import { BitwardenClient } from "@bitwarden/sdk-internal";
|
||||
import { PasswordManagerClient } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { SdkService, UserNotLoggedInError } from "../abstractions/sdk/sdk.service";
|
||||
@@ -17,18 +17,18 @@ import { DeepMockProxy, mockDeep } from "./mock-deep";
|
||||
|
||||
export class MockSdkService implements SdkService {
|
||||
private userClients$ = new BehaviorSubject<{
|
||||
[userId: UserId]: Rc<BitwardenClient> | undefined;
|
||||
[userId: UserId]: Rc<PasswordManagerClient> | undefined;
|
||||
}>({});
|
||||
|
||||
private _client$ = new BehaviorSubject(mockDeep<BitwardenClient>());
|
||||
private _client$ = new BehaviorSubject(mockDeep<PasswordManagerClient>());
|
||||
client$ = this._client$.asObservable();
|
||||
|
||||
version$ = new BehaviorSubject("0.0.1-test").asObservable();
|
||||
|
||||
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
|
||||
userClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
|
||||
return this.userClients$.pipe(
|
||||
takeWhile((clients) => clients[userId] !== undefined, false),
|
||||
map((clients) => clients[userId] as Rc<BitwardenClient>),
|
||||
map((clients) => clients[userId] as Rc<PasswordManagerClient>),
|
||||
distinctUntilChanged(),
|
||||
throwIfEmpty(() => new UserNotLoggedInError(userId)),
|
||||
);
|
||||
@@ -42,7 +42,7 @@ export class MockSdkService implements SdkService {
|
||||
* Returns the non-user scoped client mock.
|
||||
* This is what is returned by the `client$` observable.
|
||||
*/
|
||||
get client(): DeepMockProxy<BitwardenClient> {
|
||||
get client(): DeepMockProxy<PasswordManagerClient> {
|
||||
return this._client$.value;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export class MockSdkService implements SdkService {
|
||||
* @returns A user-scoped mock for the user.
|
||||
*/
|
||||
userLogin: (userId: UserId) => {
|
||||
const client = mockDeep<BitwardenClient>();
|
||||
const client = mockDeep<PasswordManagerClient>();
|
||||
this.userClients$.next({
|
||||
...this.userClients$.getValue(),
|
||||
[userId]: new Rc(client),
|
||||
|
||||
@@ -1588,8 +1588,16 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
);
|
||||
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
|
||||
|
||||
// Prevent directory traversal from malicious paths
|
||||
const pathParts = path.split("?");
|
||||
// Check for path traversal patterns from any URL.
|
||||
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
|
||||
if (isInvalidUrl) {
|
||||
throw new Error("The request URL contains dangerous patterns.");
|
||||
}
|
||||
|
||||
// Prevent directory traversal from malicious paths
|
||||
const requestUrl =
|
||||
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { CipherView } from "../models/view/cipher.view";
|
||||
import { CipherViewLike } from "../utils/cipher-view-like-utils";
|
||||
|
||||
export abstract class SearchService {
|
||||
abstract isCipherSearching$: Observable<boolean>;
|
||||
abstract isSendSearching$: Observable<boolean>;
|
||||
|
||||
abstract indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null>;
|
||||
|
||||
abstract clearIndex(userId: UserId): Promise<void>;
|
||||
|
||||
@@ -414,7 +414,10 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
creationDate: this.creationDate.toISOString(),
|
||||
deletedDate: this.deletedDate?.toISOString(),
|
||||
archivedDate: this.archivedDate?.toISOString(),
|
||||
reprompt: this.reprompt,
|
||||
reprompt:
|
||||
this.reprompt === CipherRepromptType.Password
|
||||
? CipherRepromptType.Password
|
||||
: CipherRepromptType.None,
|
||||
// Initialize all cipher-type-specific properties as undefined
|
||||
login: undefined,
|
||||
identity: undefined,
|
||||
|
||||
@@ -94,16 +94,16 @@ export class IdentityView extends ItemView implements SdkIdentityView {
|
||||
this.lastName != null
|
||||
) {
|
||||
let name = "";
|
||||
if (this.title != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.title)) {
|
||||
name += this.title + " ";
|
||||
}
|
||||
if (this.firstName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.firstName)) {
|
||||
name += this.firstName + " ";
|
||||
}
|
||||
if (this.middleName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.middleName)) {
|
||||
name += this.middleName + " ";
|
||||
}
|
||||
if (this.lastName != null) {
|
||||
if (!Utils.isNullOrWhitespace(this.lastName)) {
|
||||
name += this.lastName;
|
||||
}
|
||||
return name.trim();
|
||||
@@ -130,14 +130,20 @@ export class IdentityView extends ItemView implements SdkIdentityView {
|
||||
}
|
||||
|
||||
get fullAddressPart2(): string | undefined {
|
||||
if (this.city == null && this.state == null && this.postalCode == null) {
|
||||
const hasCity = !Utils.isNullOrWhitespace(this.city);
|
||||
const hasState = !Utils.isNullOrWhitespace(this.state);
|
||||
const hasPostalCode = !Utils.isNullOrWhitespace(this.postalCode);
|
||||
|
||||
if (!hasCity && !hasState && !hasPostalCode) {
|
||||
return undefined;
|
||||
}
|
||||
const city = this.city || "-";
|
||||
|
||||
const city = hasCity ? this.city : "-";
|
||||
const state = this.state;
|
||||
const postalCode = this.postalCode || "-";
|
||||
const postalCode = hasPostalCode ? this.postalCode : "-";
|
||||
|
||||
let addressPart2 = city;
|
||||
if (!Utils.isNullOrWhitespace(state)) {
|
||||
if (hasState) {
|
||||
addressPart2 += ", " + state;
|
||||
}
|
||||
addressPart2 += ", " + postalCode;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import * as lunr from "lunr";
|
||||
import { Observable, firstValueFrom, map } from "rxjs";
|
||||
import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { perUserCache$ } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
@@ -81,6 +81,12 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
private readonly defaultSearchableMinLength: number = 2;
|
||||
private searchableMinLength: number = this.defaultSearchableMinLength;
|
||||
|
||||
private _isCipherSearching$ = new BehaviorSubject<boolean>(false);
|
||||
isCipherSearching$: Observable<boolean> = this._isCipherSearching$.asObservable();
|
||||
|
||||
private _isSendSearching$ = new BehaviorSubject<boolean>(false);
|
||||
isSendSearching$: Observable<boolean> = this._isSendSearching$.asObservable();
|
||||
|
||||
constructor(
|
||||
private logService: LogService,
|
||||
private i18nService: I18nService,
|
||||
@@ -223,6 +229,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
|
||||
ciphers: C[],
|
||||
): Promise<C[]> {
|
||||
this._isCipherSearching$.next(true);
|
||||
const results: C[] = [];
|
||||
const searchStartTime = performance.now();
|
||||
if (query != null) {
|
||||
@@ -243,6 +250,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
}
|
||||
|
||||
if (!(await this.isSearchable(userId, query))) {
|
||||
this._isCipherSearching$.next(false);
|
||||
return ciphers;
|
||||
}
|
||||
|
||||
@@ -258,6 +266,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
// Fall back to basic search if index is not available
|
||||
const basicResults = this.searchCiphersBasic(ciphers, query);
|
||||
this.logService.measure(searchStartTime, "Vault", "SearchService", "basic search complete");
|
||||
this._isCipherSearching$.next(false);
|
||||
return basicResults;
|
||||
}
|
||||
|
||||
@@ -293,6 +302,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
});
|
||||
}
|
||||
this.logService.measure(searchStartTime, "Vault", "SearchService", "search complete");
|
||||
this._isCipherSearching$.next(false);
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -335,8 +345,10 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
}
|
||||
|
||||
searchSends(sends: SendView[], query: string) {
|
||||
this._isSendSearching$.next(true);
|
||||
query = SearchService.normalizeSearchQuery(query.trim().toLocaleLowerCase());
|
||||
if (query === null) {
|
||||
this._isSendSearching$.next(false);
|
||||
return sends;
|
||||
}
|
||||
const sendsMatched: SendView[] = [];
|
||||
@@ -359,6 +371,7 @@ export class SearchService implements SearchServiceAbstraction {
|
||||
lowPriorityMatched.push(s);
|
||||
}
|
||||
});
|
||||
this._isSendSearching$.next(false);
|
||||
return sendsMatched.concat(lowPriorityMatched);
|
||||
}
|
||||
|
||||
|
||||
109
libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
Normal file
109
libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { skeletonLoadingDelay } from "./skeleton-loading.operator";
|
||||
|
||||
describe("skeletonLoadingDelay", () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns false immediately when starting with false", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(false);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
expect(results).toEqual([false]);
|
||||
});
|
||||
|
||||
it("waits 1 second before returning true when starting with true", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
expect(results).toEqual([]);
|
||||
|
||||
jest.advanceTimersByTime(999);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(results).toEqual([true]);
|
||||
});
|
||||
|
||||
it("cancels if source becomes false before show delay completes", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
source$.next(false);
|
||||
|
||||
expect(results).toEqual([false]);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(results).toEqual([false]);
|
||||
});
|
||||
|
||||
it("delays hiding if minimum display time has not elapsed", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(results).toEqual([true]);
|
||||
|
||||
source$.next(false);
|
||||
|
||||
expect(results).toEqual([true]);
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(results).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("handles rapid true->false->true transitions", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
|
||||
|
||||
jest.advanceTimersByTime(500);
|
||||
expect(results).toEqual([]);
|
||||
|
||||
source$.next(false);
|
||||
expect(results).toEqual([false]);
|
||||
|
||||
source$.next(true);
|
||||
|
||||
jest.advanceTimersByTime(999);
|
||||
expect(results).toEqual([false]);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(results).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it("allows for custom timings", () => {
|
||||
const source$ = new BehaviorSubject<boolean>(true);
|
||||
const results: boolean[] = [];
|
||||
|
||||
source$.pipe(skeletonLoadingDelay(1000, 2000)).subscribe((value) => results.push(value));
|
||||
|
||||
jest.advanceTimersByTime(1000);
|
||||
expect(results).toEqual([true]);
|
||||
|
||||
source$.next(false);
|
||||
|
||||
jest.advanceTimersByTime(1999);
|
||||
expect(results).toEqual([true]);
|
||||
|
||||
jest.advanceTimersByTime(1);
|
||||
expect(results).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user