mirror of
https://github.com/bitwarden/browser
synced 2025-12-17 16:53:34 +00:00
Conflict resolution
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import { Directive, EventEmitter, Input, OnInit, Output } from "@angular/core";
|
||||
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -19,6 +21,8 @@ export class CollectionsComponent implements OnInit {
|
||||
cipher: CipherView;
|
||||
collectionIds: string[];
|
||||
collections: CollectionView[] = [];
|
||||
organization: Organization;
|
||||
flexibleCollectionsV1Enabled: boolean;
|
||||
|
||||
protected cipherDomain: Cipher;
|
||||
|
||||
@@ -27,6 +31,7 @@ export class CollectionsComponent implements OnInit {
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected i18nService: I18nService,
|
||||
protected cipherService: CipherService,
|
||||
protected organizationService: OrganizationService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
@@ -48,11 +53,21 @@ export class CollectionsComponent implements OnInit {
|
||||
(c as any).checked = this.collectionIds != null && this.collectionIds.indexOf(c.id) > -1;
|
||||
});
|
||||
}
|
||||
|
||||
if (this.organization == null) {
|
||||
this.organization = await this.organizationService.get(this.cipher.organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const selectedCollectionIds = this.collections
|
||||
.filter((c) => !!(c as any).checked)
|
||||
.filter((c) => {
|
||||
if (this.organization.canEditAllCiphers(this.flexibleCollectionsV1Enabled)) {
|
||||
return !!(c as any).checked;
|
||||
} else {
|
||||
return !!(c as any).checked && c.readOnly == null;
|
||||
}
|
||||
})
|
||||
.map((c) => c.id);
|
||||
if (!this.allowSelectNone && selectedCollectionIds.length === 0) {
|
||||
this.platformUtilsService.showToast(
|
||||
|
||||
@@ -14,12 +14,17 @@ import {
|
||||
throwError,
|
||||
} from "rxjs";
|
||||
|
||||
import {
|
||||
LoginEmailServiceAbstraction,
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
|
||||
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
|
||||
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
|
||||
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 } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
@@ -30,7 +35,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
enum State {
|
||||
NewUser,
|
||||
@@ -62,6 +67,8 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
protected data?: Data;
|
||||
protected loading = true;
|
||||
|
||||
activeAccountId: UserId;
|
||||
|
||||
// Remember device means for the user to trust the device
|
||||
rememberDeviceForm = this.formBuilder.group({
|
||||
rememberDevice: [true],
|
||||
@@ -79,7 +86,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
protected activatedRoute: ActivatedRoute,
|
||||
protected messagingService: MessagingService,
|
||||
protected tokenService: TokenService,
|
||||
protected loginService: LoginService,
|
||||
protected loginEmailService: LoginEmailServiceAbstraction,
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected cryptoService: CryptoService,
|
||||
protected organizationUserService: OrganizationUserService,
|
||||
@@ -88,12 +95,15 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
protected validationService: ValidationService,
|
||||
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
protected passwordResetEnrollmentService: PasswordResetEnrollmentServiceAbstraction,
|
||||
protected ssoLoginService: SsoLoginServiceAbstraction,
|
||||
protected accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.loading = true;
|
||||
this.activeAccountId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
|
||||
this.setupRememberDeviceValueChanges();
|
||||
|
||||
@@ -101,14 +111,15 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
await this.setRememberDeviceDefaultValue();
|
||||
|
||||
try {
|
||||
const accountDecryptionOptions: AccountDecryptionOptions =
|
||||
await this.stateService.getAccountDecryptionOptions();
|
||||
const userDecryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
|
||||
// see sso-login.strategy - to determine if a user is new or not it just checks if there is a key on the token response..
|
||||
// can we check if they have a user key or master key in crypto service? Would that be sufficient?
|
||||
if (
|
||||
!accountDecryptionOptions?.trustedDeviceOption?.hasAdminApproval &&
|
||||
!accountDecryptionOptions?.hasMasterPassword
|
||||
!userDecryptionOptions?.trustedDeviceOption?.hasAdminApproval &&
|
||||
!userDecryptionOptions?.hasMasterPassword
|
||||
) {
|
||||
// We are dealing with a new account if:
|
||||
// - User does not have admin approval (i.e. has not enrolled into admin reset)
|
||||
@@ -118,7 +129,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.loadNewUserData();
|
||||
} else {
|
||||
this.loadUntrustedDeviceData(accountDecryptionOptions);
|
||||
this.loadUntrustedDeviceData(userDecryptionOptions);
|
||||
}
|
||||
|
||||
// Note: this is probably not a comprehensive write up of all scenarios:
|
||||
@@ -145,7 +156,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async setRememberDeviceDefaultValue() {
|
||||
const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice();
|
||||
const rememberDeviceFromState = await this.deviceTrustCryptoService.getShouldTrustDevice(
|
||||
this.activeAccountId,
|
||||
);
|
||||
|
||||
const rememberDevice = rememberDeviceFromState ?? true;
|
||||
|
||||
@@ -156,7 +169,9 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
this.rememberDevice.valueChanges
|
||||
.pipe(
|
||||
switchMap((value) =>
|
||||
defer(() => this.deviceTrustCryptoService.setShouldTrustDevice(value)),
|
||||
defer(() =>
|
||||
this.deviceTrustCryptoService.setShouldTrustDevice(this.activeAccountId, value),
|
||||
),
|
||||
),
|
||||
takeUntil(this.destroy$),
|
||||
)
|
||||
@@ -195,7 +210,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
loadUntrustedDeviceData(accountDecryptionOptions: AccountDecryptionOptions) {
|
||||
loadUntrustedDeviceData(userDecryptionOptions: UserDecryptionOptions) {
|
||||
this.loading = true;
|
||||
|
||||
const email$ = from(this.stateService.getEmail()).pipe(
|
||||
@@ -215,13 +230,12 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
)
|
||||
.subscribe((email) => {
|
||||
const showApproveFromOtherDeviceBtn =
|
||||
accountDecryptionOptions?.trustedDeviceOption?.hasLoginApprovingDevice || false;
|
||||
userDecryptionOptions?.trustedDeviceOption?.hasLoginApprovingDevice || false;
|
||||
|
||||
const showReqAdminApprovalBtn =
|
||||
!!accountDecryptionOptions?.trustedDeviceOption?.hasAdminApproval || false;
|
||||
!!userDecryptionOptions?.trustedDeviceOption?.hasAdminApproval || false;
|
||||
|
||||
const showApproveWithMasterPasswordBtn =
|
||||
accountDecryptionOptions?.hasMasterPassword || false;
|
||||
const showApproveWithMasterPasswordBtn = userDecryptionOptions?.hasMasterPassword || false;
|
||||
|
||||
const userEmail = email;
|
||||
|
||||
@@ -240,23 +254,17 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loginService.setEmail(this.data.userEmail);
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["/login-with-device"]);
|
||||
this.loginEmailService.setEmail(this.data.userEmail);
|
||||
await this.router.navigate(["/login-with-device"]);
|
||||
}
|
||||
|
||||
async requestAdminApproval() {
|
||||
this.loginService.setEmail(this.data.userEmail);
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["/admin-approval-requested"]);
|
||||
this.loginEmailService.setEmail(this.data.userEmail);
|
||||
await this.router.navigate(["/admin-approval-requested"]);
|
||||
}
|
||||
|
||||
async approveWithMasterPassword() {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["/lock"], { queryParams: { from: "login-initiated" } });
|
||||
await this.router.navigate(["/lock"], { queryParams: { from: "login-initiated" } });
|
||||
}
|
||||
|
||||
async createUser() {
|
||||
@@ -280,7 +288,7 @@ export class BaseLoginDecryptionOptionsComponent implements OnInit, OnDestroy {
|
||||
await this.passwordResetEnrollmentService.enroll(this.data.organizationId);
|
||||
|
||||
if (this.rememberDeviceForm.value.rememberDevice) {
|
||||
await this.deviceTrustCryptoService.trustDevice();
|
||||
await this.deviceTrustCryptoService.trustDevice(this.activeAccountId);
|
||||
}
|
||||
} catch (error) {
|
||||
this.validationService.showError(error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Directive, Input } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { CaptchaIFrame } from "@bitwarden/common/auth/captcha-iframe";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
@@ -19,7 +20,8 @@ export abstract class CaptchaProtectedComponent {
|
||||
) {}
|
||||
|
||||
async setupCaptcha() {
|
||||
const webVaultUrl = this.environmentService.getWebVaultUrl();
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webVaultUrl = env.getWebVaultUrl();
|
||||
|
||||
this.captcha = new CaptchaIFrame(
|
||||
window,
|
||||
|
||||
@@ -7,17 +7,15 @@
|
||||
#trigger="cdkOverlayOrigin"
|
||||
aria-haspopup="dialog"
|
||||
aria-controls="cdk-overlay-container"
|
||||
[ngSwitch]="selectedEnvironment"
|
||||
>
|
||||
<span *ngSwitchCase="ServerEnvironmentType.US" class="text-primary">{{
|
||||
"usDomain" | i18n
|
||||
}}</span>
|
||||
<span *ngSwitchCase="ServerEnvironmentType.EU" class="text-primary">{{
|
||||
"euDomain" | i18n
|
||||
}}</span>
|
||||
<span *ngSwitchCase="ServerEnvironmentType.SelfHosted" class="text-primary">{{
|
||||
"selfHostedServer" | i18n
|
||||
}}</span>
|
||||
<span class="text-primary">
|
||||
<ng-container *ngIf="selectedRegion$ | async as selectedRegion; else fallback">
|
||||
{{ selectedRegion.domain }}
|
||||
</ng-container>
|
||||
<ng-template #fallback>
|
||||
{{ "selfHostedServer" | i18n }}
|
||||
</ng-template>
|
||||
</span>
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-down" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -41,40 +39,23 @@
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="environment-selector-dialog-item"
|
||||
(click)="toggle(ServerEnvironmentType.US)"
|
||||
[attr.aria-pressed]="selectedEnvironment === ServerEnvironmentType.US ? 'true' : 'false'"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="
|
||||
selectedEnvironment === ServerEnvironmentType.US ? 'visible' : 'hidden'
|
||||
"
|
||||
></i>
|
||||
<span>{{ "usDomain" | i18n }}</span>
|
||||
</button>
|
||||
<br />
|
||||
<button
|
||||
type="button"
|
||||
class="environment-selector-dialog-item"
|
||||
(click)="toggle(ServerEnvironmentType.EU)"
|
||||
[attr.aria-pressed]="selectedEnvironment === ServerEnvironmentType.EU ? 'true' : 'false'"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="
|
||||
selectedEnvironment === ServerEnvironmentType.EU ? 'visible' : 'hidden'
|
||||
"
|
||||
></i>
|
||||
<span>{{ "euDomain" | i18n }}</span>
|
||||
</button>
|
||||
<br />
|
||||
<ng-container *ngFor="let region of availableRegions">
|
||||
<button
|
||||
type="button"
|
||||
class="environment-selector-dialog-item"
|
||||
(click)="toggle(region.key)"
|
||||
[attr.aria-pressed]="selectedEnvironment === region.key ? 'true' : 'false'"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw bwi-sm bwi-check"
|
||||
style="padding-bottom: 1px"
|
||||
aria-hidden="true"
|
||||
[style.visibility]="selectedEnvironment === region.key ? 'visible' : 'hidden'"
|
||||
></i>
|
||||
<span>{{ region.domain }}</span>
|
||||
</button>
|
||||
<br />
|
||||
</ng-container>
|
||||
<button
|
||||
type="button"
|
||||
class="environment-selector-dialog-item"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { animate, state, style, transition, trigger } from "@angular/animations";
|
||||
import { ConnectedPosition } from "@angular/cdk/overlay";
|
||||
import { Component, EventEmitter, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { Component, EventEmitter, Output } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { Observable, map } from "rxjs";
|
||||
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import {
|
||||
EnvironmentService as EnvironmentServiceAbstraction,
|
||||
EnvironmentService,
|
||||
Region,
|
||||
RegionConfig,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
|
||||
@Component({
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
|
||||
export class EnvironmentSelectorComponent {
|
||||
@Output() onOpenSelfHostedSettings = new EventEmitter();
|
||||
isOpen = false;
|
||||
showingModal = false;
|
||||
@@ -48,59 +48,34 @@ export class EnvironmentSelectorComponent implements OnInit, OnDestroy {
|
||||
overlayY: "top",
|
||||
},
|
||||
];
|
||||
protected componentDestroyed$: Subject<void> = new Subject();
|
||||
|
||||
protected availableRegions = this.environmentService.availableRegions();
|
||||
protected selectedRegion$: Observable<RegionConfig | undefined> =
|
||||
this.environmentService.environment$.pipe(
|
||||
map((e) => e.getRegion()),
|
||||
map((r) => this.availableRegions.find((ar) => ar.key === r)),
|
||||
);
|
||||
|
||||
constructor(
|
||||
protected environmentService: EnvironmentServiceAbstraction,
|
||||
protected configService: ConfigServiceAbstraction,
|
||||
protected environmentService: EnvironmentService,
|
||||
protected router: Router,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.configService.serverConfig$.pipe(takeUntil(this.componentDestroyed$)).subscribe(() => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.updateEnvironmentInfo();
|
||||
});
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.updateEnvironmentInfo();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.componentDestroyed$.next();
|
||||
this.componentDestroyed$.complete();
|
||||
}
|
||||
|
||||
async toggle(option: Region) {
|
||||
this.isOpen = !this.isOpen;
|
||||
if (option === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.updateEnvironmentInfo();
|
||||
|
||||
if (option === Region.SelfHosted) {
|
||||
this.onOpenSelfHostedSettings.emit();
|
||||
return;
|
||||
}
|
||||
|
||||
await this.environmentService.setRegion(option);
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.updateEnvironmentInfo();
|
||||
}
|
||||
|
||||
async updateEnvironmentInfo() {
|
||||
this.selectedEnvironment = this.environmentService.selectedRegion;
|
||||
await this.environmentService.setEnvironment(option);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.isOpen = false;
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.updateEnvironmentInfo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Directive, EventEmitter, Output } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
|
||||
import {
|
||||
EnvironmentService,
|
||||
@@ -27,21 +28,29 @@ export class EnvironmentComponent {
|
||||
protected i18nService: I18nService,
|
||||
private modalService: ModalService,
|
||||
) {
|
||||
const urls = this.environmentService.getUrls();
|
||||
if (this.environmentService.selectedRegion != Region.SelfHosted) {
|
||||
return;
|
||||
}
|
||||
this.environmentService.environment$.pipe(takeUntilDestroyed()).subscribe((env) => {
|
||||
if (env.getRegion() !== Region.SelfHosted) {
|
||||
this.baseUrl = "";
|
||||
this.webVaultUrl = "";
|
||||
this.apiUrl = "";
|
||||
this.identityUrl = "";
|
||||
this.iconsUrl = "";
|
||||
this.notificationsUrl = "";
|
||||
return;
|
||||
}
|
||||
|
||||
this.baseUrl = urls.base || "";
|
||||
this.webVaultUrl = urls.webVault || "";
|
||||
this.apiUrl = urls.api || "";
|
||||
this.identityUrl = urls.identity || "";
|
||||
this.iconsUrl = urls.icons || "";
|
||||
this.notificationsUrl = urls.notifications || "";
|
||||
const urls = env.getUrls();
|
||||
this.baseUrl = urls.base || "";
|
||||
this.webVaultUrl = urls.webVault || "";
|
||||
this.apiUrl = urls.api || "";
|
||||
this.identityUrl = urls.identity || "";
|
||||
this.iconsUrl = urls.icons || "";
|
||||
this.notificationsUrl = urls.notifications || "";
|
||||
});
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const resUrls = await this.environmentService.setUrls({
|
||||
await this.environmentService.setEnvironment(Region.SelfHosted, {
|
||||
base: this.baseUrl,
|
||||
api: this.apiUrl,
|
||||
identity: this.identityUrl,
|
||||
@@ -50,14 +59,6 @@ export class EnvironmentComponent {
|
||||
notifications: this.notificationsUrl,
|
||||
});
|
||||
|
||||
// re-set urls since service can change them, ex: prefixing https://
|
||||
this.baseUrl = resUrls.base;
|
||||
this.apiUrl = resUrls.api;
|
||||
this.identityUrl = resUrls.identity;
|
||||
this.webVaultUrl = resUrls.webVault;
|
||||
this.iconsUrl = resUrls.icons;
|
||||
this.notificationsUrl = resUrls.notifications;
|
||||
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("environmentSaved"));
|
||||
this.saved();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Directive, OnInit } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
import { LoginEmailServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
|
||||
import { PasswordHintRequest } from "@bitwarden/common/auth/models/request/password-hint.request";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -22,11 +22,11 @@ export class HintComponent implements OnInit {
|
||||
protected apiService: ApiService,
|
||||
protected platformUtilsService: PlatformUtilsService,
|
||||
private logService: LogService,
|
||||
private loginService: LoginService,
|
||||
private loginEmailService: LoginEmailServiceAbstraction,
|
||||
) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.email = this.loginService.getEmail() ?? "";
|
||||
this.email = this.loginEmailService.getEmail() ?? "";
|
||||
}
|
||||
|
||||
async submit() {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { VaultTimeoutService } from "@bitwarden/common/abstractions/vault-timeou
|
||||
import { PolicyApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/policy/policy-api.service.abstraction";
|
||||
import { InternalPolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
@@ -75,6 +76,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
protected userVerificationService: UserVerificationService,
|
||||
protected pinCryptoService: PinCryptoServiceAbstraction,
|
||||
protected biometricStateService: BiometricStateService,
|
||||
protected accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -119,7 +121,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.biometricStateService.setPromptCancelled();
|
||||
await this.biometricStateService.setUserPromptCancelled();
|
||||
const userKey = await this.cryptoService.getUserKeyFromStorage(KeySuffixOptions.Biometric);
|
||||
|
||||
if (userKey) {
|
||||
@@ -269,14 +271,15 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
|
||||
// Now that we have a decrypted user key in memory, we can check if we
|
||||
// need to establish trust on the current device
|
||||
await this.deviceTrustCryptoService.trustDeviceIfRequired();
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
await this.deviceTrustCryptoService.trustDeviceIfRequired(activeAccount.id);
|
||||
|
||||
await this.doContinue(evaluatePasswordAfterUnlock);
|
||||
}
|
||||
|
||||
private async doContinue(evaluatePasswordAfterUnlock: boolean) {
|
||||
await this.stateService.setEverBeenUnlocked(true);
|
||||
await this.biometricStateService.resetPromptCancelled();
|
||||
await this.biometricStateService.resetUserPromptCancelled();
|
||||
this.messagingService.send("unlocked");
|
||||
|
||||
if (evaluatePasswordAfterUnlock) {
|
||||
@@ -346,7 +349,7 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
!this.platformUtilsService.supportsSecureStorage());
|
||||
this.email = await this.stateService.getEmail();
|
||||
|
||||
this.webVaultHostname = await this.environmentService.getHost();
|
||||
this.webVaultHostname = (await this.environmentService.getEnvironment()).getHostname();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Directive, OnDestroy, OnInit } from "@angular/core";
|
||||
import { IsActiveMatchOptions, Router } from "@angular/router";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { Subject, firstValueFrom, takeUntil } from "rxjs";
|
||||
|
||||
import {
|
||||
AuthRequestLoginCredentials,
|
||||
AuthRequestServiceAbstraction,
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AnonymousHubService } from "@bitwarden/common/auth/abstractions/anonymous-hub.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abstractions/device-trust-crypto.service.abstraction";
|
||||
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
|
||||
import { AuthRequestType } from "@bitwarden/common/auth/enums/auth-request-type";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { AdminAuthRequestStorable } from "@bitwarden/common/auth/models/domain/admin-auth-req-storable";
|
||||
@@ -68,7 +69,6 @@ export class LoginViaAuthRequestComponent
|
||||
|
||||
private authRequestKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array };
|
||||
|
||||
// TODO: in future, go to child components and remove child constructors and let deps fall through to the super class
|
||||
constructor(
|
||||
protected router: Router,
|
||||
private cryptoService: CryptoService,
|
||||
@@ -84,10 +84,11 @@ export class LoginViaAuthRequestComponent
|
||||
private anonymousHubService: AnonymousHubService,
|
||||
private validationService: ValidationService,
|
||||
private stateService: StateService,
|
||||
private loginService: LoginService,
|
||||
private loginEmailService: LoginEmailServiceAbstraction,
|
||||
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private accountService: AccountService,
|
||||
) {
|
||||
super(environmentService, i18nService, platformUtilsService);
|
||||
|
||||
@@ -95,17 +96,19 @@ export class LoginViaAuthRequestComponent
|
||||
// Why would the existence of the email depend on the navigation?
|
||||
const navigation = this.router.getCurrentNavigation();
|
||||
if (navigation) {
|
||||
this.email = this.loginService.getEmail();
|
||||
this.email = this.loginEmailService.getEmail();
|
||||
}
|
||||
|
||||
//gets signalR push notification
|
||||
this.loginStrategyService.authRequestPushNotification$
|
||||
// Gets signalR push notification
|
||||
// Only fires on approval to prevent enumeration
|
||||
this.authRequestService.authRequestPushNotification$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((id) => {
|
||||
// Only fires on approval currently
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.verifyAndHandleApprovedAuthReq(id);
|
||||
this.verifyAndHandleApprovedAuthReq(id).catch((e: Error) => {
|
||||
this.platformUtilsService.showToast("error", this.i18nService.t("error"), e.message);
|
||||
this.logService.error("Failed to use approved auth request: " + e.message);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,7 +153,7 @@ export class LoginViaAuthRequestComponent
|
||||
} else {
|
||||
// Standard auth request
|
||||
// TODO: evaluate if we can remove the setting of this.email in the constructor
|
||||
this.email = this.loginService.getEmail();
|
||||
this.email = this.loginEmailService.getEmail();
|
||||
|
||||
if (!this.email) {
|
||||
this.platformUtilsService.showToast("error", null, this.i18nService.t("userEmailMissing"));
|
||||
@@ -164,10 +167,10 @@ export class LoginViaAuthRequestComponent
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
async ngOnDestroy() {
|
||||
await this.anonymousHubService.stopHubConnection();
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
this.anonymousHubService.stopHubConnection();
|
||||
}
|
||||
|
||||
private async handleExistingAdminAuthRequest(adminAuthReqStorable: AdminAuthRequestStorable) {
|
||||
@@ -213,7 +216,7 @@ export class LoginViaAuthRequestComponent
|
||||
|
||||
// Request still pending response from admin
|
||||
// So, create hub connection so that any approvals will be received via push notification
|
||||
this.anonymousHubService.createHubConnection(adminAuthReqStorable.id);
|
||||
await this.anonymousHubService.createHubConnection(adminAuthReqStorable.id);
|
||||
}
|
||||
|
||||
private async handleExistingAdminAuthReqDeletedOrDenied() {
|
||||
@@ -273,7 +276,7 @@ export class LoginViaAuthRequestComponent
|
||||
}
|
||||
|
||||
if (reqResponse.id) {
|
||||
this.anonymousHubService.createHubConnection(reqResponse.id);
|
||||
await this.anonymousHubService.createHubConnection(reqResponse.id);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logService.error(e);
|
||||
@@ -387,7 +390,8 @@ export class LoginViaAuthRequestComponent
|
||||
|
||||
// Now that we have a decrypted user key in memory, we can check if we
|
||||
// need to establish trust on the current device
|
||||
await this.deviceTrustCryptoService.trustDeviceIfRequired();
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
await this.deviceTrustCryptoService.trustDeviceIfRequired(activeAccount.id);
|
||||
|
||||
// TODO: don't forget to use auto enrollment service everywhere we trust device
|
||||
|
||||
@@ -471,17 +475,10 @@ export class LoginViaAuthRequestComponent
|
||||
}
|
||||
}
|
||||
|
||||
async setRememberEmailValues() {
|
||||
const rememberEmail = this.loginService.getRememberEmail();
|
||||
const rememberedEmail = this.loginService.getEmail();
|
||||
await this.stateService.setRememberedEmail(rememberEmail ? rememberedEmail : null);
|
||||
this.loginService.clearValues();
|
||||
}
|
||||
|
||||
private async handleSuccessfulLoginNavigation() {
|
||||
if (this.state === State.StandardAuthRequest) {
|
||||
// Only need to set remembered email on standard login with auth req flow
|
||||
await this.setRememberEmailValues();
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
}
|
||||
|
||||
if (this.onSuccessfulLogin != null) {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Directive, ElementRef, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { Subject } from "rxjs";
|
||||
import { Subject, firstValueFrom } from "rxjs";
|
||||
import { take, takeUntil } from "rxjs/operators";
|
||||
|
||||
import { LoginStrategyServiceAbstraction, PasswordLoginCredentials } from "@bitwarden/auth/common";
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
PasswordLoginCredentials,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
||||
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { WebAuthnLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login.service.abstraction";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
@@ -77,17 +80,13 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
|
||||
protected formBuilder: FormBuilder,
|
||||
protected formValidationErrorService: FormValidationErrorsService,
|
||||
protected route: ActivatedRoute,
|
||||
protected loginService: LoginService,
|
||||
protected loginEmailService: LoginEmailServiceAbstraction,
|
||||
protected ssoLoginService: SsoLoginServiceAbstraction,
|
||||
protected webAuthnLoginService: WebAuthnLoginServiceAbstraction,
|
||||
) {
|
||||
super(environmentService, i18nService, platformUtilsService);
|
||||
}
|
||||
|
||||
get selfHostedDomain() {
|
||||
return this.environmentService.hasBaseUrl() ? this.environmentService.getWebVaultUrl() : null;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.route?.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
|
||||
if (!params) {
|
||||
@@ -97,25 +96,23 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
|
||||
const queryParamsEmail = params.email;
|
||||
|
||||
if (queryParamsEmail != null && queryParamsEmail.indexOf("@") > -1) {
|
||||
this.formGroup.get("email").setValue(queryParamsEmail);
|
||||
this.loginService.setEmail(queryParamsEmail);
|
||||
this.formGroup.controls.email.setValue(queryParamsEmail);
|
||||
this.paramEmailSet = true;
|
||||
}
|
||||
});
|
||||
let email = this.loginService.getEmail();
|
||||
|
||||
if (email == null || email === "") {
|
||||
email = await this.stateService.getRememberedEmail();
|
||||
}
|
||||
|
||||
if (!this.paramEmailSet) {
|
||||
this.formGroup.get("email")?.setValue(email ?? "");
|
||||
const storedEmail = await firstValueFrom(this.loginEmailService.storedEmail$);
|
||||
this.formGroup.controls.email.setValue(storedEmail ?? "");
|
||||
}
|
||||
let rememberEmail = this.loginService.getRememberEmail();
|
||||
|
||||
let rememberEmail = this.loginEmailService.getRememberEmail();
|
||||
|
||||
if (rememberEmail == null) {
|
||||
rememberEmail = (await this.stateService.getRememberedEmail()) != null;
|
||||
rememberEmail = (await firstValueFrom(this.loginEmailService.storedEmail$)) != null;
|
||||
}
|
||||
this.formGroup.get("rememberEmail")?.setValue(rememberEmail);
|
||||
|
||||
this.formGroup.controls.rememberEmail.setValue(rememberEmail);
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
@@ -152,8 +149,10 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
|
||||
|
||||
this.formPromise = this.loginStrategyService.logIn(credentials);
|
||||
const response = await this.formPromise;
|
||||
this.setFormValues();
|
||||
await this.loginService.saveEmailSettings();
|
||||
|
||||
this.setLoginEmailValues();
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
|
||||
if (this.handleCaptchaRequired(response)) {
|
||||
return;
|
||||
} else if (this.handleMigrateEncryptionKey(response)) {
|
||||
@@ -218,7 +217,7 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
|
||||
return;
|
||||
}
|
||||
|
||||
this.setFormValues();
|
||||
this.setLoginEmailValues();
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.router.navigate(["/login-with-device"]);
|
||||
@@ -245,7 +244,8 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
|
||||
await this.ssoLoginService.setCodeVerifier(ssoCodeVerifier);
|
||||
|
||||
// Build URI
|
||||
const webUrl = this.environmentService.getWebVaultUrl();
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webUrl = env.getWebVaultUrl();
|
||||
|
||||
// Launch browser
|
||||
this.platformUtilsService.launchUri(
|
||||
@@ -295,14 +295,14 @@ export class LoginComponent extends CaptchaProtectedComponent implements OnInit,
|
||||
}
|
||||
}
|
||||
|
||||
setFormValues() {
|
||||
this.loginService.setEmail(this.formGroup.value.email);
|
||||
this.loginService.setRememberEmail(this.formGroup.value.rememberEmail);
|
||||
setLoginEmailValues() {
|
||||
this.loginEmailService.setEmail(this.formGroup.value.email);
|
||||
this.loginEmailService.setRememberEmail(this.formGroup.value.rememberEmail);
|
||||
}
|
||||
|
||||
async saveEmailSettings() {
|
||||
this.setFormValues();
|
||||
await this.loginService.saveEmailSettings();
|
||||
this.setLoginEmailValues();
|
||||
await this.loginEmailService.saveEmailSettings();
|
||||
|
||||
// Save off email for SSO
|
||||
await this.ssoLoginService.setSsoEmail(this.formGroup.value.email);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Directive } from "@angular/core";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { of } from "rxjs";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { filter, first, switchMap, tap } from "rxjs/operators";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationUserService } from "@bitwarden/common/admin-console/abstractions/organization-user/organization-user.service";
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
DEFAULT_KDF_CONFIG,
|
||||
} from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
@@ -64,6 +64,7 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
|
||||
stateService: StateService,
|
||||
private organizationApiService: OrganizationApiServiceAbstraction,
|
||||
private organizationUserService: OrganizationUserService,
|
||||
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
dialogService: DialogService,
|
||||
) {
|
||||
@@ -228,11 +229,11 @@ export class SetPasswordComponent extends BaseChangePasswordComponent {
|
||||
await this.stateService.setForceSetPasswordReason(ForceSetPasswordReason.None);
|
||||
|
||||
// User now has a password so update account decryption options in state
|
||||
const acctDecryptionOpts: AccountDecryptionOptions =
|
||||
await this.stateService.getAccountDecryptionOptions();
|
||||
|
||||
acctDecryptionOpts.hasMasterPassword = true;
|
||||
await this.stateService.setAccountDecryptionOptions(acctDecryptionOpts);
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
userDecryptionOpts.hasMasterPassword = true;
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptions(userDecryptionOpts);
|
||||
|
||||
await this.stateService.setKdfType(this.kdf);
|
||||
await this.stateService.setKdfConfig(this.kdfConfig);
|
||||
|
||||
@@ -2,24 +2,27 @@ import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, Router } from "@angular/router";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { Observable, of } from "rxjs";
|
||||
import { BehaviorSubject, Observable, of } from "rxjs";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import {
|
||||
FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption,
|
||||
LoginStrategyServiceAbstraction,
|
||||
FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption,
|
||||
FakeUserDecryptionOptions as UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
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 { KeyConnectorUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
|
||||
import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
|
||||
|
||||
import { SsoComponent } from "./sso.component";
|
||||
@@ -62,7 +65,8 @@ describe("SsoComponent", () => {
|
||||
let mockEnvironmentService: MockProxy<EnvironmentService>;
|
||||
let mockPasswordGenerationService: MockProxy<PasswordGenerationServiceAbstraction>;
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
|
||||
let mockUserDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
|
||||
// Mock authService.logIn params
|
||||
let code: string;
|
||||
@@ -77,17 +81,19 @@ describe("SsoComponent", () => {
|
||||
let mockOnSuccessfulLoginForceResetNavigate: jest.Mock;
|
||||
let mockOnSuccessfulLoginTdeNavigate: jest.Mock;
|
||||
|
||||
let mockAcctDecryptionOpts: {
|
||||
noMasterPassword: AccountDecryptionOptions;
|
||||
withMasterPassword: AccountDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDevice: AccountDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: AccountDecryptionOptions;
|
||||
withMasterPasswordAndKeyConnector: AccountDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDevice: AccountDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: AccountDecryptionOptions;
|
||||
noMasterPasswordWithKeyConnector: AccountDecryptionOptions;
|
||||
let mockUserDecryptionOpts: {
|
||||
noMasterPassword: UserDecryptionOptions;
|
||||
withMasterPassword: UserDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDevice: UserDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
|
||||
withMasterPasswordAndKeyConnector: UserDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDevice: UserDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
|
||||
noMasterPasswordWithKeyConnector: UserDecryptionOptions;
|
||||
};
|
||||
|
||||
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock Services
|
||||
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
|
||||
@@ -101,15 +107,16 @@ describe("SsoComponent", () => {
|
||||
queryParams: mockQueryParams,
|
||||
} as any as ActivatedRoute;
|
||||
|
||||
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
mockStateService = mock<StateService>();
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
mockApiService = mock<ApiService>();
|
||||
mockCryptoFunctionService = mock<CryptoFunctionService>();
|
||||
mockEnvironmentService = mock<EnvironmentService>();
|
||||
mockPasswordGenerationService = mock<PasswordGenerationServiceAbstraction>();
|
||||
mockLogService = mock<LogService>();
|
||||
mockConfigService = mock<ConfigServiceAbstraction>();
|
||||
mockSsoLoginService = mock();
|
||||
mockStateService = mock();
|
||||
mockPlatformUtilsService = mock();
|
||||
mockApiService = mock();
|
||||
mockCryptoFunctionService = mock();
|
||||
mockEnvironmentService = mock();
|
||||
mockPasswordGenerationService = mock();
|
||||
mockLogService = mock();
|
||||
mockUserDecryptionOptionsService = mock();
|
||||
mockConfigService = mock();
|
||||
|
||||
// Mock loginStrategyService.logIn params
|
||||
code = "code";
|
||||
@@ -124,49 +131,52 @@ describe("SsoComponent", () => {
|
||||
mockOnSuccessfulLoginForceResetNavigate = jest.fn();
|
||||
mockOnSuccessfulLoginTdeNavigate = jest.fn();
|
||||
|
||||
mockAcctDecryptionOpts = {
|
||||
noMasterPassword: new AccountDecryptionOptions({
|
||||
mockUserDecryptionOpts = {
|
||||
noMasterPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPassword: new AccountDecryptionOptions({
|
||||
withMasterPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndTrustedDevice: new AccountDecryptionOptions({
|
||||
withMasterPasswordAndTrustedDevice: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndKeyConnector: new AccountDecryptionOptions({
|
||||
withMasterPasswordAndKeyConnector: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
|
||||
}),
|
||||
noMasterPasswordWithTrustedDevice: new AccountDecryptionOptions({
|
||||
noMasterPasswordWithTrustedDevice: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
noMasterPasswordWithKeyConnector: new AccountDecryptionOptions({
|
||||
noMasterPasswordWithKeyConnector: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
|
||||
}),
|
||||
};
|
||||
|
||||
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
|
||||
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [TestSsoComponent],
|
||||
providers: [
|
||||
@@ -183,8 +193,12 @@ describe("SsoComponent", () => {
|
||||
{ provide: EnvironmentService, useValue: mockEnvironmentService },
|
||||
{ provide: PasswordGenerationServiceAbstraction, useValue: mockPasswordGenerationService },
|
||||
|
||||
{
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
useValue: mockUserDecryptionOptionsService,
|
||||
},
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: ConfigServiceAbstraction, useValue: mockConfigService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -230,9 +244,7 @@ describe("SsoComponent", () => {
|
||||
authResult.twoFactorProviders = new Map([[TwoFactorProviderType.Authenticator, {}]]);
|
||||
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
|
||||
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
|
||||
});
|
||||
@@ -341,8 +353,8 @@ describe("SsoComponent", () => {
|
||||
describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => {
|
||||
let authResult;
|
||||
beforeEach(() => {
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword,
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword,
|
||||
);
|
||||
|
||||
authResult = new AuthResult();
|
||||
@@ -377,8 +389,8 @@ describe("SsoComponent", () => {
|
||||
const reasonString = ForceSetPasswordReason[forceResetPasswordReason];
|
||||
let authResult;
|
||||
beforeEach(() => {
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
);
|
||||
|
||||
authResult = new AuthResult();
|
||||
@@ -394,8 +406,8 @@ describe("SsoComponent", () => {
|
||||
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
|
||||
let authResult;
|
||||
beforeEach(() => {
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
);
|
||||
|
||||
authResult = new AuthResult();
|
||||
@@ -440,9 +452,7 @@ describe("SsoComponent", () => {
|
||||
describe("Given user needs to set a master password", () => {
|
||||
beforeEach(() => {
|
||||
// Only need to test the case where the user has no master password to test the primary change mp flow here
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.noMasterPassword,
|
||||
);
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword);
|
||||
});
|
||||
|
||||
testChangePasswordOnSuccessfulLogin();
|
||||
@@ -450,9 +460,7 @@ describe("SsoComponent", () => {
|
||||
});
|
||||
|
||||
it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => {
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.noMasterPasswordWithKeyConnector,
|
||||
);
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPasswordWithKeyConnector);
|
||||
|
||||
await _component.logIn(code, codeVerifier, orgIdFromState);
|
||||
expect(mockLoginStrategyService.logIn).toHaveBeenCalledTimes(1);
|
||||
@@ -475,9 +483,7 @@ describe("SsoComponent", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = forceResetPasswordReason;
|
||||
@@ -494,9 +500,7 @@ describe("SsoComponent", () => {
|
||||
const authResult = new AuthResult();
|
||||
authResult.twoFactorProviders = null;
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
authResult.forcePasswordReset = ForceSetPasswordReason.None;
|
||||
mockLoginStrategyService.logIn.mockResolvedValue(authResult);
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { Directive } from "@angular/core";
|
||||
import { ActivatedRoute, NavigationExtras, Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { first } from "rxjs/operators";
|
||||
|
||||
import { LoginStrategyServiceAbstraction, SsoLoginCredentials } from "@bitwarden/auth/common";
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
SsoLoginCredentials,
|
||||
TrustedDeviceUserDecryptionOption,
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
|
||||
import { SsoPreValidateResponse } from "@bitwarden/common/auth/models/response/sso-pre-validate.response";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -17,7 +23,6 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password";
|
||||
|
||||
@Directive()
|
||||
@@ -59,7 +64,8 @@ export class SsoComponent {
|
||||
protected environmentService: EnvironmentService,
|
||||
protected passwordGenerationService: PasswordGenerationServiceAbstraction,
|
||||
protected logService: LogService,
|
||||
protected configService: ConfigServiceAbstraction,
|
||||
protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
protected configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -151,8 +157,10 @@ export class SsoComponent {
|
||||
// Save state (regardless of new or existing)
|
||||
await this.ssoLoginService.setSsoState(state);
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
|
||||
let authorizeUrl =
|
||||
this.environmentService.getIdentityUrl() +
|
||||
env.getIdentityUrl() +
|
||||
"/connect/authorize?" +
|
||||
"client_id=" +
|
||||
this.clientId +
|
||||
@@ -194,9 +202,6 @@ export class SsoComponent {
|
||||
this.formPromise = this.loginStrategyService.logIn(credentials);
|
||||
const authResult = await this.formPromise;
|
||||
|
||||
const acctDecryptionOpts: AccountDecryptionOptions =
|
||||
await this.stateService.getAccountDecryptionOptions();
|
||||
|
||||
if (authResult.requiresTwoFactor) {
|
||||
return await this.handleTwoFactorRequired(orgSsoIdentifier);
|
||||
}
|
||||
@@ -217,15 +222,20 @@ export class SsoComponent {
|
||||
return await this.handleForcePasswordReset(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
// must come after 2fa check since user decryption options aren't available if 2fa is required
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
|
||||
const tdeEnabled = await this.isTrustedDeviceEncEnabled(
|
||||
acctDecryptionOpts.trustedDeviceOption,
|
||||
userDecryptionOpts.trustedDeviceOption,
|
||||
);
|
||||
|
||||
if (tdeEnabled) {
|
||||
return await this.handleTrustedDeviceEncryptionEnabled(
|
||||
authResult,
|
||||
orgSsoIdentifier,
|
||||
acctDecryptionOpts,
|
||||
userDecryptionOpts,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -233,8 +243,8 @@ export class SsoComponent {
|
||||
// have one and they aren't using key connector.
|
||||
// Note: TDE & Key connector are mutually exclusive org config options.
|
||||
const requireSetPassword =
|
||||
!acctDecryptionOpts.hasMasterPassword &&
|
||||
acctDecryptionOpts.keyConnectorOption === undefined;
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.keyConnectorOption === undefined;
|
||||
|
||||
if (requireSetPassword || authResult.resetMasterPassword) {
|
||||
// Change implies going no password -> password in this case
|
||||
@@ -270,12 +280,12 @@ export class SsoComponent {
|
||||
private async handleTrustedDeviceEncryptionEnabled(
|
||||
authResult: AuthResult,
|
||||
orgIdentifier: string,
|
||||
acctDecryptionOpts: AccountDecryptionOptions,
|
||||
userDecryptionOpts: UserDecryptionOptions,
|
||||
): Promise<void> {
|
||||
// If user doesn't have a MP, but has reset password permission, they must set a MP
|
||||
if (
|
||||
!acctDecryptionOpts.hasMasterPassword &&
|
||||
acctDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
|
||||
) {
|
||||
// Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device)
|
||||
// Note: we cannot directly navigate in this scenario as we are in a pre-decryption state, and
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Directive, EventEmitter, OnInit, Output } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
@@ -31,8 +32,9 @@ export class TwoFactorOptionsComponent implements OnInit {
|
||||
this.onProviderSelected.emit(p.type);
|
||||
}
|
||||
|
||||
recover() {
|
||||
const webVault = this.environmentService.getWebVaultUrl();
|
||||
async recover() {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webVault = env.getWebVaultUrl();
|
||||
this.platformUtilsService.launchUri(webVault + "/#/recover-2fa");
|
||||
this.onRecoverSelected.emit();
|
||||
}
|
||||
|
||||
@@ -1,28 +1,32 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { ActivatedRoute, Router, convertToParamMap } from "@angular/router";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { ActivatedRoute, convertToParamMap, Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
FakeKeyConnectorUserDecryptionOption as KeyConnectorUserDecryptionOption,
|
||||
FakeTrustedDeviceUserDecryptionOption as TrustedDeviceUserDecryptionOption,
|
||||
FakeUserDecryptionOptions as UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
import { KeyConnectorUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/key-connector-user-decryption-option";
|
||||
import { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
|
||||
|
||||
import { TwoFactorComponent } from "./two-factor.component";
|
||||
|
||||
@@ -55,21 +59,24 @@ describe("TwoFactorComponent", () => {
|
||||
let mockLogService: MockProxy<LogService>;
|
||||
let mockTwoFactorService: MockProxy<TwoFactorService>;
|
||||
let mockAppIdService: MockProxy<AppIdService>;
|
||||
let mockLoginService: MockProxy<LoginService>;
|
||||
let mockLoginEmailService: MockProxy<LoginEmailServiceAbstraction>;
|
||||
let mockUserDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let mockSsoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
|
||||
let mockAcctDecryptionOpts: {
|
||||
noMasterPassword: AccountDecryptionOptions;
|
||||
withMasterPassword: AccountDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDevice: AccountDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: AccountDecryptionOptions;
|
||||
withMasterPasswordAndKeyConnector: AccountDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDevice: AccountDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: AccountDecryptionOptions;
|
||||
noMasterPasswordWithKeyConnector: AccountDecryptionOptions;
|
||||
let mockUserDecryptionOpts: {
|
||||
noMasterPassword: UserDecryptionOptions;
|
||||
withMasterPassword: UserDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDevice: UserDecryptionOptions;
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
|
||||
withMasterPasswordAndKeyConnector: UserDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDevice: UserDecryptionOptions;
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: UserDecryptionOptions;
|
||||
noMasterPasswordWithKeyConnector: UserDecryptionOptions;
|
||||
};
|
||||
|
||||
let selectedUserDecryptionOptions: BehaviorSubject<UserDecryptionOptions>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockLoginStrategyService = mock<LoginStrategyServiceAbstraction>();
|
||||
mockRouter = mock<Router>();
|
||||
@@ -82,53 +89,57 @@ describe("TwoFactorComponent", () => {
|
||||
mockLogService = mock<LogService>();
|
||||
mockTwoFactorService = mock<TwoFactorService>();
|
||||
mockAppIdService = mock<AppIdService>();
|
||||
mockLoginService = mock<LoginService>();
|
||||
mockLoginEmailService = mock<LoginEmailServiceAbstraction>();
|
||||
mockUserDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
mockSsoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
mockConfigService = mock<ConfigServiceAbstraction>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
|
||||
mockAcctDecryptionOpts = {
|
||||
noMasterPassword: new AccountDecryptionOptions({
|
||||
mockUserDecryptionOpts = {
|
||||
noMasterPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPassword: new AccountDecryptionOptions({
|
||||
withMasterPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndTrustedDevice: new AccountDecryptionOptions({
|
||||
withMasterPasswordAndTrustedDevice: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({
|
||||
withMasterPasswordAndTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
withMasterPasswordAndKeyConnector: new AccountDecryptionOptions({
|
||||
withMasterPasswordAndKeyConnector: new UserDecryptionOptions({
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
|
||||
}),
|
||||
noMasterPasswordWithTrustedDevice: new AccountDecryptionOptions({
|
||||
noMasterPasswordWithTrustedDevice: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, false),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new AccountDecryptionOptions({
|
||||
noMasterPasswordWithTrustedDeviceWithManageResetPassword: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: new TrustedDeviceUserDecryptionOption(true, false, true),
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
noMasterPasswordWithKeyConnector: new AccountDecryptionOptions({
|
||||
noMasterPasswordWithKeyConnector: new UserDecryptionOptions({
|
||||
hasMasterPassword: false,
|
||||
trustedDeviceOption: undefined,
|
||||
keyConnectorOption: new KeyConnectorUserDecryptionOption("http://example.com"),
|
||||
}),
|
||||
};
|
||||
|
||||
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
|
||||
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [TestTwoFactorComponent],
|
||||
providers: [
|
||||
@@ -152,9 +163,13 @@ describe("TwoFactorComponent", () => {
|
||||
{ provide: LogService, useValue: mockLogService },
|
||||
{ provide: TwoFactorService, useValue: mockTwoFactorService },
|
||||
{ provide: AppIdService, useValue: mockAppIdService },
|
||||
{ provide: LoginService, useValue: mockLoginService },
|
||||
{ provide: LoginEmailServiceAbstraction, useValue: mockLoginEmailService },
|
||||
{
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
useValue: mockUserDecryptionOptionsService,
|
||||
},
|
||||
{ provide: SsoLoginServiceAbstraction, useValue: mockSsoLoginService },
|
||||
{ provide: ConfigServiceAbstraction, useValue: mockConfigService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -213,9 +228,7 @@ describe("TwoFactorComponent", () => {
|
||||
component.remember = remember;
|
||||
component.captchaToken = captchaToken;
|
||||
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
});
|
||||
|
||||
it("calls authService.logInTwoFactor with correct parameters when form is submitted", async () => {
|
||||
@@ -267,11 +280,11 @@ describe("TwoFactorComponent", () => {
|
||||
expect(component.onSuccessfulLogin).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls loginService.clearValues() when login is successful", async () => {
|
||||
it("calls loginEmailService.clearValues() when login is successful", async () => {
|
||||
// Arrange
|
||||
mockLoginStrategyService.logInTwoFactor.mockResolvedValue(new AuthResult());
|
||||
// spy on loginService.clearValues
|
||||
const clearValuesSpy = jest.spyOn(mockLoginService, "clearValues");
|
||||
// spy on loginEmailService.clearValues
|
||||
const clearValuesSpy = jest.spyOn(mockLoginEmailService, "clearValues");
|
||||
|
||||
// Act
|
||||
await component.doSubmit();
|
||||
@@ -289,17 +302,15 @@ describe("TwoFactorComponent", () => {
|
||||
describe("Given user needs to set a master password", () => {
|
||||
beforeEach(() => {
|
||||
// Only need to test the case where the user has no master password to test the primary change mp flow here
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.noMasterPassword,
|
||||
);
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.noMasterPassword);
|
||||
});
|
||||
|
||||
testChangePasswordOnSuccessfulLogin();
|
||||
});
|
||||
|
||||
it("does not navigate to the change password route when the user has key connector even if user has no master password", async () => {
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.noMasterPasswordWithKeyConnector,
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector,
|
||||
);
|
||||
|
||||
await component.doSubmit();
|
||||
@@ -321,9 +332,7 @@ describe("TwoFactorComponent", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
selectedUserDecryptionOptions.next(mockUserDecryptionOpts.withMasterPassword);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
authResult.forcePasswordReset = forceResetPasswordReason;
|
||||
@@ -385,8 +394,8 @@ describe("TwoFactorComponent", () => {
|
||||
|
||||
describe("Given Trusted Device Encryption is enabled and user needs to set a master password", () => {
|
||||
beforeEach(() => {
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword,
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.noMasterPasswordWithTrustedDeviceWithManageResetPassword,
|
||||
);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
@@ -420,8 +429,8 @@ describe("TwoFactorComponent", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
// use standard user with MP because this test is not concerned with password reset.
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
);
|
||||
|
||||
const authResult = new AuthResult();
|
||||
@@ -436,8 +445,8 @@ describe("TwoFactorComponent", () => {
|
||||
describe("Given Trusted Device Encryption is enabled, user doesn't need to set a MP, and forcePasswordReset is not required", () => {
|
||||
let authResult;
|
||||
beforeEach(() => {
|
||||
mockStateService.getAccountDecryptionOptions.mockResolvedValue(
|
||||
mockAcctDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
selectedUserDecryptionOptions.next(
|
||||
mockUserDecryptionOpts.withMasterPasswordAndTrustedDevice,
|
||||
);
|
||||
|
||||
authResult = new AuthResult();
|
||||
|
||||
@@ -6,28 +6,31 @@ import { first } from "rxjs/operators";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { WINDOW } from "@bitwarden/angular/services/injection-tokens";
|
||||
import { LoginStrategyServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import {
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginEmailServiceAbstraction,
|
||||
TrustedDeviceUserDecryptionOption,
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { LoginService } from "@bitwarden/common/auth/abstractions/login.service";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/abstractions/two-factor.service";
|
||||
import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-type";
|
||||
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 { TrustedDeviceUserDecryptionOption } from "@bitwarden/common/auth/models/domain/user-decryption-options/trusted-device-user-decryption-option";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { TwoFactorEmailRequest } from "@bitwarden/common/auth/models/request/two-factor-email.request";
|
||||
import { TwoFactorProviders } from "@bitwarden/common/auth/services/two-factor.service";
|
||||
import { WebAuthnIFrame } from "@bitwarden/common/auth/webauthn-iframe";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { AccountDecryptionOptions } from "@bitwarden/common/platform/models/domain/account";
|
||||
|
||||
import { CaptchaProtectedComponent } from "./captcha-protected.component";
|
||||
|
||||
@@ -85,9 +88,10 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
protected logService: LogService,
|
||||
protected twoFactorService: TwoFactorService,
|
||||
protected appIdService: AppIdService,
|
||||
protected loginService: LoginService,
|
||||
protected loginEmailService: LoginEmailServiceAbstraction,
|
||||
protected userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
protected ssoLoginService: SsoLoginServiceAbstraction,
|
||||
protected configService: ConfigServiceAbstraction,
|
||||
protected configService: ConfigService,
|
||||
) {
|
||||
super(environmentService, i18nService, platformUtilsService);
|
||||
this.webAuthnSupported = this.platformUtilsService.supportsWebAuthn(win);
|
||||
@@ -112,7 +116,8 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
}
|
||||
|
||||
if (this.win != null && this.webAuthnSupported) {
|
||||
const webVaultUrl = this.environmentService.getWebVaultUrl();
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const webVaultUrl = env.getWebVaultUrl();
|
||||
this.webAuthn = new WebAuthnIFrame(
|
||||
this.win,
|
||||
webVaultUrl,
|
||||
@@ -283,29 +288,30 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
// - TDE login decryption options component
|
||||
// - Browser SSO on extension open
|
||||
await this.ssoLoginService.setActiveUserOrganizationSsoIdentifier(this.orgIdentifier);
|
||||
this.loginService.clearValues();
|
||||
this.loginEmailService.clearValues();
|
||||
|
||||
// note: this flow affects both TDE & standard users
|
||||
if (this.isForcePasswordResetRequired(authResult)) {
|
||||
return await this.handleForcePasswordReset(this.orgIdentifier);
|
||||
}
|
||||
|
||||
const acctDecryptionOpts: AccountDecryptionOptions =
|
||||
await this.stateService.getAccountDecryptionOptions();
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
);
|
||||
|
||||
const tdeEnabled = await this.isTrustedDeviceEncEnabled(acctDecryptionOpts.trustedDeviceOption);
|
||||
const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption);
|
||||
|
||||
if (tdeEnabled) {
|
||||
return await this.handleTrustedDeviceEncryptionEnabled(
|
||||
authResult,
|
||||
this.orgIdentifier,
|
||||
acctDecryptionOpts,
|
||||
userDecryptionOpts,
|
||||
);
|
||||
}
|
||||
|
||||
// User must set password if they don't have one and they aren't using either TDE or key connector.
|
||||
const requireSetPassword =
|
||||
!acctDecryptionOpts.hasMasterPassword && acctDecryptionOpts.keyConnectorOption === undefined;
|
||||
!userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined;
|
||||
|
||||
if (requireSetPassword || authResult.resetMasterPassword) {
|
||||
// Change implies going no password -> password in this case
|
||||
@@ -326,12 +332,12 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
private async handleTrustedDeviceEncryptionEnabled(
|
||||
authResult: AuthResult,
|
||||
orgIdentifier: string,
|
||||
acctDecryptionOpts: AccountDecryptionOptions,
|
||||
userDecryptionOpts: UserDecryptionOptions,
|
||||
): Promise<void> {
|
||||
// If user doesn't have a MP, but has reset password permission, they must set a MP
|
||||
if (
|
||||
!acctDecryptionOpts.hasMasterPassword &&
|
||||
acctDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.trustedDeviceOption.hasManageResetPasswordPermission
|
||||
) {
|
||||
// Set flag so that auth guard can redirect to set password screen after decryption (trusted or untrusted device)
|
||||
// Note: we cannot directly navigate to the set password screen in this scenario as we are in a pre-decryption state, and
|
||||
@@ -489,5 +495,5 @@ export class TwoFactorComponent extends CaptchaProtectedComponent implements OnI
|
||||
}
|
||||
|
||||
// implemented in clients
|
||||
launchDuoFrameless() {}
|
||||
async launchDuoFrameless() {}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function lockGuard(): CanActivateFn {
|
||||
|
||||
// User is authN and in locked state.
|
||||
|
||||
const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust();
|
||||
const tdeEnabled = await firstValueFrom(deviceTrustCryptoService.supportsDeviceTrust$);
|
||||
|
||||
// Create special exception which allows users to go from the login-initiated page to the lock page for the approve w/ MP flow
|
||||
// The MP check is necessary to prevent direct manual navigation from other locked state pages for users who don't have a MP
|
||||
|
||||
@@ -46,7 +46,7 @@ export function redirectGuard(overrides: Partial<RedirectRoutes> = {}): CanActiv
|
||||
|
||||
// If locked, TDE is enabled, and the user hasn't decrypted yet, then redirect to the
|
||||
// login decryption options component.
|
||||
const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust();
|
||||
const tdeEnabled = await firstValueFrom(deviceTrustCryptoService.supportsDeviceTrust$);
|
||||
const everHadUserKey = await firstValueFrom(cryptoService.everHadUserKey$);
|
||||
if (authStatus === AuthenticationStatus.Locked && tdeEnabled && !everHadUserKey) {
|
||||
return router.createUrlTree([routes.notDecrypted], { queryParams: route.queryParams });
|
||||
|
||||
@@ -26,7 +26,7 @@ export function tdeDecryptionRequiredGuard(): CanActivateFn {
|
||||
const router = inject(Router);
|
||||
|
||||
const authStatus = await authService.getAuthStatus();
|
||||
const tdeEnabled = await deviceTrustCryptoService.supportsDeviceTrust();
|
||||
const tdeEnabled = await firstValueFrom(deviceTrustCryptoService.supportsDeviceTrust$);
|
||||
const everHadUserKey = await firstValueFrom(cryptoService.everHadUserKey$);
|
||||
if (authStatus !== AuthenticationStatus.Locked || !tdeEnabled || everHadUserKey) {
|
||||
return router.createUrlTree(["/"]);
|
||||
|
||||
@@ -62,6 +62,7 @@ export class ShareComponent implements OnInit, OnDestroy {
|
||||
this.organizations$.pipe(takeUntil(this._destroy)).subscribe((orgs) => {
|
||||
if (this.organizationId == null && orgs.length > 0) {
|
||||
this.organizationId = orgs[0].id;
|
||||
this.filterCollections();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,8 +70,6 @@ export class ShareComponent implements OnInit, OnDestroy {
|
||||
this.cipher = await cipherDomain.decrypt(
|
||||
await this.cipherService.getKeyForCipherKeyDecryption(cipherDomain),
|
||||
);
|
||||
|
||||
this.filterCollections();
|
||||
}
|
||||
|
||||
filterCollections() {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { By } from "@angular/platform-browser";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { FeatureFlag, FeatureFlagValue } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
import { IfFeatureDirective } from "./if-feature.directive";
|
||||
@@ -39,7 +39,7 @@ class TestComponent {
|
||||
describe("IfFeatureDirective", () => {
|
||||
let fixture: ComponentFixture<TestComponent>;
|
||||
let content: HTMLElement;
|
||||
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
|
||||
const mockConfigFlagValue = (flag: FeatureFlag, flagValue: FeatureFlagValue) => {
|
||||
mockConfigService.getFeatureFlag.mockImplementation((f, defaultValue) =>
|
||||
@@ -51,14 +51,14 @@ describe("IfFeatureDirective", () => {
|
||||
fixture.debugElement.query(By.css(`[data-testid="${testId}"]`))?.nativeElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockConfigService = mock<ConfigServiceAbstraction>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [IfFeatureDirective, TestComponent],
|
||||
providers: [
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{
|
||||
provide: ConfigServiceAbstraction,
|
||||
provide: ConfigService,
|
||||
useValue: mockConfigService,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Directive, Input, OnInit, TemplateRef, ViewContainerRef } from "@angular/core";
|
||||
|
||||
import { FeatureFlag, FeatureFlagValue } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
/**
|
||||
@@ -30,7 +30,7 @@ export class IfFeatureDirective implements OnInit {
|
||||
constructor(
|
||||
private templateRef: TemplateRef<any>,
|
||||
private viewContainer: ViewContainerRef,
|
||||
private configService: ConfigServiceAbstraction,
|
||||
private configService: ConfigService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ import { UserTypePipe } from "./pipes/user-type.pipe";
|
||||
import { EllipsisPipe } from "./platform/pipes/ellipsis.pipe";
|
||||
import { FingerprintPipe } from "./platform/pipes/fingerprint.pipe";
|
||||
import { I18nPipe } from "./platform/pipes/i18n.pipe";
|
||||
import { ExportScopeCalloutComponent } from "./tools/export/components/export-scope-callout.component";
|
||||
import { PasswordStrengthComponent } from "./tools/password-strength/password-strength.component";
|
||||
import { IconComponent } from "./vault/components/icon.component";
|
||||
|
||||
@@ -54,7 +53,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
CopyTextDirective,
|
||||
CreditCardNumberPipe,
|
||||
EllipsisPipe,
|
||||
ExportScopeCalloutComponent,
|
||||
FallbackSrcDirective,
|
||||
I18nPipe,
|
||||
IconComponent,
|
||||
@@ -85,7 +83,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
CopyTextDirective,
|
||||
CreditCardNumberPipe,
|
||||
EllipsisPipe,
|
||||
ExportScopeCalloutComponent,
|
||||
FallbackSrcDirective,
|
||||
I18nPipe,
|
||||
IconComponent,
|
||||
|
||||
@@ -9,5 +9,5 @@ export interface FormGroupControls {
|
||||
}
|
||||
|
||||
export abstract class FormValidationErrorsService {
|
||||
getFormValidationErrors: (controls: FormGroupControls) => AllValidationErrors[];
|
||||
abstract getFormValidationErrors(controls: FormGroupControls): AllValidationErrors[];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -21,11 +21,11 @@ describe("canAccessFeature", () => {
|
||||
const featureRoute = "enabled-feature";
|
||||
const redirectRoute = "redirect";
|
||||
|
||||
let mockConfigService: MockProxy<ConfigServiceAbstraction>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockPlatformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
|
||||
const setup = (featureGuard: CanActivateFn, flagValue: any) => {
|
||||
mockConfigService = mock<ConfigServiceAbstraction>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockPlatformUtilsService = mock<PlatformUtilsService>();
|
||||
|
||||
// Mock the correct getter based on the type of flagValue; also mock default values if one is not provided
|
||||
@@ -56,7 +56,7 @@ describe("canAccessFeature", () => {
|
||||
]),
|
||||
],
|
||||
providers: [
|
||||
{ provide: ConfigServiceAbstraction, useValue: mockConfigService },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: PlatformUtilsService, useValue: mockPlatformUtilsService },
|
||||
{ provide: LogService, useValue: mock<LogService>() },
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { inject } from "@angular/core";
|
||||
import { CanActivateFn, Router } from "@angular/router";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -23,7 +23,7 @@ export const canAccessFeature = (
|
||||
redirectUrlOnDisabled?: string,
|
||||
): CanActivateFn => {
|
||||
return async () => {
|
||||
const configService = inject(ConfigServiceAbstraction);
|
||||
const configService = inject(ConfigService);
|
||||
const platformUtilsService = inject(PlatformUtilsService);
|
||||
const router = inject(Router);
|
||||
const i18nService = inject(I18nService);
|
||||
|
||||
22
libs/angular/src/platform/services/logging-error-handler.ts
Normal file
22
libs/angular/src/platform/services/logging-error-handler.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { ErrorHandler, Injectable, Injector, inject } from "@angular/core";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
|
||||
@Injectable()
|
||||
export class LoggingErrorHandler extends ErrorHandler {
|
||||
/**
|
||||
* When injecting services into an `ErrorHandler`, we must use the `Injector` manually to avoid circular dependency errors.
|
||||
*
|
||||
* https://stackoverflow.com/a/57115053
|
||||
*/
|
||||
private injector = inject(Injector);
|
||||
|
||||
override handleError(error: any): void {
|
||||
try {
|
||||
const logService = this.injector.get(LogService, null);
|
||||
logService.error(error);
|
||||
} catch {
|
||||
super.handleError(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,12 @@ export abstract class AbstractThemingService {
|
||||
* The effective theme based on the user configured choice and the current system theme if
|
||||
* the configured choice is {@link ThemeType.System}.
|
||||
*/
|
||||
theme$: Observable<ThemeType>;
|
||||
abstract theme$: Observable<ThemeType>;
|
||||
/**
|
||||
* Listens for effective theme changes and applies changes to the provided document.
|
||||
* @param document The document that should have theme classes applied to it.
|
||||
*
|
||||
* @returns A subscription that can be unsubscribed from to cancel the application of theme classes.
|
||||
*/
|
||||
applyThemeChangesTo: (document: Document) => Subscription;
|
||||
abstract applyThemeChangesTo(document: Document): Subscription;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Constructor, Opaque } from "type-fest";
|
||||
import { SafeInjectionToken } from "../../services/injection-tokens";
|
||||
|
||||
/**
|
||||
* The return type of our dependency helper functions.
|
||||
* The return type of the {@link safeProvider} helper function.
|
||||
* Used to distinguish a type safe provider definition from a non-type safe provider definition.
|
||||
*/
|
||||
export type SafeProvider = Opaque<Provider>;
|
||||
@@ -18,12 +18,22 @@ type MapParametersToDeps<T> = {
|
||||
|
||||
type SafeInjectionTokenType<T> = T extends SafeInjectionToken<infer J> ? J : never;
|
||||
|
||||
/**
|
||||
* Gets the instance type from a constructor, abstract constructor, or SafeInjectionToken
|
||||
*/
|
||||
type ProviderInstanceType<T> =
|
||||
T extends SafeInjectionToken<any>
|
||||
? InstanceType<SafeInjectionTokenType<T>>
|
||||
: T extends Constructor<any> | AbstractConstructor<any>
|
||||
? InstanceType<T>
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Represents a dependency provided with the useClass option.
|
||||
*/
|
||||
type SafeClassProvider<
|
||||
A extends AbstractConstructor<any>,
|
||||
I extends Constructor<InstanceType<A>>,
|
||||
A extends AbstractConstructor<any> | SafeInjectionToken<any>,
|
||||
I extends Constructor<ProviderInstanceType<A>>,
|
||||
D extends MapParametersToDeps<ConstructorParameters<I>>,
|
||||
> = {
|
||||
provide: A;
|
||||
@@ -40,42 +50,41 @@ type SafeValueProvider<A extends SafeInjectionToken<any>, V extends SafeInjectio
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a dependency provided with the useFactory option where a SafeInjectionToken is used as the token.
|
||||
* Represents a dependency provided with the useFactory option.
|
||||
*/
|
||||
type SafeFactoryProviderWithToken<
|
||||
A extends SafeInjectionToken<any>,
|
||||
I extends (...args: any) => InstanceType<SafeInjectionTokenType<A>>,
|
||||
D extends MapParametersToDeps<Parameters<I>>,
|
||||
> = {
|
||||
provide: A;
|
||||
useFactory: I;
|
||||
deps: D;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a dependency provided with the useFactory option where an abstract class is used as the token.
|
||||
*/
|
||||
type SafeFactoryProviderWithClass<
|
||||
A extends AbstractConstructor<any>,
|
||||
I extends (...args: any) => InstanceType<A>,
|
||||
type SafeFactoryProvider<
|
||||
A extends AbstractConstructor<any> | SafeInjectionToken<any>,
|
||||
I extends (...args: any) => ProviderInstanceType<A>,
|
||||
D extends MapParametersToDeps<Parameters<I>>,
|
||||
> = {
|
||||
provide: A;
|
||||
useFactory: I;
|
||||
deps: D;
|
||||
multi?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a dependency provided with the useExisting option.
|
||||
*/
|
||||
type SafeExistingProvider<
|
||||
A extends Constructor<any> | AbstractConstructor<any>,
|
||||
I extends Constructor<InstanceType<A>> | AbstractConstructor<InstanceType<A>>,
|
||||
A extends Constructor<any> | AbstractConstructor<any> | SafeInjectionToken<any>,
|
||||
I extends Constructor<ProviderInstanceType<A>> | AbstractConstructor<ProviderInstanceType<A>>,
|
||||
> = {
|
||||
provide: A;
|
||||
useExisting: I;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a dependency where there is no abstract token, the token is the implementation
|
||||
*/
|
||||
type SafeConcreteProvider<
|
||||
I extends Constructor<any>,
|
||||
D extends MapParametersToDeps<ConstructorParameters<I>>,
|
||||
> = {
|
||||
provide: I;
|
||||
deps: D;
|
||||
};
|
||||
|
||||
/**
|
||||
* A factory function that creates a provider for the ngModule providers array.
|
||||
* This guarantees type safety for your provider definition. It does nothing at runtime.
|
||||
@@ -84,31 +93,30 @@ type SafeExistingProvider<
|
||||
*/
|
||||
export const safeProvider = <
|
||||
// types for useClass
|
||||
AClass extends AbstractConstructor<any>,
|
||||
IClass extends Constructor<InstanceType<AClass>>,
|
||||
AClass extends AbstractConstructor<any> | SafeInjectionToken<any>,
|
||||
IClass extends Constructor<ProviderInstanceType<AClass>>,
|
||||
DClass extends MapParametersToDeps<ConstructorParameters<IClass>>,
|
||||
// types for useValue
|
||||
AValue extends SafeInjectionToken<any>,
|
||||
VValue extends SafeInjectionTokenType<AValue>,
|
||||
// types for useFactoryWithToken
|
||||
AFactoryToken extends SafeInjectionToken<any>,
|
||||
IFactoryToken extends (...args: any) => InstanceType<SafeInjectionTokenType<AFactoryToken>>,
|
||||
DFactoryToken extends MapParametersToDeps<Parameters<IFactoryToken>>,
|
||||
// types for useFactoryWithClass
|
||||
AFactoryClass extends AbstractConstructor<any>,
|
||||
IFactoryClass extends (...args: any) => InstanceType<AFactoryClass>,
|
||||
DFactoryClass extends MapParametersToDeps<Parameters<IFactoryClass>>,
|
||||
// types for useFactory
|
||||
AFactory extends AbstractConstructor<any> | SafeInjectionToken<any>,
|
||||
IFactory extends (...args: any) => ProviderInstanceType<AFactory>,
|
||||
DFactory extends MapParametersToDeps<Parameters<IFactory>>,
|
||||
// types for useExisting
|
||||
AExisting extends Constructor<any> | AbstractConstructor<any>,
|
||||
AExisting extends Constructor<any> | AbstractConstructor<any> | SafeInjectionToken<any>,
|
||||
IExisting extends
|
||||
| Constructor<InstanceType<AExisting>>
|
||||
| AbstractConstructor<InstanceType<AExisting>>,
|
||||
| Constructor<ProviderInstanceType<AExisting>>
|
||||
| AbstractConstructor<ProviderInstanceType<AExisting>>,
|
||||
// types for no token
|
||||
IConcrete extends Constructor<any>,
|
||||
DConcrete extends MapParametersToDeps<ConstructorParameters<IConcrete>>,
|
||||
>(
|
||||
provider:
|
||||
| SafeClassProvider<AClass, IClass, DClass>
|
||||
| SafeValueProvider<AValue, VValue>
|
||||
| SafeFactoryProviderWithToken<AFactoryToken, IFactoryToken, DFactoryToken>
|
||||
| SafeFactoryProviderWithClass<AFactoryClass, IFactoryClass, DFactoryClass>
|
||||
| SafeFactoryProvider<AFactory, IFactory, DFactory>
|
||||
| SafeExistingProvider<AExisting, IExisting>
|
||||
| SafeConcreteProvider<IConcrete, DConcrete>
|
||||
| Constructor<unknown>,
|
||||
): SafeProvider => provider as SafeProvider;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { LOCALE_ID, NgModule } from "@angular/core";
|
||||
import { UnwrapOpaque } from "type-fest";
|
||||
import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
|
||||
|
||||
import {
|
||||
AuthRequestServiceAbstraction,
|
||||
@@ -8,6 +7,11 @@ import {
|
||||
PinCryptoService,
|
||||
LoginStrategyServiceAbstraction,
|
||||
LoginStrategyService,
|
||||
LoginEmailServiceAbstraction,
|
||||
LoginEmailService,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
UserDecryptionOptionsService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
|
||||
@@ -56,7 +60,6 @@ import { DeviceTrustCryptoServiceAbstraction } from "@bitwarden/common/auth/abst
|
||||
import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices/devices.service.abstraction";
|
||||
import { DevicesApiServiceAbstraction } from "@bitwarden/common/auth/abstractions/devices-api.service.abstraction";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
import { LoginService as LoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/login.service";
|
||||
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";
|
||||
@@ -75,7 +78,6 @@ import { DeviceTrustCryptoService } from "@bitwarden/common/auth/services/device
|
||||
import { DevicesServiceImplementation } from "@bitwarden/common/auth/services/devices/devices.service.implementation";
|
||||
import { DevicesApiServiceImplementation } from "@bitwarden/common/auth/services/devices-api.service.implementation";
|
||||
import { KeyConnectorService } from "@bitwarden/common/auth/services/key-connector.service";
|
||||
import { LoginService } from "@bitwarden/common/auth/services/login.service";
|
||||
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";
|
||||
@@ -108,11 +110,11 @@ import { PaymentMethodWarningsService } from "@bitwarden/common/billing/services
|
||||
import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/platform/abstractions/broadcaster.service";
|
||||
import { ConfigApiServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
import { EnvironmentService as EnvironmentServiceAbstraction } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { FileUploadService as FileUploadServiceAbstraction } from "@bitwarden/common/platform/abstractions/file-upload/file-upload.service";
|
||||
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "@bitwarden/common/platform/abstractions/key-generation.service";
|
||||
@@ -132,12 +134,12 @@ import { Account } from "@bitwarden/common/platform/models/domain/account";
|
||||
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
|
||||
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
|
||||
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/services/config/config.service";
|
||||
import { DefaultConfigService } from "@bitwarden/common/platform/services/config/default-config.service";
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/services/crypto.service";
|
||||
import { EncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/encrypt.service.implementation";
|
||||
import { MultithreadEncryptServiceImplementation } from "@bitwarden/common/platform/services/cryptography/multithread-encrypt.service.implementation";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/services/environment.service";
|
||||
import { DefaultEnvironmentService } from "@bitwarden/common/platform/services/default-environment.service";
|
||||
import { FileUploadService } from "@bitwarden/common/platform/services/file-upload/file-upload.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/platform/services/key-generation.service";
|
||||
import { MigrationBuilderService } from "@bitwarden/common/platform/services/migration-builder.service";
|
||||
@@ -236,6 +238,7 @@ import { UnauthGuard } from "../auth/guards/unauth.guard";
|
||||
import { FormValidationErrorsService as FormValidationErrorsServiceAbstraction } from "../platform/abstractions/form-validation-errors.service";
|
||||
import { BroadcasterService } from "../platform/services/broadcaster.service";
|
||||
import { FormValidationErrorsService } from "../platform/services/form-validation-errors.service";
|
||||
import { LoggingErrorHandler } from "../platform/services/logging-error-handler";
|
||||
import { AngularThemingService } from "../platform/services/theming/angular-theming.service";
|
||||
import { AbstractThemingService } from "../platform/services/theming/theming.service.abstraction";
|
||||
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
|
||||
@@ -243,8 +246,8 @@ import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
|
||||
import {
|
||||
LOCALES_DIRECTORY,
|
||||
LOCKED_CALLBACK,
|
||||
LOG_MAC_FAILURES,
|
||||
LOGOUT_CALLBACK,
|
||||
LOG_MAC_FAILURES,
|
||||
MEMORY_STORAGE,
|
||||
OBSERVABLE_DISK_STORAGE,
|
||||
OBSERVABLE_MEMORY_STORAGE,
|
||||
@@ -264,7 +267,7 @@ import { ModalService } from "./modal.service";
|
||||
* Add your provider definition here using the safeProvider function as a wrapper. This will give you type safety.
|
||||
* If you need help please ask for it, do NOT change the type of this array.
|
||||
*/
|
||||
const typesafeProviders: Array<SafeProvider> = [
|
||||
const safeProviders: SafeProvider[] = [
|
||||
safeProvider(AuthGuard),
|
||||
safeProvider(UnauthGuard),
|
||||
safeProvider(ModalService),
|
||||
@@ -342,10 +345,12 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
provide: AuthServiceAbstraction,
|
||||
useClass: AuthService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
MessagingServiceAbstraction,
|
||||
CryptoServiceAbstraction,
|
||||
ApiServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
TokenServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -360,7 +365,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
MessagingServiceAbstraction,
|
||||
LogService,
|
||||
KeyConnectorServiceAbstraction,
|
||||
EnvironmentServiceAbstraction,
|
||||
EnvironmentService,
|
||||
StateServiceAbstraction,
|
||||
TwoFactorServiceAbstraction,
|
||||
I18nServiceAbstraction,
|
||||
@@ -369,6 +374,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
PolicyServiceAbstraction,
|
||||
DeviceTrustCryptoServiceAbstraction,
|
||||
AuthRequestServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
GlobalStateProvider,
|
||||
BillingAccountProfileStateService,
|
||||
],
|
||||
@@ -395,7 +401,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
autofillSettingsService: AutofillSettingsServiceAbstraction,
|
||||
encryptService: EncryptService,
|
||||
fileUploadService: CipherFileUploadServiceAbstraction,
|
||||
configService: ConfigServiceAbstraction,
|
||||
configService: ConfigService,
|
||||
stateProvider: StateProvider,
|
||||
) =>
|
||||
new CipherService(
|
||||
@@ -421,7 +427,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
AutofillSettingsServiceAbstraction,
|
||||
EncryptService,
|
||||
CipherFileUploadServiceAbstraction,
|
||||
ConfigServiceAbstraction,
|
||||
configService,
|
||||
StateProvider,
|
||||
],
|
||||
}),
|
||||
@@ -475,10 +481,19 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
deps: [CryptoServiceAbstraction, I18nServiceAbstraction, StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: EnvironmentServiceAbstraction,
|
||||
useClass: EnvironmentService,
|
||||
provide: EnvironmentService,
|
||||
useClass: DefaultEnvironmentService,
|
||||
deps: [StateProvider, AccountServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
useClass: UserDecryptionOptionsService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: UserDecryptionOptionsServiceAbstraction,
|
||||
useExisting: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: TotpServiceAbstraction,
|
||||
useClass: TotpService,
|
||||
@@ -491,7 +506,10 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
SingleUserStateProvider,
|
||||
GlobalStateProvider,
|
||||
SUPPORTS_SECURE_STORAGE,
|
||||
AbstractStorageService,
|
||||
SECURE_STORAGE,
|
||||
KeyGenerationServiceAbstraction,
|
||||
EncryptService,
|
||||
LogService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -534,7 +552,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
deps: [
|
||||
TokenServiceAbstraction,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
EnvironmentServiceAbstraction,
|
||||
EnvironmentService,
|
||||
AppIdServiceAbstraction,
|
||||
StateServiceAbstraction,
|
||||
LOGOUT_CALLBACK,
|
||||
@@ -579,6 +597,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
FolderApiServiceAbstraction,
|
||||
InternalOrganizationServiceAbstraction,
|
||||
SendApiServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
AvatarServiceAbstraction,
|
||||
LOGOUT_CALLBACK,
|
||||
BillingAccountProfileStateService,
|
||||
@@ -589,6 +608,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
provide: VaultTimeoutSettingsServiceAbstraction,
|
||||
useClass: VaultTimeoutSettingsService,
|
||||
deps: [
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
CryptoServiceAbstraction,
|
||||
TokenServiceAbstraction,
|
||||
PolicyServiceAbstraction,
|
||||
@@ -634,7 +654,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
LogService,
|
||||
STATE_FACTORY,
|
||||
AccountServiceAbstraction,
|
||||
EnvironmentServiceAbstraction,
|
||||
EnvironmentService,
|
||||
TokenServiceAbstraction,
|
||||
MigrationRunner,
|
||||
STATE_SERVICE_USE_CACHE,
|
||||
@@ -698,7 +718,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
SyncServiceAbstraction,
|
||||
AppIdServiceAbstraction,
|
||||
ApiServiceAbstraction,
|
||||
EnvironmentServiceAbstraction,
|
||||
EnvironmentService,
|
||||
LOGOUT_CALLBACK,
|
||||
StateServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
@@ -749,7 +769,6 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
provide: KeyConnectorServiceAbstraction,
|
||||
useClass: KeyConnectorService,
|
||||
deps: [
|
||||
StateServiceAbstraction,
|
||||
CryptoServiceAbstraction,
|
||||
ApiServiceAbstraction,
|
||||
TokenServiceAbstraction,
|
||||
@@ -757,6 +776,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
OrganizationServiceAbstraction,
|
||||
KeyGenerationServiceAbstraction,
|
||||
LOGOUT_CALLBACK,
|
||||
StateProvider,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -767,6 +787,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
CryptoServiceAbstraction,
|
||||
I18nServiceAbstraction,
|
||||
UserVerificationApiServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
PinCryptoServiceAbstraction,
|
||||
LogService,
|
||||
VaultTimeoutSettingsServiceAbstraction,
|
||||
@@ -833,29 +854,23 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
deps: [],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ConfigService,
|
||||
useClass: ConfigService,
|
||||
deps: [
|
||||
StateServiceAbstraction,
|
||||
ConfigApiServiceAbstraction,
|
||||
AuthServiceAbstraction,
|
||||
EnvironmentServiceAbstraction,
|
||||
LogService,
|
||||
],
|
||||
provide: DefaultConfigService,
|
||||
useClass: DefaultConfigService,
|
||||
deps: [ConfigApiServiceAbstraction, EnvironmentService, LogService, StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ConfigServiceAbstraction,
|
||||
useExisting: ConfigService,
|
||||
provide: ConfigService,
|
||||
useExisting: DefaultConfigService,
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ConfigApiServiceAbstraction,
|
||||
useClass: ConfigApiService,
|
||||
deps: [ApiServiceAbstraction, AuthServiceAbstraction],
|
||||
deps: [ApiServiceAbstraction, TokenServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: AnonymousHubServiceAbstraction,
|
||||
useClass: AnonymousHubService,
|
||||
deps: [EnvironmentServiceAbstraction, LoginStrategyServiceAbstraction, LogService],
|
||||
deps: [EnvironmentService, AuthRequestServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ValidationServiceAbstraction,
|
||||
@@ -863,9 +878,9 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
deps: [I18nServiceAbstraction, PlatformUtilsServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: LoginServiceAbstraction,
|
||||
useClass: LoginService,
|
||||
deps: [StateServiceAbstraction],
|
||||
provide: LoginEmailServiceAbstraction,
|
||||
useClass: LoginEmailService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrgDomainInternalServiceAbstraction,
|
||||
@@ -899,11 +914,13 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
CryptoFunctionServiceAbstraction,
|
||||
CryptoServiceAbstraction,
|
||||
EncryptService,
|
||||
StateServiceAbstraction,
|
||||
AppIdServiceAbstraction,
|
||||
DevicesApiServiceAbstraction,
|
||||
I18nServiceAbstraction,
|
||||
PlatformUtilsServiceAbstraction,
|
||||
StateProvider,
|
||||
SECURE_STORAGE,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
@@ -934,7 +951,7 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
safeProvider({
|
||||
provide: WebAuthnLoginApiServiceAbstraction,
|
||||
useClass: WebAuthnLoginApiService,
|
||||
deps: [ApiServiceAbstraction, EnvironmentServiceAbstraction],
|
||||
deps: [ApiServiceAbstraction, EnvironmentService],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebAuthnLoginServiceAbstraction,
|
||||
@@ -1050,13 +1067,18 @@ const typesafeProviders: Array<SafeProvider> = [
|
||||
safeProvider({
|
||||
provide: BillingAccountProfileStateService,
|
||||
useClass: DefaultBillingAccountProfileStateService,
|
||||
deps: [ActiveUserStateProvider],
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: OrganizationManagementPreferencesService,
|
||||
useClass: DefaultOrganizationManagementPreferencesService,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ErrorHandler,
|
||||
useClass: LoggingErrorHandler,
|
||||
deps: [],
|
||||
}),
|
||||
];
|
||||
|
||||
function encryptServiceFactory(
|
||||
@@ -1072,6 +1094,6 @@ function encryptServiceFactory(
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
// Do not register your dependency here! Add it to the typesafeProviders array using the helper function
|
||||
providers: typesafeProviders as UnwrapOpaque<SafeProvider>[],
|
||||
providers: safeProviders,
|
||||
})
|
||||
export class JslibServicesModule {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DatePipe } from "@angular/common";
|
||||
import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { BehaviorSubject, Subject, concatMap, firstValueFrom, map, takeUntil } from "rxjs";
|
||||
import { Subject, firstValueFrom, takeUntil, map, BehaviorSubject, concatMap } from "rxjs";
|
||||
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
@@ -123,7 +123,6 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
{ name: i18nService.t("sendTypeFile"), value: SendType.File, premium: true },
|
||||
{ name: i18nService.t("sendTypeText"), value: SendType.Text, premium: false },
|
||||
];
|
||||
this.sendLinkBaseUrl = this.environmentService.getSendUrl();
|
||||
}
|
||||
|
||||
get link(): string {
|
||||
@@ -190,6 +189,9 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
this.sendLinkBaseUrl = env.getSendUrl();
|
||||
|
||||
this.billingAccountProfileStateService.hasPremiumFromAnySource$
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((hasPremiumFromAnySource) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Directive, NgZone, OnDestroy, OnInit } from "@angular/core";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
import { Subject, firstValueFrom, takeUntil } from "rxjs";
|
||||
|
||||
import { SearchService } from "@bitwarden/common/abstractions/search.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
@@ -198,9 +198,9 @@ export class SendComponent implements OnInit, OnDestroy {
|
||||
return true;
|
||||
}
|
||||
|
||||
copy(s: SendView) {
|
||||
const sendLinkBaseUrl = this.environmentService.getSendUrl();
|
||||
const link = sendLinkBaseUrl + s.accessId + "/" + s.urlB64Key;
|
||||
async copy(s: SendView) {
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const link = env.getSendUrl() + s.accessId + "/" + s.urlB64Key;
|
||||
this.platformUtilsService.copyToClipboard(link);
|
||||
this.platformUtilsService.showToast(
|
||||
"success",
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga
|
||||
import { EventType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
|
||||
import { ConfigServiceAbstraction } from "@bitwarden/common/platform/abstractions/config/config.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
@@ -119,7 +119,7 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
protected dialogService: DialogService,
|
||||
protected win: Window,
|
||||
protected datePipe: DatePipe,
|
||||
protected configService: ConfigServiceAbstraction,
|
||||
protected configService: ConfigService,
|
||||
) {
|
||||
this.typeOptions = [
|
||||
{ name: i18nService.t("typeLogin"), value: CipherType.Login },
|
||||
@@ -402,6 +402,14 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
removePasskey() {
|
||||
if (this.cipher.type !== CipherType.Login || this.cipher.login.fido2Credentials == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.cipher.login.fido2Credentials = null;
|
||||
}
|
||||
|
||||
onCardNumberChange(): void {
|
||||
this.cipher.card.brand = CardView.getCardBrandByPatterns(this.cipher.card.number);
|
||||
}
|
||||
@@ -650,11 +658,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
||||
|
||||
protected saveCipher(cipher: Cipher) {
|
||||
const isNotClone = this.editMode && !this.cloneMode;
|
||||
let orgAdmin = this.organization?.isAdmin;
|
||||
let orgAdmin = this.organization?.canEditAllCiphers(this.flexibleCollectionsV1Enabled);
|
||||
|
||||
if (this.flexibleCollectionsV1Enabled) {
|
||||
// Flexible Collections V1 restricts admins, check the organization setting via canEditAllCiphers
|
||||
orgAdmin = this.organization?.canEditAllCiphers(true);
|
||||
// if a cipher is unassigned we want to check if they are an admin or have permission to edit any collection
|
||||
if (!cipher.collectionIds) {
|
||||
orgAdmin = this.organization?.canEditAnyCollection;
|
||||
}
|
||||
|
||||
return this.cipher.id == null
|
||||
|
||||
@@ -39,11 +39,12 @@ export class IconComponent implements OnInit {
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const iconsUrl = this.environmentService.getIconsUrl();
|
||||
|
||||
this.data$ = combineLatest([
|
||||
this.environmentService.environment$.pipe(map((e) => e.getIconsUrl())),
|
||||
this.domainSettingsService.showFavicons$.pipe(distinctUntilChanged()),
|
||||
this.cipher$.pipe(filter((c) => c !== undefined)),
|
||||
]).pipe(map(([showFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, showFavicon)));
|
||||
]).pipe(
|
||||
map(([iconsUrl, showFavicon, cipher]) => buildCipherIcon(iconsUrl, cipher, showFavicon)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Directive } from "@angular/core";
|
||||
import { Observable, Subject } from "rxjs";
|
||||
import { OnInit, Directive } from "@angular/core";
|
||||
import { firstValueFrom, Observable } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
@@ -11,12 +11,11 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
|
||||
import { DialogService } from "@bitwarden/components";
|
||||
|
||||
@Directive()
|
||||
export class PremiumComponent {
|
||||
export class PremiumComponent implements OnInit {
|
||||
isPremium$: Observable<boolean>;
|
||||
price = 10;
|
||||
refreshPromise: Promise<any>;
|
||||
cloudWebVaultUrl: string;
|
||||
private directiveIsDestroyed$ = new Subject<boolean>();
|
||||
|
||||
constructor(
|
||||
protected i18nService: I18nService,
|
||||
@@ -25,13 +24,16 @@ export class PremiumComponent {
|
||||
private logService: LogService,
|
||||
protected stateService: StateService,
|
||||
protected dialogService: DialogService,
|
||||
environmentService: EnvironmentService,
|
||||
private environmentService: EnvironmentService,
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
) {
|
||||
this.cloudWebVaultUrl = environmentService.getCloudWebVaultUrl();
|
||||
this.isPremium$ = billingAccountProfileStateService.hasPremiumFromAnySource$;
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.cloudWebVaultUrl = await firstValueFrom(this.environmentService.cloudWebVaultUrl$);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
try {
|
||||
this.refreshPromise = this.apiService.refreshIdentityToken();
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
{{ dialogOptions.bodyText | i18n }}
|
||||
</p>
|
||||
|
||||
<app-callout
|
||||
<bit-callout
|
||||
*ngIf="dialogOptions.calloutOptions"
|
||||
[type]="dialogOptions.calloutOptions.type"
|
||||
>
|
||||
{{ dialogOptions.calloutOptions.text | i18n }}
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
</ng-container>
|
||||
|
||||
<!-- Shown when client side verification methods picked and no verification methods found -->
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
DialogModule,
|
||||
DialogService,
|
||||
} from "@bitwarden/components";
|
||||
@@ -34,6 +35,7 @@ import { UserVerificationFormInputComponent } from "./user-verification-form-inp
|
||||
DialogModule,
|
||||
AsyncActionsModule,
|
||||
UserVerificationFormInputComponent,
|
||||
CalloutModule,
|
||||
],
|
||||
})
|
||||
export class UserVerificationDialogComponent {
|
||||
|
||||
@@ -11,9 +11,9 @@ export type UserVerificationCalloutOptions = {
|
||||
|
||||
/**
|
||||
* The type of the callout.
|
||||
* Can be "warning", "danger", "error", or "tip".
|
||||
* Can be "warning", "danger", "info", or "success".
|
||||
*/
|
||||
type: "warning" | "danger" | "error" | "tip";
|
||||
type: "warning" | "danger" | "info" | "success";
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,12 +49,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-callout type="error" *ngIf="biometricsVerificationFailed">
|
||||
<bit-callout type="danger" *ngIf="biometricsVerificationFailed">
|
||||
{{ "couldNotCompleteBiometrics" | i18n }}
|
||||
<button bitLink type="button" linkType="primary" (click)="verifyUserViaBiometrics()">
|
||||
{{ "tryAgain" | i18n }}
|
||||
</button>
|
||||
</app-callout>
|
||||
</bit-callout>
|
||||
</ng-container>
|
||||
|
||||
<!-- Alternate verification options if user has more than 1 -->
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
@@ -62,6 +63,7 @@ import { ActiveClientVerificationOption } from "./active-client-verification-opt
|
||||
IconModule,
|
||||
LinkModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
],
|
||||
})
|
||||
// eslint-disable-next-line rxjs-angular/prefer-takeuntil
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
export abstract class AuthRequestServiceAbstraction {
|
||||
/** Emits an auth request id when an auth request has been approved. */
|
||||
authRequestPushNotification$: Observable<string>;
|
||||
/**
|
||||
* Approve or deny an auth request.
|
||||
* @param approve True to approve, false to deny.
|
||||
@@ -54,4 +59,11 @@ export abstract class AuthRequestServiceAbstraction {
|
||||
pubKeyEncryptedMasterKeyHash: string,
|
||||
privateKey: ArrayBuffer,
|
||||
) => Promise<{ masterKey: MasterKey; masterKeyHash: string }>;
|
||||
|
||||
/**
|
||||
* Handles incoming auth request push notifications.
|
||||
* @param notification push notification.
|
||||
* @remark We should only be receiving approved push notifications to prevent enumeration.
|
||||
*/
|
||||
abstract sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => void;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./pin-crypto.service.abstraction";
|
||||
export * from "./login-email.service";
|
||||
export * from "./login-strategy.service";
|
||||
export * from "./user-decryption-options.service.abstraction";
|
||||
export * from "./auth-request.service.abstraction";
|
||||
|
||||
38
libs/auth/src/common/abstractions/login-email.service.ts
Normal file
38
libs/auth/src/common/abstractions/login-email.service.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
export abstract class LoginEmailServiceAbstraction {
|
||||
/**
|
||||
* An observable that monitors the storedEmail
|
||||
*/
|
||||
storedEmail$: Observable<string>;
|
||||
/**
|
||||
* Gets the current email being used in the login process.
|
||||
* @returns A string of the email.
|
||||
*/
|
||||
getEmail: () => string;
|
||||
/**
|
||||
* Sets the current email being used in the login process.
|
||||
* @param email The email to be set.
|
||||
*/
|
||||
setEmail: (email: string) => void;
|
||||
/**
|
||||
* Gets whether or not the email should be stored on disk.
|
||||
* @returns A boolean stating whether or not the email should be stored on disk.
|
||||
*/
|
||||
getRememberEmail: () => boolean;
|
||||
/**
|
||||
* Sets whether or not the email should be stored on disk.
|
||||
*/
|
||||
setRememberEmail: (value: boolean) => void;
|
||||
/**
|
||||
* Sets the email and rememberEmail properties to null.
|
||||
*/
|
||||
clearValues: () => void;
|
||||
/**
|
||||
* - If rememberEmail is true, sets the storedEmail on disk to the current email.
|
||||
* - If rememberEmail is false, sets the storedEmail on disk to null.
|
||||
* - Then sets the email and rememberEmail properties to null.
|
||||
* @returns A promise that resolves once the email settings are saved.
|
||||
*/
|
||||
saveEmailSettings: () => Promise<void>;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { AuthenticationType } from "@bitwarden/common/auth/enums/authentication-
|
||||
import { AuthResult } from "@bitwarden/common/auth/models/domain/auth-result";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import {
|
||||
@@ -21,10 +20,6 @@ export abstract class LoginStrategyServiceAbstraction {
|
||||
* Emits null if the session has timed out.
|
||||
*/
|
||||
currentAuthType$: Observable<AuthenticationType | null>;
|
||||
/**
|
||||
* Emits when an auth request has been approved.
|
||||
*/
|
||||
authRequestPushNotification$: Observable<string>;
|
||||
/**
|
||||
* If the login strategy uses the email address of the user, this
|
||||
* will return it. Otherwise, it will return null.
|
||||
@@ -77,10 +72,6 @@ export abstract class LoginStrategyServiceAbstraction {
|
||||
* Creates a master key from the provided master password and email.
|
||||
*/
|
||||
makePreloginKey: (masterPassword: string, email: string) => Promise<MasterKey>;
|
||||
/**
|
||||
* Sends a notification to {@link LoginStrategyServiceAbstraction.authRequestPushNotification}
|
||||
*/
|
||||
sendAuthRequestPushNotification: (notification: AuthRequestPushNotification) => Promise<void>;
|
||||
/**
|
||||
* Sends a response to an auth request.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptions } from "../models";
|
||||
|
||||
export abstract class UserDecryptionOptionsServiceAbstraction {
|
||||
/**
|
||||
* Returns what decryption options are available for the current user.
|
||||
* @remark This is sent from the server on authentication.
|
||||
*/
|
||||
abstract userDecryptionOptions$: 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>;
|
||||
}
|
||||
|
||||
export abstract class InternalUserDecryptionOptionsServiceAbstraction extends UserDecryptionOptionsServiceAbstraction {
|
||||
/**
|
||||
* 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>;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import { AuthRequestLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import {
|
||||
@@ -37,6 +38,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let twoFactorService: MockProxy<TwoFactorService>;
|
||||
let userDecryptionOptions: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
|
||||
@@ -65,6 +67,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
logService = mock<LogService>();
|
||||
stateService = mock<StateService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
userDecryptionOptions = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
|
||||
@@ -83,6 +86,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptions,
|
||||
deviceTrustCryptoService,
|
||||
billingAccountProfileStateService,
|
||||
);
|
||||
|
||||
@@ -16,7 +16,9 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import { AuthRequestLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
@@ -54,6 +56,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
logService: LogService,
|
||||
stateService: StateService,
|
||||
twoFactorService: TwoFactorService,
|
||||
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
) {
|
||||
@@ -67,6 +70,7 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
billingAccountProfileStateService,
|
||||
);
|
||||
|
||||
@@ -125,8 +129,10 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
await this.cryptoService.setUserKey(authRequestCredentials.decryptedUserKey);
|
||||
} else {
|
||||
await this.trySetUserKeyWithMasterKey();
|
||||
|
||||
const userId = (await this.stateService.getUserId()) as UserId;
|
||||
// Establish trust if required after setting user key
|
||||
await this.deviceTrustCryptoService.trustDeviceIfRequired();
|
||||
await this.deviceTrustCryptoService.trustDeviceIfRequired(userId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
AccountProfile,
|
||||
AccountTokens,
|
||||
AccountKeys,
|
||||
AccountDecryptionOptions,
|
||||
} from "@bitwarden/common/platform/models/domain/account";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
@@ -37,10 +36,12 @@ import {
|
||||
PasswordStrengthService,
|
||||
} from "@bitwarden/common/tools/password-strength";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserKey, MasterKey, DeviceKey } from "@bitwarden/common/types/key";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "../abstractions/login-strategy.service";
|
||||
import { PasswordLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { LoginStrategyServiceAbstraction } from "../abstractions";
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import { PasswordLoginCredentials } from "../models";
|
||||
import { UserDecryptionOptions } from "../models/domain/user-decryption-options";
|
||||
|
||||
import { PasswordLoginStrategy, PasswordLoginStrategyData } from "./password-login.strategy";
|
||||
|
||||
@@ -108,6 +109,7 @@ describe("LoginStrategy", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let twoFactorService: MockProxy<TwoFactorService>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
@@ -126,7 +128,7 @@ describe("LoginStrategy", () => {
|
||||
logService = mock<LogService>();
|
||||
stateService = mock<StateService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
|
||||
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
policyService = mock<PolicyService>();
|
||||
passwordStrengthService = mock<PasswordStrengthService>();
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
@@ -146,6 +148,7 @@ describe("LoginStrategy", () => {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
passwordStrengthService,
|
||||
policyService,
|
||||
loginStrategyService,
|
||||
@@ -183,9 +186,9 @@ describe("LoginStrategy", () => {
|
||||
|
||||
expect(tokenService.setTokens).toHaveBeenCalledWith(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
mockVaultTimeoutAction,
|
||||
mockVaultTimeout,
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
expect(stateService.addAccount).toHaveBeenCalledWith(
|
||||
@@ -204,33 +207,12 @@ describe("LoginStrategy", () => {
|
||||
...new AccountTokens(),
|
||||
},
|
||||
keys: new AccountKeys(),
|
||||
decryptionOptions: AccountDecryptionOptions.fromResponse(idTokenResponse),
|
||||
}),
|
||||
);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
|
||||
});
|
||||
|
||||
it("persists a device key for trusted device encryption when it exists on login", async () => {
|
||||
// Arrange
|
||||
const idTokenResponse = identityTokenResponseFactory();
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
const deviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(userKeyBytesLength).buffer as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
stateService.getDeviceKey.mockResolvedValue(deviceKey);
|
||||
|
||||
const accountKeys = new AccountKeys();
|
||||
accountKeys.deviceKey = deviceKey;
|
||||
|
||||
// Act
|
||||
await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
// Assert
|
||||
expect(stateService.addAccount).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ keys: accountKeys }),
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
|
||||
UserDecryptionOptions.fromResponse(idTokenResponse),
|
||||
);
|
||||
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
|
||||
});
|
||||
|
||||
it("builds AuthResult", async () => {
|
||||
@@ -409,6 +391,7 @@ describe("LoginStrategy", () => {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
passwordStrengthService,
|
||||
policyService,
|
||||
loginStrategyService,
|
||||
|
||||
@@ -26,13 +26,12 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import {
|
||||
AccountKeys,
|
||||
Account,
|
||||
AccountProfile,
|
||||
AccountTokens,
|
||||
AccountDecryptionOptions,
|
||||
} from "@bitwarden/common/platform/models/domain/account";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import {
|
||||
UserApiLoginCredentials,
|
||||
PasswordLoginCredentials,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
AuthRequestLoginCredentials,
|
||||
WebAuthnLoginCredentials,
|
||||
} from "../models/domain/login-credentials";
|
||||
import { UserDecryptionOptions } from "../models/domain/user-decryption-options";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
type IdentityResponse = IdentityTokenResponse | IdentityTwoFactorResponse | IdentityCaptchaResponse;
|
||||
@@ -69,6 +69,7 @@ export abstract class LoginStrategy {
|
||||
protected logService: LogService,
|
||||
protected stateService: StateService,
|
||||
protected twoFactorService: TwoFactorService,
|
||||
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
) {}
|
||||
|
||||
@@ -158,18 +159,8 @@ export abstract class LoginStrategy {
|
||||
protected async saveAccountInformation(tokenResponse: IdentityTokenResponse): Promise<void> {
|
||||
const accountInformation = await this.tokenService.decodeAccessToken(tokenResponse.accessToken);
|
||||
|
||||
// Must persist existing device key if it exists for trusted device decryption to work
|
||||
// However, we must provide a user id so that the device key can be retrieved
|
||||
// as the state service won't have an active account at this point in time
|
||||
// even though the data exists in local storage.
|
||||
const userId = accountInformation.sub;
|
||||
|
||||
const deviceKey = await this.stateService.getDeviceKey({ userId });
|
||||
const accountKeys = new AccountKeys();
|
||||
if (deviceKey) {
|
||||
accountKeys.deviceKey = deviceKey;
|
||||
}
|
||||
|
||||
// If you don't persist existing admin auth requests on login, they will get deleted.
|
||||
const adminAuthRequest = await this.stateService.getAdminAuthRequest({ userId });
|
||||
|
||||
@@ -180,9 +171,9 @@ export abstract class LoginStrategy {
|
||||
// User id will be derived from the access token.
|
||||
await this.tokenService.setTokens(
|
||||
tokenResponse.accessToken,
|
||||
tokenResponse.refreshToken,
|
||||
vaultTimeoutAction as VaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
tokenResponse.refreshToken, // Note: CLI login via API key sends undefined for refresh token.
|
||||
);
|
||||
|
||||
await this.stateService.addAccount(
|
||||
@@ -202,12 +193,14 @@ export abstract class LoginStrategy {
|
||||
tokens: {
|
||||
...new AccountTokens(),
|
||||
},
|
||||
keys: accountKeys,
|
||||
decryptionOptions: AccountDecryptionOptions.fromResponse(tokenResponse),
|
||||
adminAuthRequest: adminAuthRequest?.toJSON(),
|
||||
}),
|
||||
);
|
||||
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptions(
|
||||
UserDecryptionOptions.fromResponse(tokenResponse),
|
||||
);
|
||||
|
||||
await this.billingAccountProfileStateService.setHasPremium(accountInformation.premium, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "../abstractions";
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import { PasswordLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
@@ -60,6 +61,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let twoFactorService: MockProxy<TwoFactorService>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
let passwordStrengthService: MockProxy<PasswordStrengthServiceAbstraction>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
@@ -79,6 +81,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
logService = mock<LogService>();
|
||||
stateService = mock<StateService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
policyService = mock<PolicyService>();
|
||||
passwordStrengthService = mock<PasswordStrengthService>();
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
@@ -108,6 +111,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
passwordStrengthService,
|
||||
policyService,
|
||||
loginStrategyService,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "../abstractions";
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import { PasswordLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
@@ -36,15 +37,11 @@ export class PasswordLoginStrategyData implements LoginStrategyData {
|
||||
|
||||
/** User's entered email obtained pre-login. Always present in MP login. */
|
||||
userEnteredEmail: string;
|
||||
|
||||
/** If 2fa is required, token is returned to bypass captcha */
|
||||
captchaBypassToken?: string;
|
||||
/**
|
||||
* The local version of the user's master key hash
|
||||
*/
|
||||
/** The local version of the user's master key hash */
|
||||
localMasterKeyHash: string;
|
||||
/**
|
||||
* The user's master key
|
||||
*/
|
||||
/** The user's master key */
|
||||
masterKey: MasterKey;
|
||||
/**
|
||||
* Tracks if the user needs to update their password due to
|
||||
@@ -62,14 +59,12 @@ export class PasswordLoginStrategyData implements LoginStrategyData {
|
||||
}
|
||||
|
||||
export class PasswordLoginStrategy extends LoginStrategy {
|
||||
/**
|
||||
* The email address of the user attempting to log in.
|
||||
*/
|
||||
/** The email address of the user attempting to log in. */
|
||||
email$: Observable<string>;
|
||||
/**
|
||||
* The master key hash of the user attempting to log in.
|
||||
*/
|
||||
masterKeyHash$: Observable<string | null>;
|
||||
/** The master key hash used for authentication */
|
||||
serverMasterKeyHash$: Observable<string>;
|
||||
/** The local master key hash we store client side */
|
||||
localMasterKeyHash$: Observable<string | null>;
|
||||
|
||||
protected cache: BehaviorSubject<PasswordLoginStrategyData>;
|
||||
|
||||
@@ -84,6 +79,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
logService: LogService,
|
||||
protected stateService: StateService,
|
||||
twoFactorService: TwoFactorService,
|
||||
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private passwordStrengthService: PasswordStrengthServiceAbstraction,
|
||||
private policyService: PolicyService,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
@@ -99,12 +95,16 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
billingAccountProfileStateService,
|
||||
);
|
||||
|
||||
this.cache = new BehaviorSubject(data);
|
||||
this.email$ = this.cache.pipe(map((state) => state.tokenRequest.email));
|
||||
this.masterKeyHash$ = this.cache.pipe(map((state) => state.localMasterKeyHash));
|
||||
this.serverMasterKeyHash$ = this.cache.pipe(
|
||||
map((state) => state.tokenRequest.masterPasswordHash),
|
||||
);
|
||||
this.localMasterKeyHash$ = this.cache.pipe(map((state) => state.localMasterKeyHash));
|
||||
}
|
||||
|
||||
override async logIn(credentials: PasswordLoginCredentials) {
|
||||
@@ -120,11 +120,14 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
data.masterKey,
|
||||
HashPurpose.LocalAuthorization,
|
||||
);
|
||||
const masterKeyHash = await this.cryptoService.hashMasterKey(masterPassword, data.masterKey);
|
||||
const serverMasterKeyHash = await this.cryptoService.hashMasterKey(
|
||||
masterPassword,
|
||||
data.masterKey,
|
||||
);
|
||||
|
||||
data.tokenRequest = new PasswordTokenRequest(
|
||||
email,
|
||||
masterKeyHash,
|
||||
serverMasterKeyHash,
|
||||
captchaToken,
|
||||
await this.buildTwoFactor(twoFactor, email),
|
||||
await this.buildDeviceRequest(),
|
||||
@@ -168,10 +171,10 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
twoFactor: TokenTwoFactorRequest,
|
||||
captchaResponse: string,
|
||||
): Promise<AuthResult> {
|
||||
this.cache.next({
|
||||
...this.cache.value,
|
||||
captchaBypassToken: captchaResponse ?? this.cache.value.captchaBypassToken,
|
||||
});
|
||||
const data = this.cache.value;
|
||||
data.tokenRequest.captchaResponse = captchaResponse ?? data.captchaBypassToken;
|
||||
this.cache.next(data);
|
||||
|
||||
const result = await super.logInTwoFactor(twoFactor);
|
||||
|
||||
// 2FA was successful, save the force update password options with the state service if defined
|
||||
|
||||
@@ -23,7 +23,10 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { DeviceKey, UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { AuthRequestServiceAbstraction } from "../abstractions";
|
||||
import {
|
||||
AuthRequestServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
} from "../abstractions";
|
||||
import { SsoLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
@@ -39,6 +42,7 @@ describe("SsoLoginStrategy", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let twoFactorService: MockProxy<TwoFactorService>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let keyConnectorService: MockProxy<KeyConnectorService>;
|
||||
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
|
||||
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
|
||||
@@ -66,6 +70,7 @@ describe("SsoLoginStrategy", () => {
|
||||
logService = mock<LogService>();
|
||||
stateService = mock<StateService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
keyConnectorService = mock<KeyConnectorService>();
|
||||
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
|
||||
authRequestService = mock<AuthRequestServiceAbstraction>();
|
||||
@@ -87,6 +92,7 @@ describe("SsoLoginStrategy", () => {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
keyConnectorService,
|
||||
deviceTrustCryptoService,
|
||||
authRequestService,
|
||||
|
||||
@@ -20,8 +20,12 @@ import { LogService } from "@bitwarden/common/platform/abstractions/log.service"
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { AuthRequestServiceAbstraction } from "../abstractions";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
AuthRequestServiceAbstraction,
|
||||
} from "../abstractions";
|
||||
import { SsoLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
@@ -84,6 +88,7 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
logService: LogService,
|
||||
stateService: StateService,
|
||||
twoFactorService: TwoFactorService,
|
||||
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private keyConnectorService: KeyConnectorService,
|
||||
private deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
@@ -100,6 +105,7 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
billingAccountProfileStateService,
|
||||
);
|
||||
|
||||
@@ -279,7 +285,8 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
if (await this.cryptoService.hasUserKey()) {
|
||||
// Now that we have a decrypted user key in memory, we can check if we
|
||||
// need to establish trust on the current device
|
||||
await this.deviceTrustCryptoService.trustDeviceIfRequired();
|
||||
const userId = (await this.stateService.getUserId()) as UserId;
|
||||
await this.deviceTrustCryptoService.trustDeviceIfRequired(userId);
|
||||
|
||||
// if we successfully decrypted the user key, we can delete the admin auth request out of state
|
||||
// TODO: eventually we post and clean up DB as well once consumed on client
|
||||
@@ -293,7 +300,9 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
private async trySetUserKeyWithDeviceKey(tokenResponse: IdentityTokenResponse): Promise<void> {
|
||||
const trustedDeviceOption = tokenResponse.userDecryptionOptions?.trustedDeviceOption;
|
||||
|
||||
const deviceKey = await this.deviceTrustCryptoService.getDeviceKey();
|
||||
const userId = (await this.stateService.getUserId()) as UserId;
|
||||
|
||||
const deviceKey = await this.deviceTrustCryptoService.getDeviceKey(userId);
|
||||
const encDevicePrivateKey = trustedDeviceOption?.encryptedPrivateKey;
|
||||
const encUserKey = trustedDeviceOption?.encryptedUserKey;
|
||||
|
||||
@@ -302,6 +311,7 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
}
|
||||
|
||||
const userKey = await this.deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
userId,
|
||||
encDevicePrivateKey,
|
||||
encUserKey,
|
||||
deviceKey,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/auth/abstractions/key-connector.service";
|
||||
@@ -8,7 +9,10 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
|
||||
import { VaultTimeoutAction } from "@bitwarden/common/enums/vault-timeout-action.enum";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import {
|
||||
Environment,
|
||||
EnvironmentService,
|
||||
} from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -18,6 +22,7 @@ import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/sym
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import { UserApiLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
@@ -35,6 +40,7 @@ describe("UserApiLoginStrategy", () => {
|
||||
let logService: MockProxy<LogService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let twoFactorService: MockProxy<TwoFactorService>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let keyConnectorService: MockProxy<KeyConnectorService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
@@ -57,6 +63,7 @@ describe("UserApiLoginStrategy", () => {
|
||||
logService = mock<LogService>();
|
||||
stateService = mock<StateService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
keyConnectorService = mock<KeyConnectorService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
@@ -76,6 +83,7 @@ describe("UserApiLoginStrategy", () => {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
environmentService,
|
||||
keyConnectorService,
|
||||
billingAccountProfileStateService,
|
||||
@@ -141,8 +149,11 @@ describe("UserApiLoginStrategy", () => {
|
||||
const tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.apiUseKeyConnector = true;
|
||||
|
||||
const env = mock<Environment>();
|
||||
env.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
|
||||
environmentService.environment$ = new BehaviorSubject(env);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
|
||||
|
||||
await apiLogInStrategy.logIn(credentials);
|
||||
|
||||
@@ -156,8 +167,11 @@ describe("UserApiLoginStrategy", () => {
|
||||
const tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.apiUseKeyConnector = true;
|
||||
|
||||
const env = mock<Environment>();
|
||||
env.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
|
||||
environmentService.environment$ = new BehaviorSubject(env);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
environmentService.getKeyConnectorUrl.mockReturnValue(keyConnectorUrl);
|
||||
cryptoService.getMasterKey.mockResolvedValue(masterKey);
|
||||
cryptoService.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
import { firstValueFrom, BehaviorSubject } from "rxjs";
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
@@ -17,6 +17,7 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import { UserApiLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
@@ -47,6 +48,7 @@ export class UserApiLoginStrategy extends LoginStrategy {
|
||||
logService: LogService,
|
||||
stateService: StateService,
|
||||
twoFactorService: TwoFactorService,
|
||||
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private environmentService: EnvironmentService,
|
||||
private keyConnectorService: KeyConnectorService,
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
@@ -61,6 +63,7 @@ export class UserApiLoginStrategy extends LoginStrategy {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
billingAccountProfileStateService,
|
||||
);
|
||||
this.cache = new BehaviorSubject(data);
|
||||
@@ -82,7 +85,8 @@ export class UserApiLoginStrategy extends LoginStrategy {
|
||||
|
||||
protected override async setMasterKey(response: IdentityTokenResponse) {
|
||||
if (response.apiUseKeyConnector) {
|
||||
const keyConnectorUrl = this.environmentService.getKeyConnectorUrl();
|
||||
const env = await firstValueFrom(this.environmentService.environment$);
|
||||
const keyConnectorUrl = env.getKeyConnectorUrl();
|
||||
await this.keyConnectorService.setMasterKeyFromUrl(keyConnectorUrl);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
|
||||
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
|
||||
|
||||
import { identityTokenResponseFactory } from "./login.strategy.spec";
|
||||
@@ -35,6 +36,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
let logService!: MockProxy<LogService>;
|
||||
let stateService!: MockProxy<StateService>;
|
||||
let twoFactorService!: MockProxy<TwoFactorService>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
|
||||
let webAuthnLoginStrategy!: WebAuthnLoginStrategy;
|
||||
@@ -70,6 +72,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
logService = mock<LogService>();
|
||||
stateService = mock<StateService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
userDecryptionOptionsService = mock<InternalUserDecryptionOptionsServiceAbstraction>();
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
|
||||
tokenService.getTwoFactorToken.mockResolvedValue(null);
|
||||
@@ -87,6 +90,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
billingAccountProfileStateService,
|
||||
);
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions";
|
||||
import { WebAuthnLoginCredentials } from "../models/domain/login-credentials";
|
||||
import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
|
||||
@@ -49,6 +50,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
logService: LogService,
|
||||
stateService: StateService,
|
||||
twoFactorService: TwoFactorService,
|
||||
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
) {
|
||||
super(
|
||||
@@ -61,6 +63,7 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
logService,
|
||||
stateService,
|
||||
twoFactorService,
|
||||
userDecryptionOptionsService,
|
||||
billingAccountProfileStateService,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./rotateable-key-set";
|
||||
export * from "./login-credentials";
|
||||
export * from "./user-decryption-options";
|
||||
|
||||
165
libs/auth/src/common/models/domain/user-decryption-options.ts
Normal file
165
libs/auth/src/common/models/domain/user-decryption-options.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response";
|
||||
import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/src/auth/models/response/identity-token.response";
|
||||
|
||||
/**
|
||||
* Key Connector decryption options. Intended to be sent to the client for use after authentication.
|
||||
* @see {@link UserDecryptionOptions}
|
||||
*/
|
||||
export class KeyConnectorUserDecryptionOption {
|
||||
/** The URL of the key connector configured for this user. */
|
||||
keyConnectorUrl: string;
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the KeyConnectorUserDecryptionOption from a response object.
|
||||
* @param response The key connector user decryption option response object.
|
||||
* @returns A new instance of the KeyConnectorUserDecryptionOption or undefined if `response` is nullish.
|
||||
*/
|
||||
static fromResponse(
|
||||
response: KeyConnectorUserDecryptionOptionResponse,
|
||||
): KeyConnectorUserDecryptionOption | undefined {
|
||||
if (response == null) {
|
||||
return undefined;
|
||||
}
|
||||
const options = new KeyConnectorUserDecryptionOption();
|
||||
options.keyConnectorUrl = response?.keyConnectorUrl ?? null;
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new instance of a KeyConnectorUserDecryptionOption from a JSON object.
|
||||
* @param obj JSON object to deserialize.
|
||||
* @returns A new instance of the KeyConnectorUserDecryptionOption or undefined if `obj` is nullish.
|
||||
*/
|
||||
static fromJSON(
|
||||
obj: Jsonify<KeyConnectorUserDecryptionOption>,
|
||||
): KeyConnectorUserDecryptionOption | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.assign(new KeyConnectorUserDecryptionOption(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
|
||||
* @see {@link UserDecryptionOptions}
|
||||
*/
|
||||
export class TrustedDeviceUserDecryptionOption {
|
||||
/** True if an admin has approved an admin auth request previously made from this device. */
|
||||
hasAdminApproval: boolean;
|
||||
/** True if the user has a device capable of approving an auth request. */
|
||||
hasLoginApprovingDevice: boolean;
|
||||
/** True if the user has manage reset password permission, as these users must be forced to have a master password. */
|
||||
hasManageResetPasswordPermission: boolean;
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the TrustedDeviceUserDecryptionOption from a response object.
|
||||
* @param response The trusted device user decryption option response object.
|
||||
* @returns A new instance of the TrustedDeviceUserDecryptionOption or undefined if `response` is nullish.
|
||||
*/
|
||||
static fromResponse(
|
||||
response: TrustedDeviceUserDecryptionOptionResponse,
|
||||
): TrustedDeviceUserDecryptionOption | undefined {
|
||||
if (response == null) {
|
||||
return undefined;
|
||||
}
|
||||
const options = new TrustedDeviceUserDecryptionOption();
|
||||
options.hasAdminApproval = response?.hasAdminApproval ?? false;
|
||||
options.hasLoginApprovingDevice = response?.hasLoginApprovingDevice ?? false;
|
||||
options.hasManageResetPasswordPermission = response?.hasManageResetPasswordPermission ?? false;
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the TrustedDeviceUserDecryptionOption from a JSON object.
|
||||
* @param obj JSON object to deserialize.
|
||||
* @returns A new instance of the TrustedDeviceUserDecryptionOption or undefined if `obj` is nullish.
|
||||
*/
|
||||
static fromJSON(
|
||||
obj: Jsonify<TrustedDeviceUserDecryptionOption>,
|
||||
): TrustedDeviceUserDecryptionOption | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.assign(new TrustedDeviceUserDecryptionOption(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the decryption options the user has configured on the server. This is intended to be sent
|
||||
* to the client on authentication, and can be used to determine how to decrypt the user's vault.
|
||||
*/
|
||||
export class UserDecryptionOptions {
|
||||
/** True if the user has a master password configured on the server. */
|
||||
hasMasterPassword: boolean;
|
||||
/** {@link TrustedDeviceUserDecryptionOption} */
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOption;
|
||||
/** {@link KeyConnectorUserDecryptionOption} */
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOption;
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the UserDecryptionOptions from a response object.
|
||||
* @param response user decryption options response object
|
||||
* @returns A new instance of the UserDecryptionOptions.
|
||||
* @throws If the response is nullish, this method will throw an error. User decryption options
|
||||
* are required for client initialization.
|
||||
*/
|
||||
// TODO: Change response type to `UserDecryptionOptionsResponse` after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
static fromResponse(response: IdentityTokenResponse): UserDecryptionOptions {
|
||||
if (response == null) {
|
||||
throw new Error("User Decryption Options are required for client initialization.");
|
||||
}
|
||||
|
||||
const decryptionOptions = new UserDecryptionOptions();
|
||||
|
||||
if (response.userDecryptionOptions) {
|
||||
// If the response has userDecryptionOptions, this means it's on a post-TDE server version and can interrogate
|
||||
// the new decryption options.
|
||||
const responseOptions = response.userDecryptionOptions;
|
||||
decryptionOptions.hasMasterPassword = responseOptions.hasMasterPassword;
|
||||
|
||||
decryptionOptions.trustedDeviceOption = TrustedDeviceUserDecryptionOption.fromResponse(
|
||||
responseOptions.trustedDeviceOption,
|
||||
);
|
||||
|
||||
decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse(
|
||||
responseOptions.keyConnectorOption,
|
||||
);
|
||||
} else {
|
||||
// If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so
|
||||
// we must base our decryption options on the presence of the keyConnectorUrl.
|
||||
// Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE
|
||||
// server versions, a master password short-circuited the addition of the keyConnectorUrl to the response.
|
||||
// TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
const usingKeyConnector = response.keyConnectorUrl != null;
|
||||
decryptionOptions.hasMasterPassword = !usingKeyConnector;
|
||||
if (usingKeyConnector) {
|
||||
decryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption();
|
||||
decryptionOptions.keyConnectorOption.keyConnectorUrl = response.keyConnectorUrl;
|
||||
}
|
||||
}
|
||||
return decryptionOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the UserDecryptionOptions from a JSON object.
|
||||
* @param obj JSON object to deserialize.
|
||||
* @returns A new instance of the UserDecryptionOptions. Will initialize even if the JSON object is nullish.
|
||||
*/
|
||||
static fromJSON(obj: Jsonify<UserDecryptionOptions>): UserDecryptionOptions {
|
||||
const decryptionOptions = Object.assign(new UserDecryptionOptions(), obj);
|
||||
|
||||
decryptionOptions.trustedDeviceOption = TrustedDeviceUserDecryptionOption.fromJSON(
|
||||
obj?.trustedDeviceOption,
|
||||
);
|
||||
|
||||
decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromJSON(
|
||||
obj?.keyConnectorOption,
|
||||
);
|
||||
|
||||
return decryptionOptions;
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./domain";
|
||||
export * from "./spec";
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
KeyConnectorUserDecryptionOption,
|
||||
TrustedDeviceUserDecryptionOption,
|
||||
UserDecryptionOptions,
|
||||
} from "../domain";
|
||||
|
||||
// To discourage creating new user decryption options, we don't expose a constructor.
|
||||
// These helpers are for testing purposes only.
|
||||
|
||||
/** Testing helper for creating new instances of `UserDecryptionOptions` */
|
||||
export class FakeUserDecryptionOptions extends UserDecryptionOptions {
|
||||
constructor(init: Partial<UserDecryptionOptions>) {
|
||||
super();
|
||||
Object.assign(this, init);
|
||||
}
|
||||
}
|
||||
|
||||
/** Testing helper for creating new instances of `KeyConnectorUserDecryptionOption` */
|
||||
export class FakeKeyConnectorUserDecryptionOption extends KeyConnectorUserDecryptionOption {
|
||||
constructor(keyConnectorUrl: string) {
|
||||
super();
|
||||
this.keyConnectorUrl = keyConnectorUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/** Testing helper for creating new instances of `TrustedDeviceUserDecryptionOption` */
|
||||
export class FakeTrustedDeviceUserDecryptionOption extends TrustedDeviceUserDecryptionOption {
|
||||
constructor(
|
||||
hasAdminApproval: boolean,
|
||||
hasLoginApprovingDevice: boolean,
|
||||
hasManageResetPasswordPermission: boolean,
|
||||
) {
|
||||
super();
|
||||
this.hasAdminApproval = hasAdminApproval;
|
||||
this.hasLoginApprovingDevice = hasLoginApprovingDevice;
|
||||
this.hasManageResetPasswordPermission = hasManageResetPasswordPermission;
|
||||
}
|
||||
}
|
||||
1
libs/auth/src/common/models/spec/index.ts
Normal file
1
libs/auth/src/common/models/spec/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./fake-user-decryption-options";
|
||||
@@ -2,6 +2,7 @@ import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
@@ -30,6 +31,22 @@ describe("AuthRequestService", () => {
|
||||
mockPrivateKey = new Uint8Array(64);
|
||||
});
|
||||
|
||||
describe("authRequestPushNotification$", () => {
|
||||
it("should emit when sendAuthRequestPushNotification is called", () => {
|
||||
const notification = {
|
||||
id: "PUSH_NOTIFICATION",
|
||||
userId: "USER_ID",
|
||||
} as AuthRequestPushNotification;
|
||||
|
||||
const spy = jest.fn();
|
||||
sut.authRequestPushNotification$.subscribe(spy);
|
||||
|
||||
sut.sendAuthRequestPushNotification(notification);
|
||||
|
||||
expect(spy).toHaveBeenCalledWith("PUSH_NOTIFICATION");
|
||||
});
|
||||
});
|
||||
|
||||
describe("approveOrDenyAuthRequest", () => {
|
||||
beforeEach(() => {
|
||||
cryptoService.rsaEncrypt.mockResolvedValue({
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Observable, Subject } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PasswordlessAuthRequest } from "@bitwarden/common/auth/models/request/passwordless-auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
@@ -11,12 +14,17 @@ import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { AuthRequestServiceAbstraction } from "../../abstractions/auth-request.service.abstraction";
|
||||
|
||||
export class AuthRequestService implements AuthRequestServiceAbstraction {
|
||||
private authRequestPushNotificationSubject = new Subject<string>();
|
||||
authRequestPushNotification$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
private appIdService: AppIdService,
|
||||
private cryptoService: CryptoService,
|
||||
private apiService: ApiService,
|
||||
private stateService: StateService,
|
||||
) {}
|
||||
) {
|
||||
this.authRequestPushNotification$ = this.authRequestPushNotificationSubject.asObservable();
|
||||
}
|
||||
|
||||
async approveOrDenyAuthRequest(
|
||||
approve: boolean,
|
||||
@@ -126,4 +134,10 @@ export class AuthRequestService implements AuthRequestServiceAbstraction {
|
||||
masterKeyHash,
|
||||
};
|
||||
}
|
||||
|
||||
sendAuthRequestPushNotification(notification: AuthRequestPushNotification): void {
|
||||
if (notification.id != null) {
|
||||
this.authRequestPushNotificationSubject.next(notification.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from "./pin-crypto/pin-crypto.service.implementation";
|
||||
export * from "./login-email/login-email.service";
|
||||
export * from "./login-strategies/login-strategy.service";
|
||||
export * from "./user-decryption-options/user-decryption-options.service";
|
||||
export * from "./auth-request/auth-request.service";
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import {
|
||||
GlobalState,
|
||||
KeyDefinition,
|
||||
LOGIN_EMAIL_DISK,
|
||||
StateProvider,
|
||||
} from "../../../../../common/src/platform/state";
|
||||
import { LoginEmailServiceAbstraction } from "../../abstractions/login-email.service";
|
||||
|
||||
const STORED_EMAIL = new KeyDefinition<string>(LOGIN_EMAIL_DISK, "storedEmail", {
|
||||
deserializer: (value: string) => value,
|
||||
});
|
||||
|
||||
export class LoginEmailService implements LoginEmailServiceAbstraction {
|
||||
private email: string;
|
||||
private rememberEmail: boolean;
|
||||
|
||||
private readonly storedEmailState: GlobalState<string>;
|
||||
storedEmail$: Observable<string>;
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.storedEmailState = this.stateProvider.getGlobal(STORED_EMAIL);
|
||||
this.storedEmail$ = this.storedEmailState.state$;
|
||||
}
|
||||
|
||||
getEmail() {
|
||||
return this.email;
|
||||
}
|
||||
|
||||
setEmail(email: string) {
|
||||
this.email = email;
|
||||
}
|
||||
|
||||
getRememberEmail() {
|
||||
return this.rememberEmail;
|
||||
}
|
||||
|
||||
setRememberEmail(value: boolean) {
|
||||
this.rememberEmail = value;
|
||||
}
|
||||
|
||||
clearValues() {
|
||||
this.email = null;
|
||||
this.rememberEmail = null;
|
||||
}
|
||||
|
||||
async saveEmailSettings() {
|
||||
await this.storedEmailState.update(() => (this.rememberEmail ? this.email : null));
|
||||
this.clearValues();
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,12 @@ import { KdfType } from "@bitwarden/common/platform/enums";
|
||||
import { FakeGlobalState, FakeGlobalStateProvider } from "@bitwarden/common/spec";
|
||||
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
|
||||
|
||||
import { AuthRequestServiceAbstraction } from "../../abstractions";
|
||||
import {
|
||||
AuthRequestServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
} from "../../abstractions";
|
||||
import { PasswordLoginCredentials } from "../../models";
|
||||
import { UserDecryptionOptionsService } from "../user-decryption-options/user-decryption-options.service";
|
||||
|
||||
import { LoginStrategyService } from "./login-strategy.service";
|
||||
import { CACHE_EXPIRATION_KEY } from "./login-strategy.state";
|
||||
@@ -51,6 +55,7 @@ describe("LoginStrategyService", () => {
|
||||
let policyService: MockProxy<PolicyService>;
|
||||
let deviceTrustCryptoService: MockProxy<DeviceTrustCryptoServiceAbstraction>;
|
||||
let authRequestService: MockProxy<AuthRequestServiceAbstraction>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
|
||||
let stateProvider: FakeGlobalStateProvider;
|
||||
@@ -74,6 +79,7 @@ describe("LoginStrategyService", () => {
|
||||
policyService = mock<PolicyService>();
|
||||
deviceTrustCryptoService = mock<DeviceTrustCryptoServiceAbstraction>();
|
||||
authRequestService = mock<AuthRequestServiceAbstraction>();
|
||||
userDecryptionOptionsService = mock<UserDecryptionOptionsService>();
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
stateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
@@ -95,6 +101,7 @@ describe("LoginStrategyService", () => {
|
||||
policyService,
|
||||
deviceTrustCryptoService,
|
||||
authRequestService,
|
||||
userDecryptionOptionsService,
|
||||
stateProvider,
|
||||
billingAccountProfileStateService,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
combineLatestWith,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
@@ -23,7 +22,6 @@ import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { PreloginRequest } from "@bitwarden/common/models/request/prelogin.request";
|
||||
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
|
||||
import { AuthRequestPushNotification } from "@bitwarden/common/models/response/notification.response";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { CryptoService } from "@bitwarden/common/platform/abstractions/crypto.service";
|
||||
import { EncryptService } from "@bitwarden/common/platform/abstractions/encrypt.service";
|
||||
@@ -40,6 +38,7 @@ import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/pass
|
||||
import { MasterKey } from "@bitwarden/common/types/key";
|
||||
|
||||
import { AuthRequestServiceAbstraction, LoginStrategyServiceAbstraction } from "../../abstractions";
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction";
|
||||
import { AuthRequestLoginStrategy } from "../../login-strategies/auth-request-login.strategy";
|
||||
import { PasswordLoginStrategy } from "../../login-strategies/password-login.strategy";
|
||||
import { SsoLoginStrategy } from "../../login-strategies/sso-login.strategy";
|
||||
@@ -80,8 +79,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
>;
|
||||
|
||||
currentAuthType$: Observable<AuthenticationType | null>;
|
||||
// TODO: move to auth request service
|
||||
authRequestPushNotification$: Observable<string>;
|
||||
|
||||
constructor(
|
||||
protected cryptoService: CryptoService,
|
||||
@@ -101,6 +98,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
protected policyService: PolicyService,
|
||||
protected deviceTrustCryptoService: DeviceTrustCryptoServiceAbstraction,
|
||||
protected authRequestService: AuthRequestServiceAbstraction,
|
||||
protected userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
protected stateProvider: GlobalStateProvider,
|
||||
protected billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
) {
|
||||
@@ -112,9 +110,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
);
|
||||
|
||||
this.currentAuthType$ = this.currentAuthnTypeState.state$;
|
||||
this.authRequestPushNotification$ = this.authRequestPushNotificationState.state$.pipe(
|
||||
filter((id) => id != null),
|
||||
);
|
||||
this.loginStrategy$ = this.currentAuthnTypeState.state$.pipe(
|
||||
distinctUntilChanged(),
|
||||
combineLatestWith(this.loginStrategyCacheState.state$),
|
||||
@@ -135,8 +130,8 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
async getMasterPasswordHash(): Promise<string | null> {
|
||||
const strategy = await firstValueFrom(this.loginStrategy$);
|
||||
|
||||
if ("masterKeyHash$" in strategy) {
|
||||
return await firstValueFrom(strategy.masterKeyHash$);
|
||||
if ("serverMasterKeyHash$" in strategy) {
|
||||
return await firstValueFrom(strategy.serverMasterKeyHash$);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -254,13 +249,6 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
return await this.cryptoService.makeMasterKey(masterPassword, email, kdf, kdfConfig);
|
||||
}
|
||||
|
||||
// TODO move to auth request service
|
||||
async sendAuthRequestPushNotification(notification: AuthRequestPushNotification): Promise<void> {
|
||||
if (notification.id != null) {
|
||||
await this.authRequestPushNotificationState.update((_) => notification.id);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: move to auth request service
|
||||
async passwordlessLogin(
|
||||
id: string,
|
||||
@@ -354,6 +342,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.passwordStrengthService,
|
||||
this.policyService,
|
||||
this,
|
||||
@@ -371,6 +360,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.keyConnectorService,
|
||||
this.deviceTrustCryptoService,
|
||||
this.authRequestService,
|
||||
@@ -389,6 +379,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.environmentService,
|
||||
this.keyConnectorService,
|
||||
this.billingAccountProfileStateService,
|
||||
@@ -405,6 +396,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.deviceTrustCryptoService,
|
||||
this.billingAccountProfileStateService,
|
||||
);
|
||||
@@ -420,6 +412,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.logService,
|
||||
this.stateService,
|
||||
this.twoFactorService,
|
||||
this.userDecryptionOptionsService,
|
||||
this.billingAccountProfileStateService,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
mockAccountServiceWith,
|
||||
} from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import {
|
||||
USER_DECRYPTION_OPTIONS,
|
||||
UserDecryptionOptionsService,
|
||||
} from "./user-decryption-options.service";
|
||||
|
||||
describe("UserDecryptionOptionsService", () => {
|
||||
let sut: UserDecryptionOptionsService;
|
||||
|
||||
const fakeUserId = Utils.newGuid() as UserId;
|
||||
let fakeAccountService: FakeAccountService;
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeAccountService = mockAccountServiceWith(fakeUserId);
|
||||
fakeStateProvider = new FakeStateProvider(fakeAccountService);
|
||||
|
||||
sut = new UserDecryptionOptionsService(fakeStateProvider);
|
||||
});
|
||||
|
||||
const userDecryptionOptions = {
|
||||
hasMasterPassword: true,
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: false,
|
||||
hasLoginApprovingDevice: false,
|
||||
hasManageResetPasswordPermission: true,
|
||||
},
|
||||
keyConnectorOption: {
|
||||
keyConnectorUrl: "https://keyconnector.bitwarden.com",
|
||||
},
|
||||
};
|
||||
|
||||
describe("userDecryptionOptions$", () => {
|
||||
it("should return the active user's decryption options", async () => {
|
||||
await fakeStateProvider.setUserState(USER_DECRYPTION_OPTIONS, userDecryptionOptions);
|
||||
|
||||
const result = await firstValueFrom(sut.userDecryptionOptions$);
|
||||
|
||||
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);
|
||||
|
||||
const result = await firstValueFrom(sut.hasMasterPassword$);
|
||||
|
||||
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",
|
||||
status: AuthenticationStatus.Locked,
|
||||
});
|
||||
await fakeStateProvider.setUserState(
|
||||
USER_DECRYPTION_OPTIONS,
|
||||
userDecryptionOptions,
|
||||
givenUser,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(sut.userDecryptionOptionsById$(givenUser));
|
||||
|
||||
expect(result).toEqual(userDecryptionOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setUserDecryptionOptions", () => {
|
||||
it("should set the active user's decryption options", async () => {
|
||||
await sut.setUserDecryptionOptions(userDecryptionOptions);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
fakeStateProvider.getActive(USER_DECRYPTION_OPTIONS).state$,
|
||||
);
|
||||
|
||||
expect(result).toEqual(userDecryptionOptions);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { map } from "rxjs";
|
||||
|
||||
import {
|
||||
ActiveUserState,
|
||||
StateProvider,
|
||||
USER_DECRYPTION_OPTIONS_DISK,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
import { UserId } from "@bitwarden/common/src/types/guid";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction";
|
||||
import { UserDecryptionOptions } from "../../models";
|
||||
|
||||
export const USER_DECRYPTION_OPTIONS = new UserKeyDefinition<UserDecryptionOptions>(
|
||||
USER_DECRYPTION_OPTIONS_DISK,
|
||||
"decryptionOptions",
|
||||
{
|
||||
deserializer: (decryptionOptions) => UserDecryptionOptions.fromJSON(decryptionOptions),
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export class UserDecryptionOptionsService
|
||||
implements InternalUserDecryptionOptionsServiceAbstraction
|
||||
{
|
||||
private userDecryptionOptionsState: ActiveUserState<UserDecryptionOptions>;
|
||||
|
||||
userDecryptionOptions$;
|
||||
hasMasterPassword$;
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
userDecryptionOptionsById$(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, USER_DECRYPTION_OPTIONS).state$;
|
||||
}
|
||||
|
||||
async setUserDecryptionOptions(userDecryptionOptions: UserDecryptionOptions): Promise<void> {
|
||||
await this.userDecryptionOptionsState.update((_) => userDecryptionOptions);
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,9 @@ export class FakeAccountService implements AccountService {
|
||||
}
|
||||
|
||||
async switchAccount(userId: UserId): Promise<void> {
|
||||
const next =
|
||||
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
|
||||
this.activeAccountSubject.next(next);
|
||||
await this.mock.switchAccount(userId);
|
||||
}
|
||||
}
|
||||
|
||||
54
libs/common/spec/matchers/to-almost-equal.spec.ts
Normal file
54
libs/common/spec/matchers/to-almost-equal.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
describe("toAlmostEqual custom matcher", () => {
|
||||
it("matches identical Dates", () => {
|
||||
const date = new Date();
|
||||
expect(date).toAlmostEqual(date);
|
||||
});
|
||||
|
||||
it("matches when older but within default ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() - 5);
|
||||
expect(date).toAlmostEqual(olderDate);
|
||||
});
|
||||
|
||||
it("matches when newer but within default ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() + 5);
|
||||
expect(date).toAlmostEqual(olderDate);
|
||||
});
|
||||
|
||||
it("doesn't match if older than default ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() - 11);
|
||||
expect(date).not.toAlmostEqual(olderDate);
|
||||
});
|
||||
|
||||
it("doesn't match if newer than default ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() + 11);
|
||||
expect(date).not.toAlmostEqual(olderDate);
|
||||
});
|
||||
|
||||
it("matches when older but within custom ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() - 15);
|
||||
expect(date).toAlmostEqual(olderDate, 20);
|
||||
});
|
||||
|
||||
it("matches when newer but within custom ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() + 15);
|
||||
expect(date).toAlmostEqual(olderDate, 20);
|
||||
});
|
||||
|
||||
it("doesn't match if older than custom ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() - 21);
|
||||
expect(date).not.toAlmostEqual(olderDate, 20);
|
||||
});
|
||||
|
||||
it("doesn't match if newer than custom ms", () => {
|
||||
const date = new Date();
|
||||
const olderDate = new Date(date.getTime() + 21);
|
||||
expect(date).not.toAlmostEqual(olderDate, 20);
|
||||
});
|
||||
});
|
||||
20
libs/common/spec/matchers/to-almost-equal.ts
Normal file
20
libs/common/spec/matchers/to-almost-equal.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Matches the expected date within an optional ms precision
|
||||
* @param received The received date
|
||||
* @param expected The expected date
|
||||
* @param msPrecision The optional precision in milliseconds
|
||||
*/
|
||||
export const toAlmostEqual: jest.CustomMatcher = function (
|
||||
received: Date,
|
||||
expected: Date,
|
||||
msPrecision: number = 10,
|
||||
) {
|
||||
const receivedTime = received.getTime();
|
||||
const expectedTime = expected.getTime();
|
||||
const difference = Math.abs(receivedTime - expectedTime);
|
||||
return {
|
||||
pass: difference <= msPrecision,
|
||||
message: () =>
|
||||
`expected ${received} to be within ${msPrecision}ms of ${expected} (actual difference: ${difference}ms)`,
|
||||
};
|
||||
};
|
||||
86
libs/common/spec/observable-tracker.ts
Normal file
86
libs/common/spec/observable-tracker.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Observable, Subscription, firstValueFrom, throwError, timeout } from "rxjs";
|
||||
|
||||
/** Test class to enable async awaiting of observable emissions */
|
||||
export class ObservableTracker<T> {
|
||||
private subscription: Subscription;
|
||||
emissions: T[] = [];
|
||||
constructor(private observable: Observable<T>) {
|
||||
this.emissions = this.trackEmissions(observable);
|
||||
}
|
||||
|
||||
/** Unsubscribes from the observable */
|
||||
unsubscribe() {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Awaits the next emission from the observable, or throws if the timeout is exceeded
|
||||
* @param msTimeout The maximum time to wait for another emission before throwing
|
||||
*/
|
||||
async expectEmission(msTimeout = 50) {
|
||||
await firstValueFrom(
|
||||
this.observable.pipe(
|
||||
timeout({
|
||||
first: msTimeout,
|
||||
with: () => throwError(() => new Error("Timeout exceeded waiting for another emission.")),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Awaits until the the total number of emissions observed by this tracker equals or exceeds {@link count}
|
||||
* @param count The number of emissions to wait for
|
||||
*/
|
||||
async pauseUntilReceived(count: number, msTimeout = 50): Promise<T[]> {
|
||||
for (let i = 0; i < count - this.emissions.length; i++) {
|
||||
await this.expectEmission(msTimeout);
|
||||
}
|
||||
return this.emissions;
|
||||
}
|
||||
|
||||
private trackEmissions<T>(observable: Observable<T>): T[] {
|
||||
const emissions: T[] = [];
|
||||
this.subscription = observable.subscribe((value) => {
|
||||
switch (value) {
|
||||
case undefined:
|
||||
case null:
|
||||
emissions.push(value);
|
||||
return;
|
||||
default:
|
||||
// process by type
|
||||
break;
|
||||
}
|
||||
|
||||
switch (typeof value) {
|
||||
case "string":
|
||||
case "number":
|
||||
case "boolean":
|
||||
emissions.push(value);
|
||||
break;
|
||||
case "symbol":
|
||||
// Cheating types to make symbols work at all
|
||||
emissions.push(value.toString() as T);
|
||||
break;
|
||||
default: {
|
||||
emissions.push(clone(value));
|
||||
}
|
||||
}
|
||||
});
|
||||
return emissions;
|
||||
}
|
||||
}
|
||||
function clone(value: any): any {
|
||||
if (global.structuredClone != undefined) {
|
||||
return structuredClone(value);
|
||||
} else {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
|
||||
/** A test helper that builds an @see{@link ObservableTracker}, which can be used to assert things about the
|
||||
* emissions of the given observable
|
||||
* @param observable The observable to track
|
||||
*/
|
||||
export function subscribeTo<T>(observable: Observable<T>) {
|
||||
return new ObservableTracker(observable);
|
||||
}
|
||||
@@ -170,7 +170,6 @@ export abstract class ApiService {
|
||||
postRegister: (request: RegisterRequest) => Promise<RegisterResponse>;
|
||||
postPremium: (data: FormData) => Promise<PaymentResponse>;
|
||||
postReinstatePremium: () => Promise<any>;
|
||||
postCancelPremium: () => Promise<any>;
|
||||
postAccountStorage: (request: StorageRequest) => Promise<PaymentResponse>;
|
||||
postAccountPayment: (request: PaymentRequest) => Promise<void>;
|
||||
postAccountLicense: (data: FormData) => Promise<any>;
|
||||
|
||||
@@ -51,7 +51,6 @@ export class OrganizationApiServiceAbstraction {
|
||||
updateSeats: (id: string, request: SeatRequest) => Promise<PaymentResponse>;
|
||||
updateStorage: (id: string, request: StorageRequest) => Promise<PaymentResponse>;
|
||||
verifyBank: (id: string, request: VerifyBankRequest) => Promise<void>;
|
||||
cancel: (id: string) => Promise<void>;
|
||||
reinstate: (id: string) => Promise<void>;
|
||||
leave: (id: string) => Promise<void>;
|
||||
delete: (id: string, request: SecretVerificationRequest) => Promise<void>;
|
||||
|
||||
@@ -184,10 +184,6 @@ export class OrganizationApiService implements OrganizationApiServiceAbstraction
|
||||
);
|
||||
}
|
||||
|
||||
async cancel(id: string): Promise<void> {
|
||||
return this.apiService.send("POST", "/organizations/" + id + "/cancel", null, true, false);
|
||||
}
|
||||
|
||||
async reinstate(id: string): Promise<void> {
|
||||
return this.apiService.send("POST", "/organizations/" + id + "/reinstate", null, true, false);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export abstract class AnonymousHubService {
|
||||
createHubConnection: (token: string) => void;
|
||||
stopHubConnection: () => void;
|
||||
createHubConnection: (token: string) => Promise<void>;
|
||||
stopHubConnection: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||
|
||||
export abstract class AuthService {
|
||||
getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
|
||||
logOut: (callback: () => void) => void;
|
||||
/** Authentication status for the active user */
|
||||
abstract activeAccountStatus$: Observable<AuthenticationStatus>;
|
||||
/**
|
||||
* Returns an observable authentication status for the given user id.
|
||||
* @note userId is a required parameter, null values will always return `AuthenticationStatus.LoggedOut`
|
||||
* @param userId The user id to check for an access token.
|
||||
*/
|
||||
abstract authStatusFor$(userId: UserId): Observable<AuthenticationStatus>;
|
||||
/** @deprecated use {@link activeAccountStatus$} instead */
|
||||
abstract getAuthStatus: (userId?: string) => Promise<AuthenticationStatus>;
|
||||
abstract logOut: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,17 @@ export abstract class AvatarService {
|
||||
* @returns a promise that resolves when the avatar color is set
|
||||
*/
|
||||
abstract setAvatarColor(color: string): Promise<void>;
|
||||
/**
|
||||
* Sets the avatar color for the given user, meant to be used via sync.
|
||||
*
|
||||
* @remarks This is meant to be used for getting an updated avatar color from
|
||||
* the sync endpoint. If the user is changing their avatar color
|
||||
* on device, you should instead call {@link setAvatarColor}.
|
||||
*
|
||||
* @param userId The user id for the user to set the avatar color for
|
||||
* @param color The color to set the avatar color to
|
||||
*/
|
||||
abstract setSyncAvatarColor(userId: UserId, color: string): Promise<void>;
|
||||
/**
|
||||
* Gets the avatar color of the specified user.
|
||||
*
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, UserKey } from "../../types/key";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
|
||||
export abstract class DeviceTrustCryptoServiceAbstraction {
|
||||
supportsDeviceTrust$: Observable<boolean>;
|
||||
/**
|
||||
* @description Retrieves the users choice to trust the device which can only happen after decryption
|
||||
* Note: this value should only be used once and then reset
|
||||
*/
|
||||
getShouldTrustDevice: () => Promise<boolean | null>;
|
||||
setShouldTrustDevice: (value: boolean) => Promise<void>;
|
||||
getShouldTrustDevice: (userId: UserId) => Promise<boolean | null>;
|
||||
setShouldTrustDevice: (userId: UserId, value: boolean) => Promise<void>;
|
||||
|
||||
trustDeviceIfRequired: () => Promise<void>;
|
||||
trustDeviceIfRequired: (userId: UserId) => Promise<void>;
|
||||
|
||||
trustDevice: () => Promise<DeviceResponse>;
|
||||
getDeviceKey: () => Promise<DeviceKey>;
|
||||
trustDevice: (userId: UserId) => Promise<DeviceResponse>;
|
||||
|
||||
/** Retrieves the device key if it exists from state or secure storage if supported for the active user. */
|
||||
getDeviceKey: (userId: UserId) => Promise<DeviceKey | null>;
|
||||
decryptUserKeyWithDeviceKey: (
|
||||
userId: UserId,
|
||||
encryptedDevicePrivateKey: EncString,
|
||||
encryptedUserKey: EncString,
|
||||
deviceKey?: DeviceKey,
|
||||
deviceKey: DeviceKey,
|
||||
) => Promise<UserKey | null>;
|
||||
rotateDevicesTrust: (newUserKey: UserKey, masterPasswordHash: string) => Promise<void>;
|
||||
|
||||
supportsDeviceTrust: () => Promise<boolean>;
|
||||
rotateDevicesTrust: (
|
||||
userId: UserId,
|
||||
newUserKey: UserKey,
|
||||
masterPasswordHash: string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -15,5 +15,4 @@ export abstract class KeyConnectorService {
|
||||
setConvertAccountRequired: (status: boolean) => Promise<void>;
|
||||
getConvertAccountRequired: () => Promise<boolean>;
|
||||
removeConvertAccountRequired: () => Promise<void>;
|
||||
clear: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export abstract class LoginService {
|
||||
getEmail: () => string;
|
||||
getRememberEmail: () => boolean;
|
||||
setEmail: (value: string) => void;
|
||||
setRememberEmail: (value: boolean) => void;
|
||||
clearValues: () => void;
|
||||
saveEmailSettings: () => Promise<void>;
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DecodedAccessToken } from "../services/token.service";
|
||||
|
||||
export abstract class TokenService {
|
||||
/**
|
||||
* Returns an observable that emits a boolean indicating whether the user has an access token.
|
||||
* @param userId The user id to check for an access token.
|
||||
*/
|
||||
abstract hasAccessToken$(userId: UserId): Observable<boolean>;
|
||||
/**
|
||||
* Sets the access token, refresh token, API Key Client ID, and API Key Client Secret in memory or disk
|
||||
* based on the given vaultTimeoutAction and vaultTimeout and the derived access token user id.
|
||||
@@ -10,17 +17,18 @@ export abstract class TokenService {
|
||||
* Note 2: this method also enforces always setting the access token and the refresh token together as
|
||||
* we can retrieve the user id required to set the refresh token from the access token for efficiency.
|
||||
* @param accessToken The access token to set.
|
||||
* @param refreshToken The refresh token to set.
|
||||
* @param clientIdClientSecret The API Key Client ID and Client Secret to set.
|
||||
* @param vaultTimeoutAction The action to take when the vault times out.
|
||||
* @param vaultTimeout The timeout for the vault.
|
||||
* @param refreshToken The optional refresh token to set. Note: this is undefined when using the CLI Login Via API Key flow
|
||||
* @param clientIdClientSecret The API Key Client ID and Client Secret to set.
|
||||
*
|
||||
* @returns A promise that resolves when the tokens have been set.
|
||||
*/
|
||||
setTokens: (
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
vaultTimeoutAction: VaultTimeoutAction,
|
||||
vaultTimeout: number | null,
|
||||
refreshToken?: string,
|
||||
clientIdClientSecret?: [string, string],
|
||||
) => Promise<void>;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export class AuthResult {
|
||||
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
|
||||
/**
|
||||
* @deprecated
|
||||
* Replace with using AccountDecryptionOptions to determine if the user does
|
||||
* Replace with using UserDecryptionOptions to determine if the user does
|
||||
* not have a master password and is not using Key Connector.
|
||||
* */
|
||||
resetMasterPassword = false;
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
export class EnvironmentUrls {
|
||||
base: string = null;
|
||||
api: string = null;
|
||||
identity: string = null;
|
||||
icons: string = null;
|
||||
notifications: string = null;
|
||||
events: string = null;
|
||||
webVault: string = null;
|
||||
keyConnector: string = null;
|
||||
|
||||
static fromJSON(obj: Jsonify<EnvironmentUrls>): EnvironmentUrls {
|
||||
return Object.assign(new EnvironmentUrls(), obj);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export class KeyConnectorUserDecryptionOption {
|
||||
constructor(public keyConnectorUrl: string) {}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export class TrustedDeviceUserDecryptionOption {
|
||||
constructor(
|
||||
public hasAdminApproval: boolean,
|
||||
public hasLoginApprovingDevice: boolean,
|
||||
public hasManageResetPasswordPermission: boolean,
|
||||
) {}
|
||||
}
|
||||
@@ -5,14 +5,15 @@ import {
|
||||
IHubProtocol,
|
||||
} from "@microsoft/signalr";
|
||||
import { MessagePackHubProtocol } from "@microsoft/signalr-protocol-msgpack";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { LoginStrategyServiceAbstraction } from "../../../../auth/src/common/abstractions/login-strategy.service";
|
||||
import { AuthRequestServiceAbstraction } from "../../../../auth/src/common/abstractions";
|
||||
import { NotificationType } from "../../enums";
|
||||
import {
|
||||
AuthRequestPushNotification,
|
||||
NotificationResponse,
|
||||
} from "../../models/response/notification.response";
|
||||
import { EnvironmentService } from "../../platform/abstractions/environment.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { AnonymousHubService as AnonymousHubServiceAbstraction } from "../abstractions/anonymous-hub.service";
|
||||
|
||||
export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
@@ -21,12 +22,11 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private loginStrategyService: LoginStrategyServiceAbstraction,
|
||||
private logService: LogService,
|
||||
private authRequestService: AuthRequestServiceAbstraction,
|
||||
) {}
|
||||
|
||||
async createHubConnection(token: string) {
|
||||
this.url = this.environmentService.getNotificationsUrl();
|
||||
this.url = (await firstValueFrom(this.environmentService.environment$)).getNotificationsUrl();
|
||||
|
||||
this.anonHubConnection = new HubConnectionBuilder()
|
||||
.withUrl(this.url + "/anonymous-hub?Token=" + token, {
|
||||
@@ -36,26 +36,25 @@ export class AnonymousHubService implements AnonymousHubServiceAbstraction {
|
||||
.withHubProtocol(new MessagePackHubProtocol() as IHubProtocol)
|
||||
.build();
|
||||
|
||||
this.anonHubConnection.start().catch((error) => this.logService.error(error));
|
||||
await this.anonHubConnection.start();
|
||||
|
||||
this.anonHubConnection.on("AuthRequestResponseRecieved", (data: any) => {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.ProcessNotification(new NotificationResponse(data));
|
||||
});
|
||||
}
|
||||
|
||||
stopHubConnection() {
|
||||
async stopHubConnection() {
|
||||
if (this.anonHubConnection) {
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.anonHubConnection.stop();
|
||||
await this.anonHubConnection.stop();
|
||||
}
|
||||
}
|
||||
|
||||
private async ProcessNotification(notification: NotificationResponse) {
|
||||
await this.loginStrategyService.sendAuthRequestPushNotification(
|
||||
notification.payload as AuthRequestPushNotification,
|
||||
);
|
||||
private ProcessNotification(notification: NotificationResponse) {
|
||||
switch (notification.type) {
|
||||
case NotificationType.AuthRequestResponse:
|
||||
this.authRequestService.sendAuthRequestPushNotification(
|
||||
notification.payload as AuthRequestPushNotification,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
161
libs/common/src/auth/services/auth.service.spec.ts
Normal file
161
libs/common/src/auth/services/auth.service.spec.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import {
|
||||
FakeAccountService,
|
||||
makeStaticByteArray,
|
||||
mockAccountServiceWith,
|
||||
trackEmissions,
|
||||
} from "../../../spec";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { TokenService } from "../abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||
|
||||
import { AuthService } from "./auth.service";
|
||||
|
||||
describe("AuthService", () => {
|
||||
let sut: AuthService;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let cryptoService: MockProxy<CryptoService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let stateService: MockProxy<StateService>;
|
||||
let tokenService: MockProxy<TokenService>;
|
||||
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const userKey = new SymmetricCryptoKey(makeStaticByteArray(32) as Uint8Array) as UserKey;
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(userId);
|
||||
messagingService = mock();
|
||||
cryptoService = mock();
|
||||
apiService = mock();
|
||||
stateService = mock();
|
||||
tokenService = mock();
|
||||
|
||||
sut = new AuthService(
|
||||
accountService,
|
||||
messagingService,
|
||||
cryptoService,
|
||||
apiService,
|
||||
stateService,
|
||||
tokenService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("activeAccountStatus$", () => {
|
||||
const accountInfo = {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: userId,
|
||||
email: "email",
|
||||
name: "name",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
accountService.activeAccountSubject.next(accountInfo);
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
});
|
||||
|
||||
it("emits LoggedOut when there is no active account", async () => {
|
||||
accountService.activeAccountSubject.next(undefined);
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits LoggedOut when there is no access token", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits LoggedOut when there is no access token but has a user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits Locked when there is an access token and no user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Locked);
|
||||
});
|
||||
|
||||
it("emits Unlocked when there is an access token and user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
|
||||
|
||||
expect(await firstValueFrom(sut.activeAccountStatus$)).toEqual(AuthenticationStatus.Unlocked);
|
||||
});
|
||||
|
||||
it("follows the current active user", async () => {
|
||||
const accountInfo2 = {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: Utils.newGuid() as UserId,
|
||||
email: "email2",
|
||||
name: "name2",
|
||||
};
|
||||
|
||||
const emissions = trackEmissions(sut.activeAccountStatus$);
|
||||
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
|
||||
accountService.activeAccountSubject.next(accountInfo2);
|
||||
|
||||
expect(emissions).toEqual([AuthenticationStatus.Locked, AuthenticationStatus.Unlocked]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authStatusFor$", () => {
|
||||
beforeEach(() => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
});
|
||||
|
||||
it("emits LoggedOut when userId is null", async () => {
|
||||
expect(await firstValueFrom(sut.authStatusFor$(null))).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits LoggedOut when there is no access token", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(false));
|
||||
|
||||
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(
|
||||
AuthenticationStatus.LoggedOut,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits Locked when there is an access token and no user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(undefined));
|
||||
|
||||
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(AuthenticationStatus.Locked);
|
||||
});
|
||||
|
||||
it("emits Unlocked when there is an access token and user key", async () => {
|
||||
tokenService.hasAccessToken$.mockReturnValue(of(true));
|
||||
cryptoService.getInMemoryUserKeyFor$.mockReturnValue(of(userKey));
|
||||
|
||||
expect(await firstValueFrom(sut.authStatusFor$(userId))).toEqual(
|
||||
AuthenticationStatus.Unlocked,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,67 @@
|
||||
import {
|
||||
Observable,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
of,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { KeySuffixOptions } from "../../platform/enums";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { AccountService } from "../abstractions/account.service";
|
||||
import { AuthService as AuthServiceAbstraction } from "../abstractions/auth.service";
|
||||
import { TokenService } from "../abstractions/token.service";
|
||||
import { AuthenticationStatus } from "../enums/authentication-status";
|
||||
|
||||
export class AuthService implements AuthServiceAbstraction {
|
||||
activeAccountStatus$: Observable<AuthenticationStatus>;
|
||||
|
||||
constructor(
|
||||
protected accountService: AccountService,
|
||||
protected messagingService: MessagingService,
|
||||
protected cryptoService: CryptoService,
|
||||
protected apiService: ApiService,
|
||||
protected stateService: StateService,
|
||||
) {}
|
||||
private tokenService: TokenService,
|
||||
) {
|
||||
this.activeAccountStatus$ = this.accountService.activeAccount$.pipe(
|
||||
map((account) => account?.id),
|
||||
switchMap((userId) => {
|
||||
return this.authStatusFor$(userId);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
authStatusFor$(userId: UserId): Observable<AuthenticationStatus> {
|
||||
if (userId == null) {
|
||||
return of(AuthenticationStatus.LoggedOut);
|
||||
}
|
||||
|
||||
return combineLatest([
|
||||
this.cryptoService.getInMemoryUserKeyFor$(userId),
|
||||
this.tokenService.hasAccessToken$(userId),
|
||||
]).pipe(
|
||||
map(([userKey, hasAccessToken]) => {
|
||||
if (!hasAccessToken) {
|
||||
return AuthenticationStatus.LoggedOut;
|
||||
}
|
||||
|
||||
if (!userKey) {
|
||||
return AuthenticationStatus.Locked;
|
||||
}
|
||||
|
||||
return AuthenticationStatus.Unlocked;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
shareReplay({ bufferSize: 1, refCount: false }),
|
||||
);
|
||||
}
|
||||
|
||||
async getAuthStatus(userId?: string): Promise<AuthenticationStatus> {
|
||||
// If we don't have an access token or userId, we're logged out
|
||||
|
||||
@@ -27,6 +27,10 @@ export class AvatarService implements AvatarServiceAbstraction {
|
||||
await this.stateProvider.setUserState(AVATAR_COLOR, avatarColor);
|
||||
}
|
||||
|
||||
async setSyncAvatarColor(userId: UserId, color: string): Promise<void> {
|
||||
await this.stateProvider.getUser(userId, AVATAR_COLOR).update(() => color);
|
||||
}
|
||||
|
||||
getUserAvatarColor$(userId: UserId): Observable<string | null> {
|
||||
return this.stateProvider.getUser(userId, AVATAR_COLOR).state$;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, map, Observable } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
|
||||
@@ -7,9 +9,13 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { DEVICE_TRUST_DISK_LOCAL, KeyDefinition, StateProvider } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { UserKey, DeviceKey } from "../../types/key";
|
||||
import { DeviceTrustCryptoServiceAbstraction } from "../abstractions/device-trust-crypto.service.abstraction";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
@@ -20,41 +26,87 @@ import {
|
||||
UpdateDevicesTrustRequest,
|
||||
} from "../models/request/update-devices-trust.request";
|
||||
|
||||
/** Uses disk storage so that the device key can persist after log out and tab removal. */
|
||||
export const DEVICE_KEY = new KeyDefinition<DeviceKey>(DEVICE_TRUST_DISK_LOCAL, "deviceKey", {
|
||||
deserializer: (deviceKey) => SymmetricCryptoKey.fromJSON(deviceKey) as DeviceKey,
|
||||
});
|
||||
|
||||
/** Uses disk storage so that the shouldTrustDevice bool can persist across login. */
|
||||
export const SHOULD_TRUST_DEVICE = new KeyDefinition<boolean>(
|
||||
DEVICE_TRUST_DISK_LOCAL,
|
||||
"shouldTrustDevice",
|
||||
{
|
||||
deserializer: (shouldTrustDevice) => shouldTrustDevice,
|
||||
},
|
||||
);
|
||||
|
||||
export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstraction {
|
||||
private readonly platformSupportsSecureStorage =
|
||||
this.platformUtilsService.supportsSecureStorage();
|
||||
private readonly deviceKeySecureStorageKey: string = "_deviceKey";
|
||||
|
||||
supportsDeviceTrust$: Observable<boolean>;
|
||||
|
||||
constructor(
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private cryptoFunctionService: CryptoFunctionService,
|
||||
private cryptoService: CryptoService,
|
||||
private encryptService: EncryptService,
|
||||
private stateService: StateService,
|
||||
private appIdService: AppIdService,
|
||||
private devicesApiService: DevicesApiServiceAbstraction,
|
||||
private i18nService: I18nService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
) {}
|
||||
private stateProvider: StateProvider,
|
||||
private secureStorageService: AbstractStorageService,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
) {
|
||||
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
|
||||
map((options) => options?.trustedDeviceOption != null ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Retrieves the users choice to trust the device which can only happen after decryption
|
||||
* Note: this value should only be used once and then reset
|
||||
*/
|
||||
async getShouldTrustDevice(): Promise<boolean> {
|
||||
return await this.stateService.getShouldTrustDevice();
|
||||
async getShouldTrustDevice(userId: UserId): Promise<boolean> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot get should trust device.");
|
||||
}
|
||||
|
||||
const shouldTrustDevice = await firstValueFrom(
|
||||
this.stateProvider.getUserState$(SHOULD_TRUST_DEVICE, userId),
|
||||
);
|
||||
|
||||
return shouldTrustDevice;
|
||||
}
|
||||
|
||||
async setShouldTrustDevice(value: boolean): Promise<void> {
|
||||
await this.stateService.setShouldTrustDevice(value);
|
||||
async setShouldTrustDevice(userId: UserId, value: boolean): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot set should trust device.");
|
||||
}
|
||||
|
||||
await this.stateProvider.setUserState(SHOULD_TRUST_DEVICE, value, userId);
|
||||
}
|
||||
|
||||
async trustDeviceIfRequired(): Promise<void> {
|
||||
const shouldTrustDevice = await this.getShouldTrustDevice();
|
||||
async trustDeviceIfRequired(userId: UserId): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot trust device if required.");
|
||||
}
|
||||
|
||||
const shouldTrustDevice = await this.getShouldTrustDevice(userId);
|
||||
if (shouldTrustDevice) {
|
||||
await this.trustDevice();
|
||||
await this.trustDevice(userId);
|
||||
// reset the trust choice
|
||||
await this.setShouldTrustDevice(false);
|
||||
await this.setShouldTrustDevice(userId, false);
|
||||
}
|
||||
}
|
||||
|
||||
async trustDevice(): Promise<DeviceResponse> {
|
||||
async trustDevice(userId: UserId): Promise<DeviceResponse> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot trust device.");
|
||||
}
|
||||
|
||||
// Attempt to get user key
|
||||
const userKey: UserKey = await this.cryptoService.getUserKey();
|
||||
|
||||
@@ -95,15 +147,23 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
|
||||
);
|
||||
|
||||
// store device key in local/secure storage if enc keys posted to server successfully
|
||||
await this.setDeviceKey(deviceKey);
|
||||
await this.setDeviceKey(userId, deviceKey);
|
||||
|
||||
this.platformUtilsService.showToast("success", null, this.i18nService.t("deviceTrusted"));
|
||||
|
||||
return deviceResponse;
|
||||
}
|
||||
|
||||
async rotateDevicesTrust(newUserKey: UserKey, masterPasswordHash: string): Promise<void> {
|
||||
const currentDeviceKey = await this.getDeviceKey();
|
||||
async rotateDevicesTrust(
|
||||
userId: UserId,
|
||||
newUserKey: UserKey,
|
||||
masterPasswordHash: string,
|
||||
): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot rotate device's trust.");
|
||||
}
|
||||
|
||||
const currentDeviceKey = await this.getDeviceKey(userId);
|
||||
if (currentDeviceKey == null) {
|
||||
// If the current device doesn't have a device key available to it, then we can't
|
||||
// rotate any trust at all, so early return.
|
||||
@@ -156,26 +216,59 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
|
||||
await this.devicesApiService.updateTrust(trustRequest, deviceIdentifier);
|
||||
}
|
||||
|
||||
async getDeviceKey(): Promise<DeviceKey> {
|
||||
return await this.stateService.getDeviceKey();
|
||||
async getDeviceKey(userId: UserId): Promise<DeviceKey | null> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot get device key.");
|
||||
}
|
||||
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
const deviceKeyB64 = await this.secureStorageService.get<
|
||||
ReturnType<SymmetricCryptoKey["toJSON"]>
|
||||
>(`${userId}${this.deviceKeySecureStorageKey}`, this.getSecureStorageOptions(userId));
|
||||
|
||||
const deviceKey = SymmetricCryptoKey.fromJSON(deviceKeyB64) as DeviceKey;
|
||||
|
||||
return deviceKey;
|
||||
}
|
||||
|
||||
const deviceKey = await firstValueFrom(this.stateProvider.getUserState$(DEVICE_KEY, userId));
|
||||
|
||||
return deviceKey;
|
||||
}
|
||||
|
||||
private async setDeviceKey(deviceKey: DeviceKey | null): Promise<void> {
|
||||
await this.stateService.setDeviceKey(deviceKey);
|
||||
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot set device key.");
|
||||
}
|
||||
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
await this.secureStorageService.save<DeviceKey>(
|
||||
`${userId}${this.deviceKeySecureStorageKey}`,
|
||||
deviceKey,
|
||||
this.getSecureStorageOptions(userId),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.stateProvider.setUserState(DEVICE_KEY, deviceKey?.toJSON(), userId);
|
||||
}
|
||||
|
||||
private async makeDeviceKey(): Promise<DeviceKey> {
|
||||
// Create 512-bit device key
|
||||
return (await this.keyGenerationService.createKey(512)) as DeviceKey;
|
||||
const deviceKey = (await this.keyGenerationService.createKey(512)) as DeviceKey;
|
||||
|
||||
return deviceKey;
|
||||
}
|
||||
|
||||
async decryptUserKeyWithDeviceKey(
|
||||
userId: UserId,
|
||||
encryptedDevicePrivateKey: EncString,
|
||||
encryptedUserKey: EncString,
|
||||
deviceKey?: DeviceKey,
|
||||
deviceKey: DeviceKey,
|
||||
): Promise<UserKey | null> {
|
||||
// If device key provided use it, otherwise try to retrieve from storage
|
||||
deviceKey ||= await this.getDeviceKey();
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot decrypt user key with device key.");
|
||||
}
|
||||
|
||||
if (!deviceKey) {
|
||||
// User doesn't have a device key anymore so device is untrusted
|
||||
@@ -198,14 +291,17 @@ export class DeviceTrustCryptoService implements DeviceTrustCryptoServiceAbstrac
|
||||
return new SymmetricCryptoKey(userKey) as UserKey;
|
||||
} catch (e) {
|
||||
// If either decryption effort fails, we want to remove the device key
|
||||
await this.setDeviceKey(null);
|
||||
await this.setDeviceKey(userId, null);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async supportsDeviceTrust(): Promise<boolean> {
|
||||
const decryptionOptions = await this.stateService.getAccountDecryptionOptions();
|
||||
return decryptionOptions?.trustedDeviceOption != null;
|
||||
private getSecureStorageOptions(userId: UserId): StorageOptions {
|
||||
return {
|
||||
storageLocation: StorageLocation.Disk,
|
||||
useSecureStorage: true,
|
||||
userId: userId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { matches, mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
|
||||
import { UserDecryptionOptions } from "../../../../auth/src/common/models/domain/user-decryption-options";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service";
|
||||
import { FakeActiveUserState } from "../../../spec/fake-state";
|
||||
import { FakeStateProvider } from "../../../spec/fake-state-provider";
|
||||
import { DeviceType } from "../../enums";
|
||||
import { AppIdService } from "../../platform/abstractions/app-id.service";
|
||||
import { CryptoFunctionService } from "../../platform/abstractions/crypto-function.service";
|
||||
@@ -9,18 +15,26 @@ import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { EncryptionType } from "../../platform/enums/encryption-type.enum";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { DeviceKey, UserKey } from "../../types/key";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
|
||||
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
|
||||
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
|
||||
|
||||
import { DeviceTrustCryptoService } from "./device-trust-crypto.service.implementation";
|
||||
import {
|
||||
SHOULD_TRUST_DEVICE,
|
||||
DEVICE_KEY,
|
||||
DeviceTrustCryptoService,
|
||||
} from "./device-trust-crypto.service.implementation";
|
||||
|
||||
describe("deviceTrustCryptoService", () => {
|
||||
let deviceTrustCryptoService: DeviceTrustCryptoService;
|
||||
@@ -29,26 +43,34 @@ describe("deviceTrustCryptoService", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const encryptService = mock<EncryptService>();
|
||||
const stateService = mock<StateService>();
|
||||
const appIdService = mock<AppIdService>();
|
||||
const devicesApiService = mock<DevicesApiServiceAbstraction>();
|
||||
const i18nService = mock<I18nService>();
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
const secureStorageService = mock<AbstractStorageService>();
|
||||
|
||||
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
const decryptionOptions = new BehaviorSubject<UserDecryptionOptions>(null);
|
||||
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const deviceKeyPartialSecureStorageKey = "_deviceKey";
|
||||
const deviceKeySecureStorageKey = `${mockUserId}${deviceKeyPartialSecureStorageKey}`;
|
||||
|
||||
const secureStorageOptions: StorageOptions = {
|
||||
storageLocation: StorageLocation.Disk,
|
||||
useSecureStorage: true,
|
||||
userId: mockUserId,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
deviceTrustCryptoService = new DeviceTrustCryptoService(
|
||||
keyGenerationService,
|
||||
cryptoFunctionService,
|
||||
cryptoService,
|
||||
encryptService,
|
||||
stateService,
|
||||
appIdService,
|
||||
devicesApiService,
|
||||
i18nService,
|
||||
platformUtilsService,
|
||||
);
|
||||
const supportsSecureStorage = false; // default to false; tests will override as needed
|
||||
// By default all the tests will have a mocked active user in state provider.
|
||||
deviceTrustCryptoService = createDeviceTrustCryptoService(mockUserId, supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
@@ -57,27 +79,26 @@ describe("deviceTrustCryptoService", () => {
|
||||
|
||||
describe("User Trust Device Choice For Decryption", () => {
|
||||
describe("getShouldTrustDevice", () => {
|
||||
it("gets the user trust device choice for decryption from the state service", async () => {
|
||||
const stateSvcGetShouldTrustDeviceSpy = jest.spyOn(stateService, "getShouldTrustDevice");
|
||||
it("gets the user trust device choice for decryption", async () => {
|
||||
const newValue = true;
|
||||
|
||||
const expectedValue = true;
|
||||
stateSvcGetShouldTrustDeviceSpy.mockResolvedValue(expectedValue);
|
||||
const result = await deviceTrustCryptoService.getShouldTrustDevice();
|
||||
await stateProvider.setUserState(SHOULD_TRUST_DEVICE, newValue, mockUserId);
|
||||
|
||||
expect(stateSvcGetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result).toEqual(expectedValue);
|
||||
const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId);
|
||||
|
||||
expect(result).toEqual(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setShouldTrustDevice", () => {
|
||||
it("sets the user trust device choice for decryption in the state service", async () => {
|
||||
const stateSvcSetShouldTrustDeviceSpy = jest.spyOn(stateService, "setShouldTrustDevice");
|
||||
it("sets the user trust device choice for decryption ", async () => {
|
||||
await stateProvider.setUserState(SHOULD_TRUST_DEVICE, false, mockUserId);
|
||||
|
||||
const newValue = true;
|
||||
await deviceTrustCryptoService.setShouldTrustDevice(newValue);
|
||||
await deviceTrustCryptoService.setShouldTrustDevice(mockUserId, newValue);
|
||||
|
||||
expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(stateSvcSetShouldTrustDeviceSpy).toHaveBeenCalledWith(newValue);
|
||||
const result = await deviceTrustCryptoService.getShouldTrustDevice(mockUserId);
|
||||
expect(result).toEqual(newValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -88,11 +109,11 @@ describe("deviceTrustCryptoService", () => {
|
||||
jest.spyOn(deviceTrustCryptoService, "trustDevice").mockResolvedValue({} as DeviceResponse);
|
||||
jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice").mockResolvedValue();
|
||||
|
||||
await deviceTrustCryptoService.trustDeviceIfRequired();
|
||||
await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId);
|
||||
|
||||
expect(deviceTrustCryptoService.getShouldTrustDevice).toHaveBeenCalledTimes(1);
|
||||
expect(deviceTrustCryptoService.trustDevice).toHaveBeenCalledTimes(1);
|
||||
expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(false);
|
||||
expect(deviceTrustCryptoService.setShouldTrustDevice).toHaveBeenCalledWith(mockUserId, false);
|
||||
});
|
||||
|
||||
it("should not trust device nor reset when getShouldTrustDevice returns false", async () => {
|
||||
@@ -102,7 +123,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
const trustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "trustDevice");
|
||||
const setShouldTrustDeviceSpy = jest.spyOn(deviceTrustCryptoService, "setShouldTrustDevice");
|
||||
|
||||
await deviceTrustCryptoService.trustDeviceIfRequired();
|
||||
await deviceTrustCryptoService.trustDeviceIfRequired(mockUserId);
|
||||
|
||||
expect(getShouldTrustDeviceSpy).toHaveBeenCalledTimes(1);
|
||||
expect(trustDeviceSpy).not.toHaveBeenCalled();
|
||||
@@ -116,53 +137,140 @@ describe("deviceTrustCryptoService", () => {
|
||||
|
||||
describe("getDeviceKey", () => {
|
||||
let existingDeviceKey: DeviceKey;
|
||||
let stateSvcGetDeviceKeySpy: jest.SpyInstance;
|
||||
let existingDeviceKeyB64: { keyB64: string };
|
||||
|
||||
beforeEach(() => {
|
||||
existingDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
stateSvcGetDeviceKeySpy = jest.spyOn(stateService, "getDeviceKey");
|
||||
existingDeviceKeyB64 = existingDeviceKey.toJSON();
|
||||
});
|
||||
|
||||
it("returns null when there is not an existing device key", async () => {
|
||||
stateSvcGetDeviceKeySpy.mockResolvedValue(null);
|
||||
describe("Secure Storage not supported", () => {
|
||||
it("returns null when there is not an existing device key", async () => {
|
||||
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
|
||||
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey();
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
|
||||
|
||||
expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(deviceKey).toBeNull();
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(deviceKey).toBeNull();
|
||||
it("returns the device key when there is an existing device key", async () => {
|
||||
await stateProvider.setUserState(DEVICE_KEY, existingDeviceKey, mockUserId);
|
||||
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
|
||||
|
||||
expect(deviceKey).not.toBeNull();
|
||||
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
|
||||
expect(deviceKey).toEqual(existingDeviceKey);
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the device key when there is an existing device key", async () => {
|
||||
stateSvcGetDeviceKeySpy.mockResolvedValue(existingDeviceKey);
|
||||
describe("Secure Storage supported", () => {
|
||||
beforeEach(() => {
|
||||
const supportsSecureStorage = true;
|
||||
deviceTrustCryptoService = createDeviceTrustCryptoService(
|
||||
mockUserId,
|
||||
supportsSecureStorage,
|
||||
);
|
||||
});
|
||||
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey();
|
||||
it("returns null when there is not an existing device key for the passed in user id", async () => {
|
||||
secureStorageService.get.mockResolvedValue(null);
|
||||
|
||||
expect(stateSvcGetDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
// Act
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
|
||||
|
||||
expect(deviceKey).not.toBeNull();
|
||||
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
|
||||
expect(deviceKey).toEqual(existingDeviceKey);
|
||||
// Assert
|
||||
expect(deviceKey).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the device key when there is an existing device key for the passed in user id", async () => {
|
||||
// Arrange
|
||||
secureStorageService.get.mockResolvedValue(existingDeviceKeyB64);
|
||||
|
||||
// Act
|
||||
const deviceKey = await deviceTrustCryptoService.getDeviceKey(mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(deviceKey).not.toBeNull();
|
||||
expect(deviceKey).toBeInstanceOf(SymmetricCryptoKey);
|
||||
expect(deviceKey).toEqual(existingDeviceKey);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws an error when no user id is passed in", async () => {
|
||||
await expect(deviceTrustCryptoService.getDeviceKey(null)).rejects.toThrow(
|
||||
"UserId is required. Cannot get device key.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setDeviceKey", () => {
|
||||
it("sets the device key in the state service", async () => {
|
||||
const stateSvcSetDeviceKeySpy = jest.spyOn(stateService, "setDeviceKey");
|
||||
describe("Secure Storage not supported", () => {
|
||||
it("successfully sets the device key in state provider", async () => {
|
||||
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
|
||||
|
||||
const deviceKey = new SymmetricCryptoKey(
|
||||
const newDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
// TypeScript will allow calling private methods if the object is of type 'any'
|
||||
// This is a hacky workaround, but it allows for cleaner tests
|
||||
await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey);
|
||||
|
||||
expect(stateProvider.mock.setUserState).toHaveBeenLastCalledWith(
|
||||
DEVICE_KEY,
|
||||
newDeviceKey.toJSON(),
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
describe("Secure Storage supported", () => {
|
||||
beforeEach(() => {
|
||||
const supportsSecureStorage = true;
|
||||
deviceTrustCryptoService = createDeviceTrustCryptoService(
|
||||
mockUserId,
|
||||
supportsSecureStorage,
|
||||
);
|
||||
});
|
||||
|
||||
it("successfully sets the device key in secure storage", async () => {
|
||||
// Arrange
|
||||
await stateProvider.setUserState(DEVICE_KEY, null, mockUserId);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(null);
|
||||
|
||||
const newDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
// Act
|
||||
// TypeScript will allow calling private methods if the object is of type 'any'
|
||||
// This is a hacky workaround, but it allows for cleaner tests
|
||||
await (deviceTrustCryptoService as any).setDeviceKey(mockUserId, newDeviceKey);
|
||||
|
||||
// Assert
|
||||
expect(stateProvider.mock.setUserState).not.toHaveBeenCalledTimes(2);
|
||||
expect(secureStorageService.save).toHaveBeenCalledWith(
|
||||
deviceKeySecureStorageKey,
|
||||
newDeviceKey,
|
||||
secureStorageOptions,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
const newDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength) as CsprngArray,
|
||||
) as DeviceKey;
|
||||
|
||||
// TypeScript will allow calling private methods if the object is of type 'any'
|
||||
// This is a hacky workaround, but it allows for cleaner tests
|
||||
await (deviceTrustCryptoService as any).setDeviceKey(deviceKey);
|
||||
|
||||
expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(stateSvcSetDeviceKeySpy).toHaveBeenCalledWith(deviceKey);
|
||||
await expect(
|
||||
(deviceTrustCryptoService as any).setDeviceKey(null, newDeviceKey),
|
||||
).rejects.toThrow("UserId is required. Cannot set device key.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,7 +398,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
});
|
||||
|
||||
it("calls the required methods with the correct arguments and returns a DeviceResponse", async () => {
|
||||
const response = await deviceTrustCryptoService.trustDevice();
|
||||
const response = await deviceTrustCryptoService.trustDevice(mockUserId);
|
||||
|
||||
expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -321,7 +429,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
// setup the spy to return null
|
||||
cryptoSvcGetUserKeySpy.mockResolvedValue(null);
|
||||
// check if the expected error is thrown
|
||||
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(
|
||||
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
"User symmetric key not found",
|
||||
);
|
||||
|
||||
@@ -331,7 +439,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
// setup the spy to return undefined
|
||||
cryptoSvcGetUserKeySpy.mockResolvedValue(undefined);
|
||||
// check if the expected error is thrown
|
||||
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(
|
||||
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
"User symmetric key not found",
|
||||
);
|
||||
});
|
||||
@@ -371,7 +479,9 @@ describe("deviceTrustCryptoService", () => {
|
||||
it(`throws an error if ${method} fails`, async () => {
|
||||
const methodSpy = spy();
|
||||
methodSpy.mockRejectedValue(new Error(errorText));
|
||||
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow(errorText);
|
||||
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow(
|
||||
errorText,
|
||||
);
|
||||
});
|
||||
|
||||
test.each([null, undefined])(
|
||||
@@ -379,11 +489,17 @@ describe("deviceTrustCryptoService", () => {
|
||||
async (invalidValue) => {
|
||||
const methodSpy = spy();
|
||||
methodSpy.mockResolvedValue(invalidValue);
|
||||
await expect(deviceTrustCryptoService.trustDevice()).rejects.toThrow();
|
||||
await expect(deviceTrustCryptoService.trustDevice(mockUserId)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
await expect(deviceTrustCryptoService.trustDevice(null)).rejects.toThrow(
|
||||
"UserId is required. Cannot trust device.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("decryptUserKeyWithDeviceKey", () => {
|
||||
@@ -412,19 +528,26 @@ describe("deviceTrustCryptoService", () => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns null when device key isn't provided and isn't in state", async () => {
|
||||
const getDeviceKeySpy = jest
|
||||
.spyOn(deviceTrustCryptoService, "getDeviceKey")
|
||||
.mockResolvedValue(null);
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
null,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
),
|
||||
).rejects.toThrow("UserId is required. Cannot decrypt user key with device key.");
|
||||
});
|
||||
|
||||
it("returns null when device key isn't provided", async () => {
|
||||
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
mockUserId,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
|
||||
expect(getDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("successfully returns the user key when provided keys (including device key) can decrypt it", async () => {
|
||||
@@ -436,6 +559,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
|
||||
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
mockUserId,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
@@ -446,31 +570,6 @@ describe("deviceTrustCryptoService", () => {
|
||||
expect(rsaDecryptSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("successfully returns the user key when a device key is not provided (retrieves device key from state)", async () => {
|
||||
const getDeviceKeySpy = jest
|
||||
.spyOn(deviceTrustCryptoService, "getDeviceKey")
|
||||
.mockResolvedValue(mockDeviceKey);
|
||||
|
||||
const decryptToBytesSpy = jest
|
||||
.spyOn(encryptService, "decryptToBytes")
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
const rsaDecryptSpy = jest
|
||||
.spyOn(cryptoService, "rsaDecrypt")
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
|
||||
// Call without providing a device key
|
||||
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
);
|
||||
|
||||
expect(getDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(result).toEqual(mockUserKey);
|
||||
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(rsaDecryptSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns null and removes device key when the decryption fails", async () => {
|
||||
const decryptToBytesSpy = jest
|
||||
.spyOn(encryptService, "decryptToBytes")
|
||||
@@ -478,6 +577,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
const setDeviceKeySpy = jest.spyOn(deviceTrustCryptoService as any, "setDeviceKey");
|
||||
|
||||
const result = await deviceTrustCryptoService.decryptUserKeyWithDeviceKey(
|
||||
mockUserId,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
@@ -486,7 +586,7 @@ describe("deviceTrustCryptoService", () => {
|
||||
expect(result).toBeNull();
|
||||
expect(decryptToBytesSpy).toHaveBeenCalledTimes(1);
|
||||
expect(setDeviceKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(setDeviceKeySpy).toHaveBeenCalledWith(null);
|
||||
expect(setDeviceKeySpy).toHaveBeenCalledWith(mockUserId, null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -504,19 +604,28 @@ describe("deviceTrustCryptoService", () => {
|
||||
cryptoService.activeUserKey$ = of(fakeNewUserKey);
|
||||
});
|
||||
|
||||
it("does an early exit when the current device is not a trusted device", async () => {
|
||||
stateService.getDeviceKey.mockResolvedValue(null);
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustCryptoService.rotateDevicesTrust(null, fakeNewUserKey, ""),
|
||||
).rejects.toThrow("UserId is required. Cannot rotate device's trust.");
|
||||
});
|
||||
|
||||
await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "");
|
||||
it("does an early exit when the current device is not a trusted device", async () => {
|
||||
const deviceKeyState: FakeActiveUserState<DeviceKey> =
|
||||
stateProvider.activeUser.getFake(DEVICE_KEY);
|
||||
deviceKeyState.nextState(null);
|
||||
|
||||
await deviceTrustCryptoService.rotateDevicesTrust(mockUserId, fakeNewUserKey, "");
|
||||
|
||||
expect(devicesApiService.updateTrust).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("is on a trusted device", () => {
|
||||
beforeEach(() => {
|
||||
stateService.getDeviceKey.mockResolvedValue(
|
||||
new SymmetricCryptoKey(new Uint8Array(deviceKeyBytesLength)) as DeviceKey,
|
||||
);
|
||||
beforeEach(async () => {
|
||||
const mockDeviceKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(deviceKeyBytesLength),
|
||||
) as DeviceKey;
|
||||
await stateProvider.setUserState(DEVICE_KEY, mockDeviceKey, mockUserId);
|
||||
});
|
||||
|
||||
it("rotates current device keys and calls api service when the current device is trusted", async () => {
|
||||
@@ -582,7 +691,11 @@ describe("deviceTrustCryptoService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
await deviceTrustCryptoService.rotateDevicesTrust(fakeNewUserKey, "my_password_hash");
|
||||
await deviceTrustCryptoService.rotateDevicesTrust(
|
||||
mockUserId,
|
||||
fakeNewUserKey,
|
||||
"my_password_hash",
|
||||
);
|
||||
|
||||
expect(devicesApiService.updateTrust).toHaveBeenCalledWith(
|
||||
matches((updateTrustModel: UpdateDevicesTrustRequest) => {
|
||||
@@ -598,4 +711,32 @@ describe("deviceTrustCryptoService", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers
|
||||
function createDeviceTrustCryptoService(
|
||||
mockUserId: UserId | null,
|
||||
supportsSecureStorage: boolean,
|
||||
) {
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage);
|
||||
|
||||
decryptionOptions.next({} as any);
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
|
||||
|
||||
return new DeviceTrustCryptoService(
|
||||
keyGenerationService,
|
||||
cryptoFunctionService,
|
||||
cryptoService,
|
||||
encryptService,
|
||||
appIdService,
|
||||
devicesApiService,
|
||||
i18nService,
|
||||
platformUtilsService,
|
||||
stateProvider,
|
||||
secureStorageService,
|
||||
userDecryptionOptionsService,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
376
libs/common/src/auth/services/key-connector.service.spec.ts
Normal file
376
libs/common/src/auth/services/key-connector.service.spec.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../spec";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationData } from "../../admin-console/models/data/organization.data";
|
||||
import { Organization } from "../../admin-console/models/domain/organization";
|
||||
import { ProfileOrganizationResponse } from "../../admin-console/models/response/profile-organization.response";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { KeyGenerationService } from "../../platform/services/key-generation.service";
|
||||
import { OrganizationId, UserId } from "../../types/guid";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user-key.request";
|
||||
import { KeyConnectorUserKeyResponse } from "../models/response/key-connector-user-key.response";
|
||||
|
||||
import {
|
||||
USES_KEY_CONNECTOR,
|
||||
CONVERT_ACCOUNT_TO_KEY_CONNECTOR,
|
||||
KeyConnectorService,
|
||||
} from "./key-connector.service";
|
||||
import { TokenService } from "./token.service";
|
||||
|
||||
describe("KeyConnectorService", () => {
|
||||
let keyConnectorService: KeyConnectorService;
|
||||
|
||||
const cryptoService = mock<CryptoService>();
|
||||
const apiService = mock<ApiService>();
|
||||
const tokenService = mock<TokenService>();
|
||||
const logService = mock<LogService>();
|
||||
const organizationService = mock<OrganizationService>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
|
||||
let stateProvider: FakeStateProvider;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const mockOrgId = Utils.newGuid() as OrganizationId;
|
||||
|
||||
const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({
|
||||
key: "eO9nVlVl3I3sU6O+CyK0kEkpGtl/auT84Hig2WTXmZtDTqYtKpDvUPfjhgMOHf+KQzx++TVS2AOLYq856Caa7w==",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
keyConnectorService = new KeyConnectorService(
|
||||
cryptoService,
|
||||
apiService,
|
||||
tokenService,
|
||||
logService,
|
||||
organizationService,
|
||||
keyGenerationService,
|
||||
async () => {},
|
||||
stateProvider,
|
||||
);
|
||||
});
|
||||
|
||||
it("instantiates", () => {
|
||||
expect(keyConnectorService).not.toBeFalsy();
|
||||
});
|
||||
|
||||
describe("setUsesKeyConnector()", () => {
|
||||
it("should update the usesKeyConnectorState with the provided value", async () => {
|
||||
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
|
||||
state.nextState(false);
|
||||
|
||||
const newValue = true;
|
||||
|
||||
await keyConnectorService.setUsesKeyConnector(newValue);
|
||||
|
||||
expect(await keyConnectorService.getUsesKeyConnector()).toBe(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getManagingOrganization()", () => {
|
||||
it("should return the managing organization with key connector enabled", async () => {
|
||||
// Arrange
|
||||
const orgs = [
|
||||
organizationData(true, true, "https://key-connector-url.com", 2, false),
|
||||
organizationData(false, true, "https://key-connector-url.com", 2, false),
|
||||
organizationData(true, false, "https://key-connector-url.com", 2, false),
|
||||
organizationData(true, true, "https://other-url.com", 2, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(orgs[0]);
|
||||
});
|
||||
|
||||
it("should return undefined if no managing organization with key connector enabled is found", async () => {
|
||||
// Arrange
|
||||
const orgs = [
|
||||
organizationData(true, false, "https://key-connector-url.com", 2, false),
|
||||
organizationData(false, false, "https://key-connector-url.com", 2, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined if user is Owner or Admin", async () => {
|
||||
// Arrange
|
||||
const orgs = [
|
||||
organizationData(true, true, "https://key-connector-url.com", 0, false),
|
||||
organizationData(true, true, "https://key-connector-url.com", 1, false),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined if user is a Provider", async () => {
|
||||
// Arrange
|
||||
const orgs = [
|
||||
organizationData(true, true, "https://key-connector-url.com", 2, true),
|
||||
organizationData(false, true, "https://key-connector-url.com", 2, true),
|
||||
];
|
||||
organizationService.getAll.mockResolvedValue(orgs);
|
||||
|
||||
// Act
|
||||
const result = await keyConnectorService.getManagingOrganization();
|
||||
|
||||
// Assert
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setConvertAccountRequired()", () => {
|
||||
it("should update the convertAccountToKeyConnectorState with the provided value", async () => {
|
||||
const state = stateProvider.activeUser.getFake(CONVERT_ACCOUNT_TO_KEY_CONNECTOR);
|
||||
state.nextState(false);
|
||||
|
||||
const newValue = true;
|
||||
|
||||
await keyConnectorService.setConvertAccountRequired(newValue);
|
||||
|
||||
expect(await keyConnectorService.getConvertAccountRequired()).toBe(newValue);
|
||||
});
|
||||
|
||||
it("should remove the convertAccountToKeyConnectorState", async () => {
|
||||
const state = stateProvider.activeUser.getFake(CONVERT_ACCOUNT_TO_KEY_CONNECTOR);
|
||||
state.nextState(false);
|
||||
|
||||
const newValue: boolean = null;
|
||||
|
||||
await keyConnectorService.setConvertAccountRequired(newValue);
|
||||
|
||||
expect(await keyConnectorService.getConvertAccountRequired()).toBe(newValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userNeedsMigration()", () => {
|
||||
it("should return true if the user needs migration", async () => {
|
||||
// token
|
||||
tokenService.getIsExternal.mockResolvedValue(true);
|
||||
|
||||
// create organization object
|
||||
const data = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
organizationService.getAll.mockResolvedValue([data]);
|
||||
|
||||
// uses KeyConnector
|
||||
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
|
||||
state.nextState(false);
|
||||
|
||||
const result = await keyConnectorService.userNeedsMigration();
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the user does not need migration", async () => {
|
||||
tokenService.getIsExternal.mockResolvedValue(false);
|
||||
const data = organizationData(false, false, "https://key-connector-url.com", 2, false);
|
||||
organizationService.getAll.mockResolvedValue([data]);
|
||||
|
||||
const state = stateProvider.activeUser.getFake(USES_KEY_CONNECTOR);
|
||||
state.nextState(true);
|
||||
const result = await keyConnectorService.userNeedsMigration();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setMasterKeyFromUrl", () => {
|
||||
it("should set the master key from the provided URL", async () => {
|
||||
// Arrange
|
||||
const url = "https://key-connector-url.com";
|
||||
|
||||
apiService.getMasterKeyFromKeyConnector.mockResolvedValue(mockMasterKeyResponse);
|
||||
|
||||
// Hard to mock these, but we can generate the same keys
|
||||
const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key);
|
||||
const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey;
|
||||
|
||||
// Act
|
||||
await keyConnectorService.setMasterKeyFromUrl(url);
|
||||
|
||||
// Assert
|
||||
expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url);
|
||||
expect(cryptoService.setMasterKey).toHaveBeenCalledWith(masterKey);
|
||||
});
|
||||
|
||||
it("should handle errors thrown during the process", async () => {
|
||||
// Arrange
|
||||
const url = "https://key-connector-url.com";
|
||||
|
||||
const error = new Error("Failed to get master key");
|
||||
apiService.getMasterKeyFromKeyConnector.mockRejectedValue(error);
|
||||
jest.spyOn(logService, "error");
|
||||
|
||||
try {
|
||||
// Act
|
||||
await keyConnectorService.setMasterKeyFromUrl(url);
|
||||
} catch {
|
||||
// Assert
|
||||
expect(logService.error).toHaveBeenCalledWith(error);
|
||||
expect(apiService.getMasterKeyFromKeyConnector).toHaveBeenCalledWith(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("migrateUser()", () => {
|
||||
it("should migrate the user to the key connector", async () => {
|
||||
// Arrange
|
||||
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
const masterKey = getMockMasterKey();
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
|
||||
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
|
||||
jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey);
|
||||
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue();
|
||||
|
||||
// Act
|
||||
await keyConnectorService.migrateUser();
|
||||
|
||||
// Assert
|
||||
expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled();
|
||||
expect(cryptoService.getMasterKey).toHaveBeenCalled();
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
organization.keyConnectorUrl,
|
||||
keyConnectorRequest,
|
||||
);
|
||||
expect(apiService.postConvertToKeyConnector).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors thrown during migration", async () => {
|
||||
// Arrange
|
||||
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
const masterKey = getMockMasterKey();
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const error = new Error("Failed to post user key to key connector");
|
||||
organizationService.getAll.mockResolvedValue([organization]);
|
||||
|
||||
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
|
||||
jest.spyOn(cryptoService, "getMasterKey").mockResolvedValue(masterKey);
|
||||
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockRejectedValue(error);
|
||||
jest.spyOn(logService, "error");
|
||||
|
||||
try {
|
||||
// Act
|
||||
await keyConnectorService.migrateUser();
|
||||
} catch {
|
||||
// Assert
|
||||
expect(logService.error).toHaveBeenCalledWith(error);
|
||||
expect(keyConnectorService.getManagingOrganization).toHaveBeenCalled();
|
||||
expect(cryptoService.getMasterKey).toHaveBeenCalled();
|
||||
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
|
||||
organization.keyConnectorUrl,
|
||||
keyConnectorRequest,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function organizationData(
|
||||
usesKeyConnector: boolean,
|
||||
keyConnectorEnabled: boolean,
|
||||
keyConnectorUrl: string,
|
||||
userType: number,
|
||||
isProviderUser: boolean,
|
||||
): Organization {
|
||||
return new Organization(
|
||||
new OrganizationData(
|
||||
new ProfileOrganizationResponse({
|
||||
id: mockOrgId,
|
||||
name: "TEST_KEY_CONNECTOR_ORG",
|
||||
usePolicies: true,
|
||||
useSso: true,
|
||||
useKeyConnector: usesKeyConnector,
|
||||
useScim: true,
|
||||
useGroups: true,
|
||||
useDirectory: true,
|
||||
useEvents: true,
|
||||
useTotp: true,
|
||||
use2fa: true,
|
||||
useApi: true,
|
||||
useResetPassword: true,
|
||||
useSecretsManager: true,
|
||||
usePasswordManager: true,
|
||||
usersGetPremium: true,
|
||||
useCustomPermissions: true,
|
||||
useActivateAutofillPolicy: true,
|
||||
selfHost: true,
|
||||
seats: 5,
|
||||
maxCollections: null,
|
||||
maxStorageGb: 1,
|
||||
key: "super-secret-key",
|
||||
status: 2,
|
||||
type: userType,
|
||||
enabled: true,
|
||||
ssoBound: true,
|
||||
identifier: "TEST_KEY_CONNECTOR_ORG",
|
||||
permissions: {
|
||||
accessEventLogs: false,
|
||||
accessImportExport: false,
|
||||
accessReports: false,
|
||||
createNewCollections: false,
|
||||
editAnyCollection: false,
|
||||
deleteAnyCollection: false,
|
||||
editAssignedCollections: false,
|
||||
deleteAssignedCollections: false,
|
||||
manageGroups: false,
|
||||
managePolicies: false,
|
||||
manageSso: false,
|
||||
manageUsers: false,
|
||||
manageResetPassword: false,
|
||||
manageScim: false,
|
||||
},
|
||||
resetPasswordEnrolled: true,
|
||||
userId: mockUserId,
|
||||
hasPublicAndPrivateKeys: true,
|
||||
providerId: null,
|
||||
providerName: null,
|
||||
providerType: null,
|
||||
familySponsorshipFriendlyName: null,
|
||||
familySponsorshipAvailable: true,
|
||||
planProductType: 3,
|
||||
KeyConnectorEnabled: keyConnectorEnabled,
|
||||
KeyConnectorUrl: keyConnectorUrl,
|
||||
familySponsorshipLastSyncDate: null,
|
||||
familySponsorshipValidUntil: null,
|
||||
familySponsorshipToDelete: null,
|
||||
accessSecretsManager: false,
|
||||
limitCollectionCreationDeletion: true,
|
||||
allowAdminAccessToAllCollectionItems: true,
|
||||
flexibleCollections: false,
|
||||
object: "profileOrganization",
|
||||
}),
|
||||
{ isMember: true, isProviderUser: isProviderUser },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getMockMasterKey(): MasterKey {
|
||||
const keyArr = Utils.fromB64ToArray(mockMasterKeyResponse.key);
|
||||
const masterKey = new SymmetricCryptoKey(keyArr) as MasterKey;
|
||||
return masterKey;
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { OrganizationService } from "../../admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { OrganizationUserType } from "../../admin-console/enums";
|
||||
@@ -5,9 +7,14 @@ import { KeysRequest } from "../../models/request/keys.request";
|
||||
import { CryptoService } from "../../platform/abstractions/crypto.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
ActiveUserState,
|
||||
KEY_CONNECTOR_DISK,
|
||||
StateProvider,
|
||||
UserKeyDefinition,
|
||||
} from "../../platform/state";
|
||||
import { MasterKey } from "../../types/key";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
|
||||
import { TokenService } from "../abstractions/token.service";
|
||||
@@ -16,9 +23,28 @@ import { KeyConnectorUserKeyRequest } from "../models/request/key-connector-user
|
||||
import { SetKeyConnectorKeyRequest } from "../models/request/set-key-connector-key.request";
|
||||
import { IdentityTokenResponse } from "../models/response/identity-token.response";
|
||||
|
||||
export const USES_KEY_CONNECTOR = new UserKeyDefinition<boolean>(
|
||||
KEY_CONNECTOR_DISK,
|
||||
"usesKeyConnector",
|
||||
{
|
||||
deserializer: (usesKeyConnector) => usesKeyConnector,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export const CONVERT_ACCOUNT_TO_KEY_CONNECTOR = new UserKeyDefinition<boolean>(
|
||||
KEY_CONNECTOR_DISK,
|
||||
"convertAccountToKeyConnector",
|
||||
{
|
||||
deserializer: (convertAccountToKeyConnector) => convertAccountToKeyConnector,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
private usesKeyConnectorState: ActiveUserState<boolean>;
|
||||
private convertAccountToKeyConnectorState: ActiveUserState<boolean>;
|
||||
constructor(
|
||||
private stateService: StateService,
|
||||
private cryptoService: CryptoService,
|
||||
private apiService: ApiService,
|
||||
private tokenService: TokenService,
|
||||
@@ -26,14 +52,20 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
private organizationService: OrganizationService,
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private logoutCallback: (expired: boolean, userId?: string) => Promise<void>,
|
||||
) {}
|
||||
|
||||
setUsesKeyConnector(usesKeyConnector: boolean) {
|
||||
return this.stateService.setUsesKeyConnector(usesKeyConnector);
|
||||
private stateProvider: StateProvider,
|
||||
) {
|
||||
this.usesKeyConnectorState = this.stateProvider.getActive(USES_KEY_CONNECTOR);
|
||||
this.convertAccountToKeyConnectorState = this.stateProvider.getActive(
|
||||
CONVERT_ACCOUNT_TO_KEY_CONNECTOR,
|
||||
);
|
||||
}
|
||||
|
||||
async getUsesKeyConnector(): Promise<boolean> {
|
||||
return await this.stateService.getUsesKeyConnector();
|
||||
async setUsesKeyConnector(usesKeyConnector: boolean) {
|
||||
await this.usesKeyConnectorState.update(() => usesKeyConnector);
|
||||
}
|
||||
|
||||
getUsesKeyConnector(): Promise<boolean> {
|
||||
return firstValueFrom(this.usesKeyConnectorState.state$);
|
||||
}
|
||||
|
||||
async userNeedsMigration() {
|
||||
@@ -132,19 +164,15 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
}
|
||||
|
||||
async setConvertAccountRequired(status: boolean) {
|
||||
await this.stateService.setConvertAccountToKeyConnector(status);
|
||||
await this.convertAccountToKeyConnectorState.update(() => status);
|
||||
}
|
||||
|
||||
async getConvertAccountRequired(): Promise<boolean> {
|
||||
return await this.stateService.getConvertAccountToKeyConnector();
|
||||
getConvertAccountRequired(): Promise<boolean> {
|
||||
return firstValueFrom(this.convertAccountToKeyConnectorState.state$);
|
||||
}
|
||||
|
||||
async removeConvertAccountRequired() {
|
||||
await this.stateService.setConvertAccountToKeyConnector(null);
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.removeConvertAccountRequired();
|
||||
await this.setConvertAccountRequired(null);
|
||||
}
|
||||
|
||||
private handleKeyConnectorError(e: any) {
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { StateService } from "../../platform/abstractions/state.service";
|
||||
import { LoginService as LoginServiceAbstraction } from "../abstractions/login.service";
|
||||
|
||||
export class LoginService implements LoginServiceAbstraction {
|
||||
private _email: string;
|
||||
private _rememberEmail: boolean;
|
||||
|
||||
constructor(private stateService: StateService) {}
|
||||
|
||||
getEmail() {
|
||||
return this._email;
|
||||
}
|
||||
|
||||
getRememberEmail() {
|
||||
return this._rememberEmail;
|
||||
}
|
||||
|
||||
setEmail(value: string) {
|
||||
this._email = value;
|
||||
}
|
||||
|
||||
setRememberEmail(value: boolean) {
|
||||
this._rememberEmail = value;
|
||||
}
|
||||
|
||||
clearValues() {
|
||||
this._email = null;
|
||||
this._rememberEmail = null;
|
||||
}
|
||||
|
||||
async saveEmailSettings() {
|
||||
await this.stateService.setRememberedEmail(this._rememberEmail ? this._email : null);
|
||||
this.clearValues();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeSingleUserStateProvider, FakeGlobalStateProvider } from "../../../spec";
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
@@ -12,7 +16,6 @@ import { DecodedAccessToken, TokenService } from "./token.service";
|
||||
import {
|
||||
ACCESS_TOKEN_DISK,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
API_KEY_CLIENT_ID_MEMORY,
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
@@ -28,7 +31,10 @@ describe("TokenService", () => {
|
||||
let singleUserStateProvider: FakeSingleUserStateProvider;
|
||||
let globalStateProvider: FakeGlobalStateProvider;
|
||||
|
||||
const secureStorageService = mock<AbstractStorageService>();
|
||||
let secureStorageService: MockProxy<AbstractStorageService>;
|
||||
let keyGenerationService: MockProxy<KeyGenerationService>;
|
||||
let encryptService: MockProxy<EncryptService>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const memoryVaultTimeoutAction = VaultTimeoutAction.LogOut;
|
||||
const memoryVaultTimeout = 30;
|
||||
@@ -74,12 +80,19 @@ describe("TokenService", () => {
|
||||
userId: userIdFromAccessToken,
|
||||
};
|
||||
|
||||
const accessTokenKeyB64 = { keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8" };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
singleUserStateProvider = new FakeSingleUserStateProvider();
|
||||
globalStateProvider = new FakeGlobalStateProvider();
|
||||
|
||||
secureStorageService = mock<AbstractStorageService>();
|
||||
keyGenerationService = mock<KeyGenerationService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
const supportsSecureStorage = false; // default to false; tests will override as needed
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
@@ -89,8 +102,63 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Access Token methods", () => {
|
||||
const accessTokenPartialSecureStorageKey = `_accessToken`;
|
||||
const accessTokenSecureStorageKey = `${userIdFromAccessToken}${accessTokenPartialSecureStorageKey}`;
|
||||
const accessTokenKeyPartialSecureStorageKey = `_accessTokenKey`;
|
||||
const accessTokenKeySecureStorageKey = `${userIdFromAccessToken}${accessTokenKeyPartialSecureStorageKey}`;
|
||||
|
||||
describe("hasAccessToken$", () => {
|
||||
it("returns true when an access token exists in memory", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns true when an access token exists in disk", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it("returns true when an access token exists in secure storage", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
|
||||
|
||||
// Act
|
||||
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
|
||||
it("should return false if no access token exists in memory, disk, or secure storage", async () => {
|
||||
// Act
|
||||
const result = await firstValueFrom(tokenService.hasAccessToken$(userIdFromAccessToken));
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccessToken", () => {
|
||||
it("should throw an error if the access token is null", async () => {
|
||||
@@ -150,18 +218,22 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should set the access token in secure storage, null out data on disk or in memory, and set a flag to indicate the token has been migrated", async () => {
|
||||
it("should set an access token key in secure storage, the encrypted access token in disk, and clear out the token in memory", async () => {
|
||||
// Arrange:
|
||||
|
||||
// For testing purposes, let's assume that the access token is already in disk and memory
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// For testing purposes, let's assume that the access token is already in memory
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
keyGenerationService.createKey.mockResolvedValue("accessTokenKey" as any);
|
||||
|
||||
const mockEncryptedAccessToken = "encryptedAccessToken";
|
||||
|
||||
encryptService.encrypt.mockResolvedValue({
|
||||
encryptedString: mockEncryptedAccessToken,
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
await tokenService.setAccessToken(
|
||||
accessTokenJwt,
|
||||
@@ -170,27 +242,22 @@ describe("TokenService", () => {
|
||||
);
|
||||
// Assert
|
||||
|
||||
// assert that the access token was set in secure storage
|
||||
// assert that the AccessTokenKey was set in secure storage
|
||||
expect(secureStorageService.save).toHaveBeenCalledWith(
|
||||
accessTokenSecureStorageKey,
|
||||
accessTokenJwt,
|
||||
accessTokenKeySecureStorageKey,
|
||||
"accessTokenKey",
|
||||
secureStorageOptions,
|
||||
);
|
||||
|
||||
// assert data was migrated out of disk and memory + flag was set
|
||||
// assert that the access token was encrypted and set in disk
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
).toHaveBeenCalledWith(mockEncryptedAccessToken);
|
||||
|
||||
// assert data was migrated out of memory
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(
|
||||
singleUserStateProvider.getFake(
|
||||
userIdFromAccessToken,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
).nextMock,
|
||||
).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -216,7 +283,13 @@ describe("TokenService", () => {
|
||||
});
|
||||
|
||||
describe("Memory storage tests", () => {
|
||||
it("should get the access token from memory with no user id specified (uses global active user)", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the access token from memory for the provided user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
["should get the access token from memory with no user id provided", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -228,37 +301,28 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from memory for the specified user id", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// set disk to undefined
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disk storage tests (secure storage not supported on platform)", () => {
|
||||
it("should get the access token from disk with no user id specified", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the access token from disk for the specified user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
["should get the access token from disk with no user id specified", undefined],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -269,28 +333,14 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from disk for the specified user id", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
@@ -302,7 +352,16 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should get the access token from secure storage when no user id is specified and the migration flag is set to true", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should get the encrypted access token from disk, decrypt it, and return it when user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should get the encrypted access token from disk, decrypt it, and return it when no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -310,76 +369,35 @@ describe("TokenService", () => {
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
.stateSubject.next([userIdFromAccessToken, "encryptedAccessToken"]);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(accessTokenJwt);
|
||||
secureStorageService.get.mockResolvedValue(accessTokenKeyB64);
|
||||
encryptService.decryptToUtf8.mockResolvedValue("decryptedAccessToken");
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
|
||||
// set access token migration flag to true
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, true]);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should get the access token from secure storage when user id is specified and the migration flag set to true", async () => {
|
||||
// Arrange
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
secureStorageService.get.mockResolvedValue(accessTokenJwt);
|
||||
|
||||
// set access token migration flag to true
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, true]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
});
|
||||
|
||||
it("should fallback and get the access token from disk when user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, undefined]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// set access token migration flag to false
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, false]);
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken(userIdFromAccessToken);
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
|
||||
// assert that secure storage was not called
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
expect(result).toEqual("decryptedAccessToken");
|
||||
});
|
||||
|
||||
it("should fallback and get the access token from disk when no user id is specified and the migration flag is set to false even if the platform supports secure storage", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should fallback and get the unencrypted access token from disk when there isn't an access token key in secure storage and a user id is provided",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should fallback and get the unencrypted access token from disk when there isn't an access token key in secure storage and no user id is provided",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -390,23 +408,19 @@ describe("TokenService", () => {
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// set access token migration flag to false
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.stateSubject.next([userIdFromAccessToken, false]);
|
||||
// No access token key set
|
||||
|
||||
// Act
|
||||
const result = await tokenService.getAccessToken();
|
||||
const result = await tokenService.getAccessToken(userId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(accessTokenJwt);
|
||||
|
||||
// assert that secure storage was not called
|
||||
expect(secureStorageService.get).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -426,7 +440,16 @@ describe("TokenService", () => {
|
||||
tokenService = createTokenService(supportsSecureStorage);
|
||||
});
|
||||
|
||||
it("should clear the access token from all storage locations for the specified user id", async () => {
|
||||
test.each([
|
||||
[
|
||||
"should clear the access token from all storage locations for the provided user id",
|
||||
userIdFromAccessToken,
|
||||
],
|
||||
[
|
||||
"should clear the access token from all storage locations for the global active user",
|
||||
undefined,
|
||||
],
|
||||
])("%s", async (_, userId) => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
@@ -436,6 +459,13 @@ describe("TokenService", () => {
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
if (!userId) {
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
}
|
||||
|
||||
// Act
|
||||
await tokenService.clearAccessToken(userIdFromAccessToken);
|
||||
|
||||
@@ -448,39 +478,7 @@ describe("TokenService", () => {
|
||||
).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(secureStorageService.remove).toHaveBeenCalledWith(
|
||||
accessTokenSecureStorageKey,
|
||||
secureStorageOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it("should clear the access token from all storage locations for the global active user", async () => {
|
||||
// Arrange
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
singleUserStateProvider
|
||||
.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK)
|
||||
.stateSubject.next([userIdFromAccessToken, accessTokenJwt]);
|
||||
|
||||
// Need to have global active id set to the user id
|
||||
globalStateProvider
|
||||
.getFake(ACCOUNT_ACTIVE_ACCOUNT_ID)
|
||||
.stateSubject.next(userIdFromAccessToken);
|
||||
|
||||
// Act
|
||||
await tokenService.clearAccessToken();
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_MEMORY).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
expect(
|
||||
singleUserStateProvider.getFake(userIdFromAccessToken, ACCESS_TOKEN_DISK).nextMock,
|
||||
).toHaveBeenCalledWith(null);
|
||||
|
||||
expect(secureStorageService.remove).toHaveBeenCalledWith(
|
||||
accessTokenSecureStorageKey,
|
||||
accessTokenKeySecureStorageKey,
|
||||
secureStorageOptions,
|
||||
);
|
||||
});
|
||||
@@ -1049,6 +1047,7 @@ describe("TokenService", () => {
|
||||
refreshToken,
|
||||
VaultTimeoutAction.Lock,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("User id not found. Cannot save refresh token.");
|
||||
@@ -1912,7 +1911,7 @@ describe("TokenService", () => {
|
||||
|
||||
// Act
|
||||
// Note: passing a valid access token so that a valid user id can be determined from the access token
|
||||
await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout, [
|
||||
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken, [
|
||||
clientId,
|
||||
clientSecret,
|
||||
]);
|
||||
@@ -1959,7 +1958,7 @@ describe("TokenService", () => {
|
||||
tokenService.setClientSecret = jest.fn();
|
||||
|
||||
// Act
|
||||
await tokenService.setTokens(accessTokenJwt, refreshToken, vaultTimeoutAction, vaultTimeout);
|
||||
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken);
|
||||
|
||||
// Assert
|
||||
expect((tokenService as any)._setAccessToken).toHaveBeenCalledWith(
|
||||
@@ -1991,9 +1990,9 @@ describe("TokenService", () => {
|
||||
// Act
|
||||
const result = tokenService.setTokens(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
// Assert
|
||||
@@ -2010,32 +2009,27 @@ describe("TokenService", () => {
|
||||
// Act
|
||||
const result = tokenService.setTokens(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
refreshToken,
|
||||
);
|
||||
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("Access token and refresh token are required.");
|
||||
await expect(result).rejects.toThrow("Access token is required.");
|
||||
});
|
||||
|
||||
it("should throw an error if the refresh token is missing", async () => {
|
||||
it("should not throw an error if the refresh token is missing and it should just not set it", async () => {
|
||||
// Arrange
|
||||
const accessToken = "accessToken";
|
||||
const refreshToken: string = null;
|
||||
const vaultTimeoutAction = VaultTimeoutAction.Lock;
|
||||
const vaultTimeout = 30;
|
||||
(tokenService as any).setRefreshToken = jest.fn();
|
||||
|
||||
// Act
|
||||
const result = tokenService.setTokens(
|
||||
accessToken,
|
||||
refreshToken,
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
);
|
||||
await tokenService.setTokens(accessTokenJwt, vaultTimeoutAction, vaultTimeout, refreshToken);
|
||||
|
||||
// Assert
|
||||
await expect(result).rejects.toThrow("Access token and refresh token are required.");
|
||||
expect((tokenService as any).setRefreshToken).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2232,6 +2226,9 @@ describe("TokenService", () => {
|
||||
globalStateProvider,
|
||||
supportsSecureStorage,
|
||||
secureStorageService,
|
||||
keyGenerationService,
|
||||
encryptService,
|
||||
logService,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { Observable, combineLatest, firstValueFrom, map } from "rxjs";
|
||||
import { Opaque } from "type-fest";
|
||||
|
||||
import { decodeJwtTokenToJson } from "@bitwarden/auth/common";
|
||||
|
||||
import { VaultTimeoutAction } from "../../enums/vault-timeout-action.enum";
|
||||
import { EncryptService } from "../../platform/abstractions/encrypt.service";
|
||||
import { KeyGenerationService } from "../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { AbstractStorageService } from "../../platform/abstractions/storage.service";
|
||||
import { StorageLocation } from "../../platform/enums";
|
||||
import { EncString, EncryptedString } from "../../platform/models/domain/enc-string";
|
||||
import { StorageOptions } from "../../platform/models/domain/storage-options";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
GlobalState,
|
||||
GlobalStateProvider,
|
||||
@@ -19,7 +25,6 @@ import { ACCOUNT_ACTIVE_ACCOUNT_ID } from "./account.service";
|
||||
import {
|
||||
ACCESS_TOKEN_DISK,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
API_KEY_CLIENT_ID_MEMORY,
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
@@ -101,8 +106,14 @@ export type DecodedAccessToken = {
|
||||
jti?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A symmetric key for encrypting the access token before the token is stored on disk.
|
||||
* This key should be stored in secure storage.
|
||||
* */
|
||||
type AccessTokenKey = Opaque<SymmetricCryptoKey, "AccessTokenKey">;
|
||||
|
||||
export class TokenService implements TokenServiceAbstraction {
|
||||
private readonly accessTokenSecureStorageKey: string = "_accessToken";
|
||||
private readonly accessTokenKeySecureStorageKey: string = "_accessTokenKey";
|
||||
|
||||
private readonly refreshTokenSecureStorageKey: string = "_refreshToken";
|
||||
|
||||
@@ -117,10 +128,26 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
private globalStateProvider: GlobalStateProvider,
|
||||
private readonly platformSupportsSecureStorage: boolean,
|
||||
private secureStorageService: AbstractStorageService,
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private encryptService: EncryptService,
|
||||
private logService: LogService,
|
||||
) {
|
||||
this.initializeState();
|
||||
}
|
||||
|
||||
hasAccessToken$(userId: UserId): Observable<boolean> {
|
||||
// FIXME Once once vault timeout action is observable, we can use it to determine storage location
|
||||
// and avoid the need to check both disk and memory.
|
||||
return combineLatest([
|
||||
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).state$,
|
||||
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).state$,
|
||||
]).pipe(map(([disk, memory]) => Boolean(disk || memory)));
|
||||
}
|
||||
|
||||
// pivoting to an approach where we create a symmetric key we store in secure storage
|
||||
// which is used to protect the data before persisting to disk.
|
||||
// We will also use the same symmetric key to decrypt the data when reading from disk.
|
||||
|
||||
private initializeState(): void {
|
||||
this.emailTwoFactorTokenRecordGlobalState = this.globalStateProvider.get(
|
||||
EMAIL_TWO_FACTOR_TOKEN_RECORD_DISK_LOCAL,
|
||||
@@ -131,13 +158,13 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
|
||||
async setTokens(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
vaultTimeoutAction: VaultTimeoutAction,
|
||||
vaultTimeout: number | null,
|
||||
refreshToken?: string,
|
||||
clientIdClientSecret?: [string, string],
|
||||
): Promise<void> {
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error("Access token and refresh token are required.");
|
||||
if (!accessToken) {
|
||||
throw new Error("Access token is required.");
|
||||
}
|
||||
|
||||
// get user id the access token
|
||||
@@ -148,13 +175,95 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
}
|
||||
|
||||
await this._setAccessToken(accessToken, vaultTimeoutAction, vaultTimeout, userId);
|
||||
await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId);
|
||||
|
||||
if (refreshToken) {
|
||||
await this.setRefreshToken(refreshToken, vaultTimeoutAction, vaultTimeout, userId);
|
||||
}
|
||||
|
||||
if (clientIdClientSecret != null) {
|
||||
await this.setClientId(clientIdClientSecret[0], vaultTimeoutAction, vaultTimeout, userId);
|
||||
await this.setClientSecret(clientIdClientSecret[1], vaultTimeoutAction, vaultTimeout, userId);
|
||||
}
|
||||
}
|
||||
|
||||
private async getAccessTokenKey(userId: UserId): Promise<AccessTokenKey | null> {
|
||||
const accessTokenKeyB64 = await this.secureStorageService.get<
|
||||
ReturnType<SymmetricCryptoKey["toJSON"]>
|
||||
>(`${userId}${this.accessTokenKeySecureStorageKey}`, this.getSecureStorageOptions(userId));
|
||||
|
||||
if (!accessTokenKeyB64) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const accessTokenKey = SymmetricCryptoKey.fromJSON(accessTokenKeyB64) as AccessTokenKey;
|
||||
return accessTokenKey;
|
||||
}
|
||||
|
||||
private async createAndSaveAccessTokenKey(userId: UserId): Promise<AccessTokenKey> {
|
||||
const newAccessTokenKey = (await this.keyGenerationService.createKey(512)) as AccessTokenKey;
|
||||
|
||||
await this.secureStorageService.save<AccessTokenKey>(
|
||||
`${userId}${this.accessTokenKeySecureStorageKey}`,
|
||||
newAccessTokenKey,
|
||||
this.getSecureStorageOptions(userId),
|
||||
);
|
||||
|
||||
return newAccessTokenKey;
|
||||
}
|
||||
|
||||
private async clearAccessTokenKey(userId: UserId): Promise<void> {
|
||||
await this.secureStorageService.remove(
|
||||
`${userId}${this.accessTokenKeySecureStorageKey}`,
|
||||
this.getSecureStorageOptions(userId),
|
||||
);
|
||||
}
|
||||
|
||||
private async getOrCreateAccessTokenKey(userId: UserId): Promise<AccessTokenKey> {
|
||||
if (!this.platformSupportsSecureStorage) {
|
||||
throw new Error("Platform does not support secure storage. Cannot obtain access token key.");
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("User id not found. Cannot obtain access token key.");
|
||||
}
|
||||
|
||||
// First see if we have an accessTokenKey in secure storage and return it if we do
|
||||
let accessTokenKey: AccessTokenKey = await this.getAccessTokenKey(userId);
|
||||
|
||||
if (!accessTokenKey) {
|
||||
// Otherwise, create a new one and save it to secure storage, then return it
|
||||
accessTokenKey = await this.createAndSaveAccessTokenKey(userId);
|
||||
}
|
||||
|
||||
return accessTokenKey;
|
||||
}
|
||||
|
||||
private async encryptAccessToken(accessToken: string, userId: UserId): Promise<EncString> {
|
||||
const accessTokenKey = await this.getOrCreateAccessTokenKey(userId);
|
||||
|
||||
return await this.encryptService.encrypt(accessToken, accessTokenKey);
|
||||
}
|
||||
|
||||
private async decryptAccessToken(
|
||||
encryptedAccessToken: EncString,
|
||||
userId: UserId,
|
||||
): Promise<string | null> {
|
||||
const accessTokenKey = await this.getAccessTokenKey(userId);
|
||||
|
||||
if (!accessTokenKey) {
|
||||
// If we don't have an accessTokenKey, then that means we don't have an access token as it hasn't been set yet
|
||||
// and we have to return null here to properly indicate the the user isn't logged in.
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedAccessToken = await this.encryptService.decryptToUtf8(
|
||||
encryptedAccessToken,
|
||||
accessTokenKey,
|
||||
);
|
||||
|
||||
return decryptedAccessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper for set access token which always requires user id.
|
||||
* This is useful because setTokens always will have a user id from the access token whereas
|
||||
@@ -173,26 +282,33 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
);
|
||||
|
||||
switch (storageLocation) {
|
||||
case TokenStorageLocation.SecureStorage:
|
||||
await this.saveStringToSecureStorage(userId, this.accessTokenSecureStorageKey, accessToken);
|
||||
case TokenStorageLocation.SecureStorage: {
|
||||
// Secure storage implementations have variable length limitations (Windows), so we cannot
|
||||
// store the access token directly. Instead, we encrypt with accessTokenKey and store that
|
||||
// in secure storage.
|
||||
|
||||
const encryptedAccessToken: EncString = await this.encryptAccessToken(accessToken, userId);
|
||||
|
||||
// Save the encrypted access token to disk
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_DISK)
|
||||
.update((_) => encryptedAccessToken.encryptedString);
|
||||
|
||||
// TODO: PM-6408 - https://bitwarden.atlassian.net/browse/PM-6408
|
||||
// 2024-02-20: Remove access token from memory and disk so that we migrate to secure storage over time.
|
||||
// Remove these 2 calls to remove the access token from memory and disk after 3 releases.
|
||||
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null);
|
||||
// 2024-02-20: Remove access token from memory so that we migrate to encrypt the access token over time.
|
||||
// Remove this call to remove the access token from memory after 3 releases.
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MEMORY).update((_) => null);
|
||||
|
||||
// Set flag to indicate that the access token has been migrated to secure storage (don't remove this)
|
||||
await this.setAccessTokenMigratedToSecureStorage(userId);
|
||||
|
||||
return;
|
||||
}
|
||||
case TokenStorageLocation.Disk:
|
||||
// Access token stored on disk unencrypted as platform does not support secure storage
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_DISK)
|
||||
.update((_) => accessToken);
|
||||
return;
|
||||
case TokenStorageLocation.Memory:
|
||||
// Access token stored in memory due to vault timeout settings
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_MEMORY)
|
||||
.update((_) => accessToken);
|
||||
@@ -226,15 +342,14 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
throw new Error("User id not found. Cannot clear access token.");
|
||||
}
|
||||
|
||||
// TODO: re-eval this once we get shared key definitions for vault timeout and vault timeout action data.
|
||||
// TODO: re-eval this implementation once we get shared key definitions for vault timeout and vault timeout action data.
|
||||
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
|
||||
// but we can simply clear all locations to avoid the need to require those parameters
|
||||
// but we can simply clear all locations to avoid the need to require those parameters.
|
||||
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
await this.secureStorageService.remove(
|
||||
`${userId}${this.accessTokenSecureStorageKey}`,
|
||||
this.getSecureStorageOptions(userId),
|
||||
);
|
||||
// Always clear the access token key when clearing the access token
|
||||
// The next set of the access token will create a new access token key
|
||||
await this.clearAccessTokenKey(userId);
|
||||
}
|
||||
|
||||
// Platform doesn't support secure storage, so use state provider implementation
|
||||
@@ -249,36 +364,48 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const accessTokenMigratedToSecureStorage =
|
||||
await this.getAccessTokenMigratedToSecureStorage(userId);
|
||||
if (this.platformSupportsSecureStorage && accessTokenMigratedToSecureStorage) {
|
||||
return await this.getStringFromSecureStorage(userId, this.accessTokenSecureStorageKey);
|
||||
}
|
||||
|
||||
// Try to get the access token from memory
|
||||
const accessTokenMemory = await this.getStateValueByUserIdAndKeyDef(
|
||||
userId,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
);
|
||||
|
||||
if (accessTokenMemory != null) {
|
||||
return accessTokenMemory;
|
||||
}
|
||||
|
||||
// If memory is null, read from disk
|
||||
return await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK);
|
||||
}
|
||||
const accessTokenDisk = await this.getStateValueByUserIdAndKeyDef(userId, ACCESS_TOKEN_DISK);
|
||||
if (!accessTokenDisk) {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getAccessTokenMigratedToSecureStorage(userId: UserId): Promise<boolean> {
|
||||
return await firstValueFrom(
|
||||
this.singleUserStateProvider.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE).state$,
|
||||
);
|
||||
}
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
const accessTokenKey = await this.getAccessTokenKey(userId);
|
||||
|
||||
private async setAccessTokenMigratedToSecureStorage(userId: UserId): Promise<void> {
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE)
|
||||
.update((_) => true);
|
||||
if (!accessTokenKey) {
|
||||
// We know this is an unencrypted access token because we don't have an access token key
|
||||
return accessTokenDisk;
|
||||
}
|
||||
|
||||
try {
|
||||
const encryptedAccessTokenEncString = new EncString(accessTokenDisk as EncryptedString);
|
||||
|
||||
const decryptedAccessToken = await this.decryptAccessToken(
|
||||
encryptedAccessTokenEncString,
|
||||
userId,
|
||||
);
|
||||
return decryptedAccessToken;
|
||||
} catch (error) {
|
||||
// If an error occurs during decryption, return null for logout.
|
||||
// We don't try to recover here since we'd like to know
|
||||
// if access token and key are getting out of sync.
|
||||
this.logService.error(
|
||||
`Failed to decrypt access token: ${error?.message ?? "Unknown error."}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return accessTokenDisk;
|
||||
}
|
||||
|
||||
// Private because we only ever set the refresh token when also setting the access token
|
||||
@@ -417,7 +544,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
const storageLocation = await this.determineStorageLocation(
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
false,
|
||||
false, // don't use secure storage for client id
|
||||
);
|
||||
|
||||
if (storageLocation === TokenStorageLocation.Disk) {
|
||||
@@ -484,7 +611,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
const storageLocation = await this.determineStorageLocation(
|
||||
vaultTimeoutAction,
|
||||
vaultTimeout,
|
||||
false,
|
||||
false, // don't use secure storage for client secret
|
||||
);
|
||||
|
||||
if (storageLocation === TokenStorageLocation.Disk) {
|
||||
@@ -567,6 +694,7 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: stop accepting optional userIds
|
||||
async clearTokens(userId?: UserId): Promise<void> {
|
||||
userId ??= await firstValueFrom(this.activeUserIdGlobalState.state$);
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { KeyDefinition } from "../../platform/state";
|
||||
import {
|
||||
ACCESS_TOKEN_DISK,
|
||||
ACCESS_TOKEN_MEMORY,
|
||||
ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE,
|
||||
API_KEY_CLIENT_ID_DISK,
|
||||
API_KEY_CLIENT_ID_MEMORY,
|
||||
API_KEY_CLIENT_SECRET_DISK,
|
||||
@@ -17,7 +16,6 @@ import {
|
||||
describe.each([
|
||||
[ACCESS_TOKEN_DISK, "accessTokenDisk"],
|
||||
[ACCESS_TOKEN_MEMORY, "accessTokenMemory"],
|
||||
[ACCESS_TOKEN_MIGRATED_TO_SECURE_STORAGE, true],
|
||||
[REFRESH_TOKEN_DISK, "refreshTokenDisk"],
|
||||
[REFRESH_TOKEN_MEMORY, "refreshTokenMemory"],
|
||||
[REFRESH_TOKEN_MIGRATED_TO_SECURE_STORAGE, true],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user