mirror of
https://github.com/bitwarden/browser
synced 2026-02-17 18:09:17 +00:00
Merge branch 'main' into km/replace-encstring-with-unsigned-shared-key
This commit is contained in:
@@ -3,9 +3,7 @@ import { DeviceManagementComponentServiceAbstraction } from "./device-management
|
||||
/**
|
||||
* Default implementation of the device management component service
|
||||
*/
|
||||
export class DefaultDeviceManagementComponentService
|
||||
implements DeviceManagementComponentServiceAbstraction
|
||||
{
|
||||
export class DefaultDeviceManagementComponentService implements DeviceManagementComponentServiceAbstraction {
|
||||
/**
|
||||
* Show header information in web client
|
||||
*/
|
||||
|
||||
@@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
|
||||
import {
|
||||
Account,
|
||||
AccountInfo,
|
||||
AccountService,
|
||||
} from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
|
||||
@@ -18,6 +14,7 @@ import { KeyConnectorService } from "@bitwarden/common/key-management/key-connec
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { authGuard } from "./auth.guard";
|
||||
@@ -38,16 +35,13 @@ describe("AuthGuard", () => {
|
||||
const accountService: MockProxy<AccountService> = mock<AccountService>();
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
activeAccountSubject.next(
|
||||
Object.assign(
|
||||
{
|
||||
name: "Test User 1",
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
} as AccountInfo,
|
||||
{ id: "test-id" as UserId },
|
||||
),
|
||||
);
|
||||
activeAccountSubject.next({
|
||||
id: "test-id" as UserId,
|
||||
...mockAccountInfoWith({
|
||||
name: "Test User 1",
|
||||
email: "test@email.com",
|
||||
}),
|
||||
});
|
||||
|
||||
if (featureFlag) {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
|
||||
@@ -5,11 +5,7 @@ import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.guard.spec";
|
||||
import {
|
||||
Account,
|
||||
AccountInfo,
|
||||
AccountService,
|
||||
} from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
@@ -20,6 +16,7 @@ import { KeyConnectorDomainConfirmation } from "@bitwarden/common/key-management
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -68,16 +65,13 @@ describe("lockGuard", () => {
|
||||
const accountService: MockProxy<AccountService> = mock<AccountService>();
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>(null);
|
||||
accountService.activeAccount$ = activeAccountSubject;
|
||||
activeAccountSubject.next(
|
||||
Object.assign(
|
||||
{
|
||||
name: "Test User 1",
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
} as AccountInfo,
|
||||
{ id: "test-id" as UserId },
|
||||
),
|
||||
);
|
||||
activeAccountSubject.next({
|
||||
id: "test-id" as UserId,
|
||||
...mockAccountInfoWith({
|
||||
name: "Test User 1",
|
||||
email: "test@email.com",
|
||||
}),
|
||||
});
|
||||
|
||||
const testBed = TestBed.configureTestingModule({
|
||||
imports: [
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EmptyComponent } from "@bitwarden/angular/platform/guard/feature-flag.g
|
||||
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.guard";
|
||||
@@ -14,9 +15,10 @@ import { redirectToVaultIfUnlockedGuard } from "./redirect-to-vault-if-unlocked.
|
||||
describe("redirectToVaultIfUnlockedGuard", () => {
|
||||
const activeUser: Account = {
|
||||
id: "userId" as UserId,
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
...mockAccountInfoWith({
|
||||
email: "test@email.com",
|
||||
name: "Test User",
|
||||
}),
|
||||
};
|
||||
|
||||
const setup = (activeUser: Account | null, authStatus: AuthenticationStatus | null) => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -17,9 +18,10 @@ import { tdeDecryptionRequiredGuard } from "./tde-decryption-required.guard";
|
||||
describe("tdeDecryptionRequiredGuard", () => {
|
||||
const activeUser: Account = {
|
||||
id: "fake_user_id" as UserId,
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
...mockAccountInfoWith({
|
||||
email: "test@email.com",
|
||||
name: "Test User",
|
||||
}),
|
||||
};
|
||||
|
||||
const setup = (
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
@@ -18,9 +19,10 @@ import { unauthGuardFn } from "./unauth.guard";
|
||||
describe("UnauthGuard", () => {
|
||||
const activeUser: Account = {
|
||||
id: "fake_user_id" as UserId,
|
||||
email: "test@email.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
...mockAccountInfoWith({
|
||||
email: "test@email.com",
|
||||
name: "Test User",
|
||||
}),
|
||||
};
|
||||
|
||||
const setup = (
|
||||
|
||||
@@ -3,9 +3,7 @@ import { LoginApprovalDialogComponentServiceAbstraction } from "./login-approval
|
||||
/**
|
||||
* Default implementation of the LoginApprovalDialogComponentServiceAbstraction.
|
||||
*/
|
||||
export class DefaultLoginApprovalDialogComponentService
|
||||
implements LoginApprovalDialogComponentServiceAbstraction
|
||||
{
|
||||
export class DefaultLoginApprovalDialogComponentService implements LoginApprovalDialogComponentServiceAbstraction {
|
||||
/**
|
||||
* No-op implementation of the showLoginRequestedAlertIfWindowNotVisible method.
|
||||
* @returns
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DevicesServiceAbstraction } from "@bitwarden/common/auth/abstractions/d
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogRef, DIALOG_DATA, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
@@ -48,10 +49,11 @@ describe("LoginApprovalDialogComponent", () => {
|
||||
validationService = mock<ValidationService>();
|
||||
|
||||
accountService.activeAccount$ = of({
|
||||
email: testEmail,
|
||||
id: "test-user-id" as UserId,
|
||||
emailVerified: true,
|
||||
name: null,
|
||||
...mockAccountInfoWith({
|
||||
email: testEmail,
|
||||
name: null,
|
||||
}),
|
||||
});
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
|
||||
@@ -26,9 +27,11 @@ describe("DefaultChangePasswordService", () => {
|
||||
|
||||
const user: Account = {
|
||||
id: userId,
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
name: "name",
|
||||
...mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
emailVerified: false,
|
||||
}),
|
||||
};
|
||||
|
||||
const passwordInputResult: PasswordInputResult = {
|
||||
|
||||
@@ -14,10 +14,11 @@ import { BadgeModule } from "@bitwarden/components";
|
||||
type="button"
|
||||
*appNotPremium
|
||||
bitBadge
|
||||
variant="success"
|
||||
[variant]="'primary'"
|
||||
class="!tw-text-primary-600 !tw-border-primary-600"
|
||||
(click)="promptForPremium($event)"
|
||||
>
|
||||
{{ "premium" | i18n }}
|
||||
<i class="bwi bwi-premium tw-pe-1"></i>{{ "upgrade" | i18n }}
|
||||
</button>
|
||||
`,
|
||||
imports: [BadgeModule, JslibModule],
|
||||
|
||||
@@ -29,7 +29,7 @@ export default {
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
premium: "Premium",
|
||||
upgrade: "Upgrade",
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<bit-callout [icon]="icon" [title]="title" [type]="$any(type)" [useAlertRole]="useAlertRole">
|
||||
<div class="tw-pl-7 tw-m-0" *ngIf="enforcedPolicyOptions">
|
||||
{{ enforcedPolicyMessage }}
|
||||
<ul>
|
||||
<li *ngIf="enforcedPolicyOptions?.minComplexity > 0">
|
||||
{{ "policyInEffectMinComplexity" | i18n: getPasswordScoreAlertDisplay() }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.minLength > 0">
|
||||
{{ "policyInEffectMinLength" | i18n: enforcedPolicyOptions?.minLength.toString() }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.requireUpper">
|
||||
{{ "policyInEffectUppercase" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.requireLower">
|
||||
{{ "policyInEffectLowercase" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.requireNumbers">
|
||||
{{ "policyInEffectNumbers" | i18n }}
|
||||
</li>
|
||||
<li *ngIf="enforcedPolicyOptions?.requireSpecial">
|
||||
{{ "policyInEffectSpecial" | i18n: "!@#$%^&*" }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ng-content></ng-content>
|
||||
</bit-callout>
|
||||
@@ -1,70 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
|
||||
import { MasterPasswordPolicyOptions } from "@bitwarden/common/admin-console/models/domain/master-password-policy-options";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { CalloutTypes } from "@bitwarden/components";
|
||||
|
||||
/**
|
||||
* @deprecated use the CL's `CalloutComponent` instead
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-callout",
|
||||
templateUrl: "callout.component.html",
|
||||
standalone: false,
|
||||
})
|
||||
export class DeprecatedCalloutComponent implements OnInit {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() type: CalloutTypes = "info";
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() icon: string;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() title: string;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() enforcedPolicyOptions: MasterPasswordPolicyOptions;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() enforcedPolicyMessage: string;
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-signals
|
||||
@Input() useAlertRole = false;
|
||||
|
||||
calloutStyle: string;
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.calloutStyle = this.type;
|
||||
|
||||
if (this.enforcedPolicyMessage === undefined) {
|
||||
this.enforcedPolicyMessage = this.i18nService.t("masterPasswordPolicyInEffect");
|
||||
}
|
||||
}
|
||||
|
||||
getPasswordScoreAlertDisplay() {
|
||||
if (this.enforcedPolicyOptions == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let str: string;
|
||||
switch (this.enforcedPolicyOptions.minComplexity) {
|
||||
case 4:
|
||||
str = this.i18nService.t("strong");
|
||||
break;
|
||||
case 3:
|
||||
str = this.i18nService.t("good");
|
||||
break;
|
||||
default:
|
||||
str = this.i18nService.t("weak");
|
||||
break;
|
||||
}
|
||||
return str + " (" + this.enforcedPolicyOptions.minComplexity + ")";
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
|
||||
import { TwoFactorIconComponent } from "./auth/components/two-factor-icon.component";
|
||||
import { NotPremiumDirective } from "./billing/directives/not-premium.directive";
|
||||
import { DeprecatedCalloutComponent } from "./components/callout.component";
|
||||
import { A11yInvalidDirective } from "./directives/a11y-invalid.directive";
|
||||
import { ApiActionDirective } from "./directives/api-action.directive";
|
||||
import { BoxRowDirective } from "./directives/box-row.directive";
|
||||
@@ -86,7 +85,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
A11yInvalidDirective,
|
||||
ApiActionDirective,
|
||||
BoxRowDirective,
|
||||
DeprecatedCalloutComponent,
|
||||
CopyTextDirective,
|
||||
CreditCardNumberPipe,
|
||||
EllipsisPipe,
|
||||
@@ -115,7 +113,6 @@ import { IconComponent } from "./vault/components/icon.component";
|
||||
AutofocusDirective,
|
||||
ToastModule,
|
||||
BoxRowDirective,
|
||||
DeprecatedCalloutComponent,
|
||||
CopyTextDirective,
|
||||
CreditCardNumberPipe,
|
||||
EllipsisPipe,
|
||||
|
||||
@@ -2,14 +2,13 @@ import { Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { AccountInfo } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { FakeAccountService } from "@bitwarden/common/spec";
|
||||
import { mockAccountInfoWith, FakeAccountService } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
@@ -22,17 +21,15 @@ import { PromptMigrationPasswordComponent } from "./prompt-migration-password.co
|
||||
|
||||
const SomeUser = "SomeUser" as UserId;
|
||||
const AnotherUser = "SomeOtherUser" as UserId;
|
||||
const accounts: Record<UserId, AccountInfo> = {
|
||||
[SomeUser]: {
|
||||
const accounts = {
|
||||
[SomeUser]: mockAccountInfoWith({
|
||||
name: "some user",
|
||||
email: "some.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
[AnotherUser]: {
|
||||
}),
|
||||
[AnotherUser]: mockAccountInfoWith({
|
||||
name: "some other user",
|
||||
email: "some.other.user@example.com",
|
||||
emailVerified: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
describe("DefaultEncryptedMigrationsSchedulerService", () => {
|
||||
|
||||
@@ -38,16 +38,14 @@ export const ENCRYPTED_MIGRATION_DISMISSED = new UserKeyDefinition<Date>(
|
||||
},
|
||||
);
|
||||
const DISMISS_TIME_HOURS = 24;
|
||||
const VAULT_ROUTE = "/vault";
|
||||
const VAULT_ROUTES = ["/vault", "/tabs/vault", "/tabs/current"];
|
||||
|
||||
/**
|
||||
* This services schedules encrypted migrations for users on clients that are interactive (non-cli), and handles manual interaction,
|
||||
* if it is required by showing a UI prompt. It is only one means of triggering migrations, in case the user stays unlocked for a while,
|
||||
* or regularly logs in without a master-password, when the migrations do require a master-password to run.
|
||||
*/
|
||||
export class DefaultEncryptedMigrationsSchedulerService
|
||||
implements EncryptedMigrationsSchedulerService
|
||||
{
|
||||
export class DefaultEncryptedMigrationsSchedulerService implements EncryptedMigrationsSchedulerService {
|
||||
isMigrating = false;
|
||||
url$: Observable<string>;
|
||||
|
||||
@@ -87,7 +85,7 @@ export class DefaultEncryptedMigrationsSchedulerService
|
||||
]).pipe(
|
||||
filter(
|
||||
([authStatus, _date, url]) =>
|
||||
authStatus === AuthenticationStatus.Unlocked && url === VAULT_ROUTE,
|
||||
authStatus === AuthenticationStatus.Unlocked && VAULT_ROUTES.includes(url),
|
||||
),
|
||||
concatMap(() => this.runMigrationsIfNeeded(userId)),
|
||||
),
|
||||
|
||||
@@ -184,7 +184,9 @@ import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf
|
||||
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
|
||||
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service";
|
||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf.service.abstraction";
|
||||
import { KeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector-api.service";
|
||||
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { DefaultKeyConnectorApiService } from "@bitwarden/common/key-management/key-connector/services/default-key-connector-api.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
|
||||
import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction";
|
||||
import { RotateableKeySetService } from "@bitwarden/common/key-management/keys/services/abstractions/rotateable-key-set.service";
|
||||
@@ -950,7 +952,7 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [
|
||||
FolderServiceAbstraction,
|
||||
CipherServiceAbstraction,
|
||||
PinServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
@@ -970,7 +972,7 @@ const safeProviders: SafeProvider[] = [
|
||||
deps: [
|
||||
CipherServiceAbstraction,
|
||||
VaultExportApiService,
|
||||
PinServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
CryptoFunctionServiceAbstraction,
|
||||
@@ -1355,16 +1357,7 @@ const safeProviders: SafeProvider[] = [
|
||||
safeProvider({
|
||||
provide: PinServiceAbstraction,
|
||||
useClass: PinService,
|
||||
deps: [
|
||||
AccountServiceAbstraction,
|
||||
EncryptService,
|
||||
KdfConfigService,
|
||||
KeyGenerationService,
|
||||
LogService,
|
||||
KeyService,
|
||||
SdkService,
|
||||
PinStateServiceAbstraction,
|
||||
],
|
||||
deps: [EncryptService, LogService, KeyService, SdkService, PinStateServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: WebAuthnLoginPrfKeyServiceAbstraction,
|
||||
@@ -1835,6 +1828,11 @@ const safeProviders: SafeProvider[] = [
|
||||
useClass: IpcSessionRepository,
|
||||
deps: [StateProvider],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: KeyConnectorApiService,
|
||||
useClass: DefaultKeyConnectorApiService,
|
||||
deps: [ApiServiceAbstraction],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: PremiumInterestStateService,
|
||||
useClass: NoopPremiumInterestStateService,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<!-- Applying width and height styles directly to synchronize icon sizing between web/browser/desktop -->
|
||||
<div
|
||||
class="tw-flex tw-justify-center tw-items-center"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div class="tw-flex tw-justify-center tw-items-center" [ngStyle]="iconStyle()" aria-hidden="true">
|
||||
<ng-container *ngIf="data$ | async as data">
|
||||
@if (data.imageEnabled && data.image) {
|
||||
<img
|
||||
@@ -16,7 +12,7 @@
|
||||
'tw-invisible tw-absolute': !imageLoaded(),
|
||||
'tw-size-6': !coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
[ngStyle]="iconStyle()"
|
||||
(load)="imageLoaded.set(true)"
|
||||
(error)="imageLoaded.set(false)"
|
||||
/>
|
||||
@@ -28,7 +24,7 @@
|
||||
'tw-bg-illustration-bg-primary tw-rounded-full':
|
||||
data.icon?.startsWith('bwi-') && coloredIcon(),
|
||||
}"
|
||||
[ngStyle]="coloredIcon() ? { width: '36px', height: '36px' } : {}"
|
||||
[ngStyle]="iconStyle()"
|
||||
>
|
||||
<i
|
||||
class="tw-text-muted bwi bwi-lg {{ data.icon }}"
|
||||
@@ -36,6 +32,7 @@
|
||||
color: coloredIcon() ? 'rgb(var(--color-illustration-outline))' : null,
|
||||
width: data.icon?.startsWith('credit-card') && coloredIcon() ? '36px' : null,
|
||||
height: data.icon?.startsWith('credit-card') && coloredIcon() ? '30px' : null,
|
||||
fontSize: size() ? size() + 'px' : null,
|
||||
}"
|
||||
></i>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, computed, input, signal } from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
combineLatest,
|
||||
@@ -32,8 +32,32 @@ export class IconComponent {
|
||||
*/
|
||||
readonly coloredIcon = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Optional custom size for the icon in pixels.
|
||||
* When provided, forces explicit dimensions on the icon wrapper to prevent layout collapse at different zoom levels.
|
||||
* If not provided, the wrapper has no explicit dimensions and relies on CSS classes (tw-size-6/24px for images).
|
||||
* This can cause the wrapper to collapse when images are loading/hidden, especially at high browser zoom levels.
|
||||
* Reference: default image size is tw-size-6 (24px), coloredIcon uses 36px.
|
||||
*/
|
||||
readonly size = input<number>();
|
||||
|
||||
readonly imageLoaded = signal(false);
|
||||
|
||||
/**
|
||||
* Computed style object for icon dimensions.
|
||||
* Centralizes the sizing logic to avoid repetition in the template.
|
||||
*/
|
||||
protected readonly iconStyle = computed(() => {
|
||||
if (this.coloredIcon()) {
|
||||
return { width: "36px", height: "36px" };
|
||||
}
|
||||
const size = this.size();
|
||||
if (size) {
|
||||
return { width: size + "px", height: size + "px" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
protected data$: Observable<CipherIconDetails>;
|
||||
|
||||
constructor(
|
||||
|
||||
@@ -33,19 +33,27 @@ export class DefaultLoginComponentService implements LoginComponentService {
|
||||
*/
|
||||
async redirectToSsoLogin(email: string): Promise<void | null> {
|
||||
// Set the state that we'll need to verify the SSO login when we get the code back
|
||||
const [state, codeChallenge] = await this.setSsoPreLoginState();
|
||||
|
||||
// Set the email address in state. This is used in 2 places:
|
||||
// 1. On the web client, on the SSO component we need the email address to look up
|
||||
// the org SSO identifier. The email address is passed via query param for the other clients.
|
||||
// 2. On all clients, after authentication on the originating client the SSO component
|
||||
// will need to look up 2FA Remember token by email.
|
||||
await this.ssoLoginService.setSsoEmail(email);
|
||||
const [state, codeChallenge] = await this.setSsoPreLoginState(email);
|
||||
|
||||
// Finally, we redirect to the SSO login page. This will be handled by each client implementation of this service.
|
||||
await this.redirectToSso(email, state, codeChallenge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the user to the SSO login page, either via route or in a new browser window.
|
||||
* @param email The email address of the user attempting to log in
|
||||
*/
|
||||
async redirectToSsoLoginWithOrganizationSsoIdentifier(
|
||||
email: string,
|
||||
orgSsoIdentifier: string,
|
||||
): Promise<void | null> {
|
||||
// Set the state that we'll need to verify the SSO login when we get the code back
|
||||
const [state, codeChallenge] = await this.setSsoPreLoginState(email);
|
||||
|
||||
// Finally, we redirect to the SSO login page. This will be handled by each client implementation of this service.
|
||||
await this.redirectToSso(email, state, codeChallenge, orgSsoIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* No-op implementation of redirectToSso
|
||||
*/
|
||||
@@ -53,6 +61,7 @@ export class DefaultLoginComponentService implements LoginComponentService {
|
||||
email: string,
|
||||
state: string,
|
||||
codeChallenge: string,
|
||||
orgSsoIdentifier?: string,
|
||||
): Promise<void> {
|
||||
return;
|
||||
}
|
||||
@@ -65,9 +74,9 @@ export class DefaultLoginComponentService implements LoginComponentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the state required for verifying SSO login after completion
|
||||
* Set the state that we'll need to verify the SSO login when we get the authorization code back
|
||||
*/
|
||||
private async setSsoPreLoginState(): Promise<[string, string]> {
|
||||
private async setSsoPreLoginState(email: string): Promise<[string, string]> {
|
||||
// Generate SSO params
|
||||
const passwordOptions: any = {
|
||||
type: "password",
|
||||
@@ -93,6 +102,13 @@ export class DefaultLoginComponentService implements LoginComponentService {
|
||||
await this.ssoLoginService.setSsoState(state);
|
||||
await this.ssoLoginService.setCodeVerifier(codeVerifier);
|
||||
|
||||
// Set the email address in state. This is used in 2 places:
|
||||
// 1. On the web client, on the SSO component we need the email address to look up
|
||||
// the org SSO identifier. The email address is passed via query param for the other clients.
|
||||
// 2. On all clients, after authentication on the originating client the SSO component
|
||||
// will need to look up 2FA Remember token by email.
|
||||
await this.ssoLoginService.setSsoEmail(email);
|
||||
|
||||
return [state, codeChallenge];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ export abstract class LoginComponentService {
|
||||
*/
|
||||
redirectToSsoLogin: (email: string) => Promise<void | null>;
|
||||
|
||||
/**
|
||||
* Redirects the user to the SSO login page with organization SSO identifier, either via route or in a new browser window.
|
||||
*/
|
||||
redirectToSsoLoginWithOrganizationSsoIdentifier: (
|
||||
email: string,
|
||||
orgSsoIdentifier: string | null | undefined,
|
||||
) => Promise<void | null>;
|
||||
|
||||
/**
|
||||
* Shows the back button.
|
||||
*/
|
||||
|
||||
@@ -381,6 +381,24 @@ export class LoginComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// redirect to SSO if ssoOrganizationIdentifier is present in token response
|
||||
if (authResult.requiresSso) {
|
||||
const email = this.formGroup?.value?.email;
|
||||
if (!email) {
|
||||
this.toastService.showToast({
|
||||
variant: "error",
|
||||
title: this.i18nService.t("errorOccurred"),
|
||||
message: this.i18nService.t("emailRequiredForSsoLogin"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.loginComponentService.redirectToSsoLoginWithOrganizationSsoIdentifier(
|
||||
email,
|
||||
authResult.ssoOrganizationIdentifier,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// User logged in successfully so execute side effects
|
||||
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NewDeviceVerificationComponentService } from "./new-device-verification-component.service";
|
||||
|
||||
export class DefaultNewDeviceVerificationComponentService
|
||||
implements NewDeviceVerificationComponentService
|
||||
{
|
||||
export class DefaultNewDeviceVerificationComponentService implements NewDeviceVerificationComponentService {
|
||||
showBackButton() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { TwoFactorAuthWebAuthnComponentService } from "./two-factor-auth-webauthn-component.service";
|
||||
|
||||
export class DefaultTwoFactorAuthWebAuthnComponentService
|
||||
implements TwoFactorAuthWebAuthnComponentService
|
||||
{
|
||||
export class DefaultTwoFactorAuthWebAuthnComponentService implements TwoFactorAuthWebAuthnComponentService {
|
||||
/**
|
||||
* Default implementation is to not open in a new tab.
|
||||
*/
|
||||
|
||||
@@ -421,6 +421,7 @@ describe("TwoFactorAuthComponent", () => {
|
||||
keyConnectorUrl:
|
||||
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector.keyConnectorOption!
|
||||
.keyConnectorUrl,
|
||||
organizationSsoIdentifier: "test-sso-id",
|
||||
}),
|
||||
);
|
||||
const authResult = new AuthResult();
|
||||
|
||||
@@ -13,6 +13,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
|
||||
import { UserApiTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/user-api-token.request";
|
||||
import { WebAuthnLoginTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/webauthn-login-token.request";
|
||||
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
|
||||
import { IdentitySsoRequiredResponse } from "@bitwarden/common/auth/models/response/identity-sso-required.response";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
@@ -49,7 +50,8 @@ import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
type IdentityResponse =
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityDeviceVerificationResponse;
|
||||
| IdentityDeviceVerificationResponse
|
||||
| IdentitySsoRequiredResponse;
|
||||
|
||||
export abstract class LoginStrategyData {
|
||||
tokenRequest:
|
||||
@@ -128,6 +130,8 @@ export abstract class LoginStrategy {
|
||||
return [await this.processTokenResponse(response), response];
|
||||
} else if (response instanceof IdentityDeviceVerificationResponse) {
|
||||
return [await this.processDeviceVerificationResponse(response), response];
|
||||
} else if (response instanceof IdentitySsoRequiredResponse) {
|
||||
return [await this.processSsoRequiredResponse(response), response];
|
||||
}
|
||||
|
||||
throw new Error("Invalid response object.");
|
||||
@@ -185,6 +189,7 @@ export abstract class LoginStrategy {
|
||||
name: accountInformation.name,
|
||||
email: accountInformation.email ?? "",
|
||||
emailVerified: accountInformation.email_verified ?? false,
|
||||
creationDate: undefined, // We don't get a creation date in the token. See https://bitwarden.atlassian.net/browse/PM-29551 for consolidation plans.
|
||||
});
|
||||
|
||||
// User env must be seeded from currently set env before switching to the account
|
||||
@@ -398,4 +403,19 @@ export abstract class LoginStrategy {
|
||||
result.requiresDeviceVerification = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the response from the server when a SSO Authentication is required.
|
||||
* It hydrates the AuthResult with the SSO organization identifier.
|
||||
*
|
||||
* @param {IdentitySsoRequiredResponse} response - The response from the server indicating that SSO is required.
|
||||
* @returns {Promise<AuthResult>} - A promise that resolves to an AuthResult object
|
||||
*/
|
||||
protected async processSsoRequiredResponse(
|
||||
response: IdentitySsoRequiredResponse,
|
||||
): Promise<AuthResult> {
|
||||
const result = new AuthResult();
|
||||
result.ssoOrganizationIdentifier = response.ssoOrganizationIdentifier;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/for
|
||||
import { PasswordTokenRequest } from "@bitwarden/common/auth/models/request/identity-token/password-token.request";
|
||||
import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/identity-token/token-two-factor.request";
|
||||
import { IdentityDeviceVerificationResponse } from "@bitwarden/common/auth/models/response/identity-device-verification.response";
|
||||
import { IdentitySsoRequiredResponse } from "@bitwarden/common/auth/models/response/identity-sso-required.response";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
@@ -165,14 +166,20 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
identityResponse:
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityDeviceVerificationResponse,
|
||||
| IdentityDeviceVerificationResponse
|
||||
| IdentitySsoRequiredResponse,
|
||||
credentials: PasswordLoginCredentials,
|
||||
authResult: AuthResult,
|
||||
): Promise<void> {
|
||||
// TODO: PM-21084 - investigate if we should be sending down masterPasswordPolicy on the
|
||||
// IdentityDeviceVerificationResponse like we do for the IdentityTwoFactorResponse
|
||||
// If the response is a device verification response, we don't need to evaluate the password
|
||||
if (identityResponse instanceof IdentityDeviceVerificationResponse) {
|
||||
// If SSO is required, we also do not evaluate the password here, since the user needs to first
|
||||
// authenticate with their SSO IdP Provider
|
||||
if (
|
||||
identityResponse instanceof IdentityDeviceVerificationResponse ||
|
||||
identityResponse instanceof IdentitySsoRequiredResponse
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-
|
||||
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
|
||||
import { mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { mockAccountServiceWith, mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
@@ -79,17 +79,21 @@ describe("DefaultLockService", () => {
|
||||
);
|
||||
|
||||
it("locks the active account last", async () => {
|
||||
await accountService.addAccount(mockUser2, {
|
||||
name: "name2",
|
||||
email: "email2@example.com",
|
||||
emailVerified: false,
|
||||
});
|
||||
await accountService.addAccount(
|
||||
mockUser2,
|
||||
mockAccountInfoWith({
|
||||
name: "name2",
|
||||
email: "email2@example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
await accountService.addAccount(mockUser3, {
|
||||
name: "name3",
|
||||
email: "name3@example.com",
|
||||
emailVerified: false,
|
||||
});
|
||||
await accountService.addAccount(
|
||||
mockUser3,
|
||||
mockAccountInfoWith({
|
||||
name: "name3",
|
||||
email: "name3@example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
const lockSpy = jest.spyOn(sut, "lock").mockResolvedValue(undefined);
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ export class SsoUrlService {
|
||||
* @param webAppUrl The URL of the web app
|
||||
* @param clientType The client type that is initiating SSO, which will drive how the response is handled
|
||||
* @param redirectUri The redirect URI or callback that will receive the SSO code after authentication
|
||||
* @param state A state value that will be peristed through the SSO flow
|
||||
* @param state A state value that will be persisted through the SSO flow
|
||||
* @param codeChallenge A challenge value that will be used to verify the SSO code after authentication
|
||||
* @param email The optional email adddress of the user initiating SSO, which will be used to look up the org SSO identifier
|
||||
* @param email The optional email address of the user initiating SSO, which will be used to look up the org SSO identifier
|
||||
* @param orgSsoIdentifier The optional SSO identifier of the org that is initiating SSO
|
||||
* @returns The URL for redirecting users to the web app SSO component
|
||||
*/
|
||||
|
||||
@@ -19,9 +19,7 @@ export const USER_DECRYPTION_OPTIONS = new UserKeyDefinition<UserDecryptionOptio
|
||||
},
|
||||
);
|
||||
|
||||
export class UserDecryptionOptionsService
|
||||
implements InternalUserDecryptionOptionsServiceAbstraction
|
||||
{
|
||||
export class UserDecryptionOptionsService implements InternalUserDecryptionOptionsServiceAbstraction {
|
||||
constructor(private singleUserStateProvider: SingleUserStateProvider) {}
|
||||
|
||||
userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions> {
|
||||
|
||||
@@ -6,19 +6,26 @@ import { ReplaySubject, combineLatest, map, Observable } from "rxjs";
|
||||
import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
|
||||
import { UserId } from "../src/types/guid";
|
||||
|
||||
/**
|
||||
* Creates a mock AccountInfo object with sensible defaults that can be overridden.
|
||||
* Use this when you need just an AccountInfo object in tests.
|
||||
*/
|
||||
export function mockAccountInfoWith(info: Partial<AccountInfo> = {}): AccountInfo {
|
||||
return {
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: true,
|
||||
creationDate: "2024-01-01T00:00:00.000Z",
|
||||
...info,
|
||||
};
|
||||
}
|
||||
|
||||
export function mockAccountServiceWith(
|
||||
userId: UserId,
|
||||
info: Partial<AccountInfo> = {},
|
||||
activity: Record<UserId, Date> = {},
|
||||
): FakeAccountService {
|
||||
const fullInfo: AccountInfo = {
|
||||
...info,
|
||||
...{
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: true,
|
||||
},
|
||||
};
|
||||
const fullInfo = mockAccountInfoWith(info);
|
||||
|
||||
const fullActivity = { [userId]: new Date(), ...activity };
|
||||
|
||||
@@ -104,6 +111,10 @@ export class FakeAccountService implements AccountService {
|
||||
await this.mock.setAccountEmailVerified(userId, emailVerified);
|
||||
}
|
||||
|
||||
async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> {
|
||||
await this.mock.setAccountCreationDate(userId, creationDate);
|
||||
}
|
||||
|
||||
async switchAccount(userId: UserId): Promise<void> {
|
||||
const next =
|
||||
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
|
||||
@@ -127,4 +138,5 @@ const loggedOutInfo: AccountInfo = {
|
||||
name: undefined,
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
creationDate: undefined,
|
||||
};
|
||||
|
||||
@@ -50,6 +50,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ
|
||||
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
|
||||
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
|
||||
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
|
||||
import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response";
|
||||
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
|
||||
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
|
||||
@@ -140,7 +141,10 @@ export abstract class ApiService {
|
||||
| UserApiTokenRequest
|
||||
| WebAuthnLoginTokenRequest,
|
||||
): Promise<
|
||||
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityDeviceVerificationResponse
|
||||
| IdentitySsoRequiredResponse
|
||||
>;
|
||||
abstract refreshIdentityToken(userId?: UserId): Promise<any>;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export class PolicyData {
|
||||
type: PolicyType;
|
||||
data: Record<string, string | number | boolean>;
|
||||
enabled: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response?: PolicyResponse) {
|
||||
if (response == null) {
|
||||
@@ -22,6 +23,7 @@ export class PolicyData {
|
||||
this.type = response.type;
|
||||
this.data = response.data;
|
||||
this.enabled = response.enabled;
|
||||
this.revisionDate = response.revisionDate;
|
||||
}
|
||||
|
||||
static fromPolicy(policy: Policy): PolicyData {
|
||||
|
||||
@@ -19,6 +19,8 @@ export class Policy extends Domain {
|
||||
*/
|
||||
enabled: boolean;
|
||||
|
||||
revisionDate: Date;
|
||||
|
||||
constructor(obj?: PolicyData) {
|
||||
super();
|
||||
if (obj == null) {
|
||||
@@ -30,6 +32,7 @@ export class Policy extends Domain {
|
||||
this.type = obj.type;
|
||||
this.data = obj.data;
|
||||
this.enabled = obj.enabled;
|
||||
this.revisionDate = new Date(obj.revisionDate);
|
||||
}
|
||||
|
||||
static fromResponse(response: PolicyResponse): Policy {
|
||||
|
||||
@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
|
||||
data: any;
|
||||
enabled: boolean;
|
||||
canToggleState: boolean;
|
||||
revisionDate: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
|
||||
this.data = this.getResponseProperty("Data");
|
||||
this.enabled = this.getResponseProperty("Enabled");
|
||||
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,7 @@ function buildKeyDefinition<T>(key: string): UserKeyDefinition<T> {
|
||||
|
||||
export const AUTO_CONFIRM_FINGERPRINTS = buildKeyDefinition<boolean>("autoConfirmFingerPrints");
|
||||
|
||||
export class DefaultOrganizationManagementPreferencesService
|
||||
implements OrganizationManagementPreferencesService
|
||||
{
|
||||
export class DefaultOrganizationManagementPreferencesService implements OrganizationManagementPreferencesService {
|
||||
constructor(private stateProvider: StateProvider) {}
|
||||
|
||||
autoConfirmFingerPrints = this.buildOrganizationManagementPreference(
|
||||
|
||||
@@ -83,12 +83,15 @@ describe("PolicyService", () => {
|
||||
type: PolicyType.MaximumVaultTimeout,
|
||||
enabled: true,
|
||||
data: { minutes: 14 },
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "99",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -113,6 +116,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -242,6 +247,8 @@ describe("PolicyService", () => {
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -331,24 +338,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -371,24 +386,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: false,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -411,24 +434,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org2",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -451,24 +482,32 @@ describe("PolicyService", () => {
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org3",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
data: undefined,
|
||||
revisionDate: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -788,6 +827,7 @@ describe("PolicyService", () => {
|
||||
policyData.type = type;
|
||||
policyData.enabled = enabled;
|
||||
policyData.data = data;
|
||||
policyData.revisionDate = new Date().toISOString();
|
||||
|
||||
return policyData;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,11 @@ import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
|
||||
/**
|
||||
* Holds information about an account for use in the AccountService
|
||||
* if more information is added, be sure to update the equality method.
|
||||
*/
|
||||
export type AccountInfo = {
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
name: string | undefined;
|
||||
creationDate: string | undefined;
|
||||
};
|
||||
|
||||
export type Account = { id: UserId } & AccountInfo;
|
||||
@@ -75,6 +72,12 @@ export abstract class AccountService {
|
||||
* @param emailVerified
|
||||
*/
|
||||
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
|
||||
/**
|
||||
* updates the `accounts$` observable with the creation date for the account.
|
||||
* @param userId
|
||||
* @param creationDate
|
||||
*/
|
||||
abstract setAccountCreationDate(userId: UserId, creationDate: string): Promise<void>;
|
||||
/**
|
||||
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
|
||||
* @param userId
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
|
||||
|
||||
@@ -18,10 +20,16 @@ export class AuthResult {
|
||||
email: string;
|
||||
requiresEncryptionKeyMigration: boolean;
|
||||
requiresDeviceVerification: boolean;
|
||||
ssoOrganizationIdentifier?: string | null;
|
||||
// The master-password used in the authentication process
|
||||
masterPassword: string | null;
|
||||
|
||||
get requiresTwoFactor() {
|
||||
return this.twoFactorProviders != null;
|
||||
}
|
||||
|
||||
// This is not as extensible as an object-based approach. In the future we may need to adjust to an object based approach.
|
||||
get requiresSso() {
|
||||
return !Utils.isNullOrWhitespace(this.ssoOrganizationIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
|
||||
export class IdentitySsoRequiredResponse extends BaseResponse {
|
||||
ssoOrganizationIdentifier: string | null;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.ssoOrganizationIdentifier = this.getResponseProperty("SsoOrganizationIdentifier");
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
|
||||
import { FakeGlobalState } from "../../../spec/fake-state";
|
||||
import {
|
||||
FakeGlobalStateProvider,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
} from "./account.service";
|
||||
|
||||
describe("accountInfoEqual", () => {
|
||||
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
|
||||
const accountInfo = mockAccountInfoWith();
|
||||
|
||||
it("compares nulls", () => {
|
||||
expect(accountInfoEqual(null, null)).toBe(true);
|
||||
@@ -64,6 +65,23 @@ describe("accountInfoEqual", () => {
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares creationDate", () => {
|
||||
const same = { ...accountInfo };
|
||||
const different = { ...accountInfo, creationDate: "2024-12-31T00:00:00.000Z" };
|
||||
|
||||
expect(accountInfoEqual(accountInfo, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountInfo, different)).toBe(false);
|
||||
});
|
||||
|
||||
it("compares undefined creationDate", () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
|
||||
const same = { ...accountWithoutCreationDate };
|
||||
const different = { ...accountWithoutCreationDate, creationDate: "2024-01-01T00:00:00.000Z" };
|
||||
|
||||
expect(accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
|
||||
expect(accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("accountService", () => {
|
||||
@@ -76,7 +94,10 @@ describe("accountService", () => {
|
||||
let activeAccountIdState: FakeGlobalState<UserId>;
|
||||
let accountActivityState: FakeGlobalState<Record<UserId, Date>>;
|
||||
const userId = Utils.newGuid() as UserId;
|
||||
const userInfo = { email: "email", name: "name", emailVerified: true };
|
||||
const userInfo = mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
messagingService = mock();
|
||||
@@ -253,6 +274,56 @@ describe("accountService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setCreationDate", () => {
|
||||
const initialState = { [userId]: userInfo };
|
||||
beforeEach(() => {
|
||||
accountsState.stateSubject.next(initialState);
|
||||
});
|
||||
|
||||
it("should update the account with a new creation date", async () => {
|
||||
const newCreationDate = "2024-12-31T00:00:00.000Z";
|
||||
await sut.setAccountCreationDate(userId, newCreationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...userInfo, creationDate: newCreationDate },
|
||||
});
|
||||
});
|
||||
|
||||
it("should not update if the creation date is the same", async () => {
|
||||
await sut.setAccountCreationDate(userId, userInfo.creationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual(initialState);
|
||||
});
|
||||
|
||||
it("should update from undefined to a defined creation date", async () => {
|
||||
const accountWithoutCreationDate = mockAccountInfoWith({
|
||||
...userInfo,
|
||||
creationDate: undefined,
|
||||
});
|
||||
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
|
||||
|
||||
const newCreationDate = "2024-06-15T12:30:00.000Z";
|
||||
await sut.setAccountCreationDate(userId, newCreationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...accountWithoutCreationDate, creationDate: newCreationDate },
|
||||
});
|
||||
});
|
||||
|
||||
it("should update to a different creation date string format", async () => {
|
||||
const newCreationDate = "2023-03-15T08:45:30.123Z";
|
||||
await sut.setAccountCreationDate(userId, newCreationDate);
|
||||
const currentState = await firstValueFrom(accountsState.state$);
|
||||
|
||||
expect(currentState).toEqual({
|
||||
[userId]: { ...userInfo, creationDate: newCreationDate },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setAccountVerifyNewDeviceLogin", () => {
|
||||
const initialState = true;
|
||||
beforeEach(() => {
|
||||
@@ -294,6 +365,7 @@ describe("accountService", () => {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
name: undefined,
|
||||
creationDate: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,6 +62,7 @@ const LOGGED_OUT_INFO: AccountInfo = {
|
||||
email: "",
|
||||
emailVerified: false,
|
||||
name: undefined,
|
||||
creationDate: undefined,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,6 +168,10 @@ export class AccountServiceImplementation implements InternalAccountService {
|
||||
await this.setAccountInfo(userId, { emailVerified });
|
||||
}
|
||||
|
||||
async setAccountCreationDate(userId: UserId, creationDate: string): Promise<void> {
|
||||
await this.setAccountInfo(userId, { creationDate });
|
||||
}
|
||||
|
||||
async clean(userId: UserId) {
|
||||
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
|
||||
await this.removeAccountActivity(userId);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SystemNotificationEvent,
|
||||
SystemNotificationsService,
|
||||
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
|
||||
import { mockAccountInfoWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { AuthRequestAnsweringService } from "./auth-request-answering.service";
|
||||
@@ -48,14 +49,16 @@ describe("AuthRequestAnsweringService", () => {
|
||||
|
||||
// Common defaults
|
||||
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
|
||||
accountService.activeAccount$ = of({
|
||||
id: userId,
|
||||
const accountInfo = mockAccountInfoWith({
|
||||
email: "user@example.com",
|
||||
emailVerified: true,
|
||||
name: "User",
|
||||
});
|
||||
accountService.activeAccount$ = of({
|
||||
id: userId,
|
||||
...accountInfo,
|
||||
});
|
||||
accountService.accounts$ = of({
|
||||
[userId]: { email: "user@example.com", emailVerified: true, name: "User" },
|
||||
[userId]: accountInfo,
|
||||
});
|
||||
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
|
||||
of(ForceSetPasswordReason.None),
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
makeStaticByteArray,
|
||||
mockAccountServiceWith,
|
||||
trackEmissions,
|
||||
mockAccountInfoWith,
|
||||
} from "../../../spec";
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
import { MessagingService } from "../../platform/abstractions/messaging.service";
|
||||
@@ -58,9 +59,10 @@ describe("AuthService", () => {
|
||||
const accountInfo = {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: userId,
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
name: "name",
|
||||
...mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
}),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -112,9 +114,10 @@ describe("AuthService", () => {
|
||||
const accountInfo2 = {
|
||||
status: AuthenticationStatus.Unlocked,
|
||||
id: Utils.newGuid() as UserId,
|
||||
email: "email2",
|
||||
emailVerified: false,
|
||||
name: "name2",
|
||||
...mockAccountInfoWith({
|
||||
email: "email2",
|
||||
name: "name2",
|
||||
}),
|
||||
};
|
||||
|
||||
const emissions = trackEmissions(sut.activeAccountStatus$);
|
||||
@@ -131,11 +134,13 @@ describe("AuthService", () => {
|
||||
it("requests auth status for all known users", async () => {
|
||||
const userId2 = Utils.newGuid() as UserId;
|
||||
|
||||
await accountService.addAccount(userId2, {
|
||||
email: "email2",
|
||||
emailVerified: false,
|
||||
name: "name2",
|
||||
});
|
||||
await accountService.addAccount(
|
||||
userId2,
|
||||
mockAccountInfoWith({
|
||||
email: "email2",
|
||||
name: "name2",
|
||||
}),
|
||||
);
|
||||
|
||||
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
|
||||
sut.authStatusFor$ = mockFn;
|
||||
|
||||
@@ -8,12 +8,13 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
|
||||
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "../../platform/abstractions/i18n.service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { Account, AccountInfo, AccountService } from "../abstractions/account.service";
|
||||
import { Account, AccountService } from "../abstractions/account.service";
|
||||
|
||||
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";
|
||||
|
||||
@@ -96,11 +97,10 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
|
||||
const encryptedKey = "encryptedString";
|
||||
organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any);
|
||||
|
||||
const user1AccountInfo: AccountInfo = {
|
||||
const user1AccountInfo = mockAccountInfoWith({
|
||||
name: "Test User 1",
|
||||
email: "test1@email.com",
|
||||
emailVerified: true,
|
||||
};
|
||||
});
|
||||
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
|
||||
|
||||
keyService.userKey$.mockReturnValue(of({ key: "key" } as any));
|
||||
|
||||
@@ -22,9 +22,7 @@ import { UserKey } from "../../types/key";
|
||||
import { AccountService } from "../abstractions/account.service";
|
||||
import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction";
|
||||
|
||||
export class PasswordResetEnrollmentServiceImplementation
|
||||
implements PasswordResetEnrollmentServiceAbstraction
|
||||
{
|
||||
export class PasswordResetEnrollmentServiceImplementation implements PasswordResetEnrollmentServiceAbstraction {
|
||||
constructor(
|
||||
protected organizationApiService: OrganizationApiServiceAbstraction,
|
||||
protected accountService: AccountService,
|
||||
|
||||
@@ -445,13 +445,15 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
// 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.
|
||||
|
||||
// When secure storage is supported, clear the encryption key from secure storage.
|
||||
// When not supported (e.g., portable builds), tokens are stored on disk and this step is skipped.
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
// 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
|
||||
// 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
|
||||
// Clear tokens from disk storage (all platforms)
|
||||
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, {
|
||||
shouldUpdate: (previousValue) => previousValue !== null,
|
||||
});
|
||||
@@ -478,6 +480,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
return null;
|
||||
}
|
||||
|
||||
// When platformSupportsSecureStorage=true, tokens on disk are encrypted and require
|
||||
// decryption keys from secure storage. When false (e.g., portable builds), tokens are
|
||||
// stored on disk.
|
||||
if (this.platformSupportsSecureStorage) {
|
||||
let accessTokenKey: AccessTokenKey;
|
||||
try {
|
||||
@@ -1118,6 +1123,9 @@ export class TokenService implements TokenServiceAbstraction {
|
||||
) {
|
||||
return TokenStorageLocation.Memory;
|
||||
} else {
|
||||
// Secure storage (e.g., OS credential manager) is preferred when available.
|
||||
// Desktop portable builds set platformSupportsSecureStorage=false to store tokens
|
||||
// on disk for portability across machines.
|
||||
if (useSecureStorage && this.platformSupportsSecureStorage) {
|
||||
return TokenStorageLocation.SecureStorage;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import { PlatformUtilsService } from "../../../platform/abstractions/platform-ut
|
||||
import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction";
|
||||
import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response";
|
||||
|
||||
export class OrganizationSponsorshipApiService
|
||||
implements OrganizationSponsorshipApiServiceAbstraction
|
||||
{
|
||||
export class OrganizationSponsorshipApiService implements OrganizationSponsorshipApiServiceAbstraction {
|
||||
constructor(
|
||||
private apiService: ApiService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
|
||||
@@ -43,6 +43,7 @@ export enum FeatureFlag {
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
|
||||
/* Tools */
|
||||
@@ -64,6 +65,7 @@ export enum FeatureFlag {
|
||||
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
|
||||
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
|
||||
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
|
||||
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
@@ -123,6 +125,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.RiskInsightsForPremium]: FALSE,
|
||||
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
|
||||
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
|
||||
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
@@ -147,6 +150,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
|
||||
@@ -91,4 +91,12 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
|
||||
|
||||
return new SymmetricCryptoKey(newKey);
|
||||
}
|
||||
|
||||
async deriveVaultExportKey(
|
||||
password: string,
|
||||
salt: string,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
return await this.stretchKey(await this.deriveKeyFromPassword(password, salt, kdfConfig));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,4 +87,19 @@ export abstract class KeyGenerationService {
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
abstract stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey>;
|
||||
|
||||
/**
|
||||
* Derives a 64 byte key for encrypting and decrypting vault exports.
|
||||
*
|
||||
* @deprecated Do not use this for new use-cases.
|
||||
* @param password Password to derive the key from.
|
||||
* @param salt Salt for the key derivation function.
|
||||
* @param kdfConfig Configuration for the key derivation function.
|
||||
* @returns 64 byte derived key.
|
||||
*/
|
||||
abstract deriveVaultExportKey(
|
||||
password: string,
|
||||
salt: string,
|
||||
kdfConfig: KdfConfig,
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
|
||||
|
||||
export abstract class KeyConnectorApiService {
|
||||
abstract getConfirmationDetails(
|
||||
orgSsoIdentifier: string,
|
||||
): Promise<KeyConnectorConfirmationDetailsResponse>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export interface KeyConnectorDomainConfirmation {
|
||||
keyConnectorUrl: string;
|
||||
organizationSsoIdentifier: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { BaseResponse } from "../../../../models/response/base.response";
|
||||
|
||||
export class KeyConnectorConfirmationDetailsResponse extends BaseResponse {
|
||||
organizationName: string;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.organizationName = this.getResponseProperty("OrganizationName");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
|
||||
|
||||
import { DefaultKeyConnectorApiService } from "./default-key-connector-api.service";
|
||||
|
||||
describe("DefaultKeyConnectorApiService", () => {
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let sut: DefaultKeyConnectorApiService;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
sut = new DefaultKeyConnectorApiService(apiService);
|
||||
});
|
||||
|
||||
describe("getConfirmationDetails", () => {
|
||||
it("encodes orgSsoIdentifier in URL", async () => {
|
||||
const orgSsoIdentifier = "test org/with special@chars";
|
||||
const expectedEncodedIdentifier = encodeURIComponent(orgSsoIdentifier);
|
||||
const mockResponse = {};
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
await sut.getConfirmationDetails(orgSsoIdentifier);
|
||||
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
`/accounts/key-connector/confirmation-details/${expectedEncodedIdentifier}`,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns expected response", async () => {
|
||||
const orgSsoIdentifier = "test-org";
|
||||
const expectedOrgName = "example";
|
||||
const mockResponse = { OrganizationName: expectedOrgName };
|
||||
apiService.send.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await sut.getConfirmationDetails(orgSsoIdentifier);
|
||||
|
||||
expect(result).toBeInstanceOf(KeyConnectorConfirmationDetailsResponse);
|
||||
expect(result.organizationName).toBe(expectedOrgName);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"GET",
|
||||
"/accounts/key-connector/confirmation-details/test-org",
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { KeyConnectorApiService } from "../abstractions/key-connector-api.service";
|
||||
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
|
||||
|
||||
export class DefaultKeyConnectorApiService implements KeyConnectorApiService {
|
||||
constructor(private apiService: ApiService) {}
|
||||
|
||||
async getConfirmationDetails(
|
||||
orgSsoIdentifier: string,
|
||||
): Promise<KeyConnectorConfirmationDetailsResponse> {
|
||||
const r = await this.apiService.send(
|
||||
"GET",
|
||||
"/accounts/key-connector/confirmation-details/" + encodeURIComponent(orgSsoIdentifier),
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
return new KeyConnectorConfirmationDetailsResponse(r);
|
||||
}
|
||||
}
|
||||
@@ -603,7 +603,10 @@ describe("KeyConnectorService", () => {
|
||||
const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId);
|
||||
const data = await firstValueFrom(data$);
|
||||
|
||||
expect(data).toEqual({ keyConnectorUrl: conversion.keyConnectorUrl });
|
||||
expect(data).toEqual({
|
||||
keyConnectorUrl: conversion.keyConnectorUrl,
|
||||
organizationSsoIdentifier: conversion.organizationId,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return observable of null value when no data is set", async () => {
|
||||
|
||||
@@ -202,9 +202,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
}
|
||||
|
||||
requiresDomainConfirmation$(userId: UserId): Observable<KeyConnectorDomainConfirmation | null> {
|
||||
return this.stateProvider
|
||||
.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId)
|
||||
.pipe(map((data) => (data != null ? { keyConnectorUrl: data.keyConnectorUrl } : null)));
|
||||
return this.stateProvider.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId).pipe(
|
||||
map((data) =>
|
||||
data != null
|
||||
? {
|
||||
keyConnectorUrl: data.keyConnectorUrl,
|
||||
organizationSsoIdentifier: data.organizationId,
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private handleKeyConnectorError(e: any) {
|
||||
|
||||
@@ -45,14 +45,6 @@ export abstract class PinStateServiceAbstraction {
|
||||
pinLockType: PinLockType,
|
||||
): Promise<PasswordProtectedKeyEnvelope | null>;
|
||||
|
||||
/**
|
||||
* Gets the user's legacy PIN-protected UserKey
|
||||
* @deprecated Use {@link getPinProtectedUserKeyEnvelope} instead. Only for migration support.
|
||||
* @param userId The user's id
|
||||
* @throws If the user id is not provided
|
||||
*/
|
||||
abstract getLegacyPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null>;
|
||||
|
||||
/**
|
||||
* Sets the PIN state for the user
|
||||
* @deprecated - This is not a public API. DO NOT USE IT
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT,
|
||||
PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL,
|
||||
USER_KEY_ENCRYPTED_PIN,
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
} from "./pin.state";
|
||||
|
||||
export class PinStateService implements PinStateServiceAbstraction {
|
||||
@@ -36,9 +35,7 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
const isPersistentPinSet =
|
||||
(await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null ||
|
||||
// Deprecated
|
||||
(await this.getLegacyPinKeyEncryptedUserKeyPersistent(userId)) != null;
|
||||
(await this.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null;
|
||||
const isPinSet =
|
||||
(await firstValueFrom(this.stateProvider.getUserState$(USER_KEY_ENCRYPTED_PIN, userId))) !=
|
||||
null;
|
||||
@@ -71,16 +68,6 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
}
|
||||
}
|
||||
|
||||
async getLegacyPinKeyEncryptedUserKeyPersistent(userId: UserId): Promise<EncString | null> {
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
return await firstValueFrom(
|
||||
this.stateProvider
|
||||
.getUserState$(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, userId)
|
||||
.pipe(map((value) => (value ? new EncString(value) : null))),
|
||||
);
|
||||
}
|
||||
|
||||
async setPinState(
|
||||
userId: UserId,
|
||||
pinProtectedUserKeyEnvelope: PasswordProtectedKeyEnvelope,
|
||||
@@ -116,9 +103,6 @@ export class PinStateService implements PinStateServiceAbstraction {
|
||||
await this.stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, userId);
|
||||
await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL, null, userId);
|
||||
await this.stateProvider.setUserState(PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT, null, userId);
|
||||
|
||||
// Note: This can be deleted after sufficiently many PINs are migrated and the state is removed.
|
||||
await this.stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, null, userId);
|
||||
}
|
||||
|
||||
async clearEphemeralPinState(userId: UserId): Promise<void> {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
USER_KEY_ENCRYPTED_PIN,
|
||||
PIN_PROTECTED_USER_KEY_ENVELOPE_EPHEMERAL,
|
||||
PIN_PROTECTED_USER_KEY_ENVELOPE_PERSISTENT,
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
} from "./pin.state";
|
||||
|
||||
describe("PinStateService", () => {
|
||||
@@ -121,21 +120,6 @@ describe("PinStateService", () => {
|
||||
expect(result).toBe("PERSISTENT");
|
||||
});
|
||||
|
||||
it("should return 'PERSISTENT' if a legacy pin key encrypted user key (persistent) is found", async () => {
|
||||
// Arrange
|
||||
await stateProvider.setUserState(
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
mockUserKeyEncryptedPin,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await sut.getPinLockType(mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBe("PERSISTENT");
|
||||
});
|
||||
|
||||
it("should return 'EPHEMERAL' if only user key encrypted pin is found", async () => {
|
||||
// Arrange
|
||||
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, mockUserKeyEncryptedPin, mockUserId);
|
||||
@@ -164,7 +148,6 @@ describe("PinStateService", () => {
|
||||
null,
|
||||
mockUserId,
|
||||
);
|
||||
await stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, null, mockUserId);
|
||||
await stateProvider.setUserState(USER_KEY_ENCRYPTED_PIN, null, mockUserId);
|
||||
|
||||
// Act
|
||||
@@ -290,45 +273,6 @@ describe("PinStateService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLegacyPinKeyEncryptedUserKeyPersistent()", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test.each([null, undefined])("throws if userId is %p", async (userId) => {
|
||||
// Act & Assert
|
||||
await expect(() =>
|
||||
sut.getLegacyPinKeyEncryptedUserKeyPersistent(userId as any),
|
||||
).rejects.toThrow("userId is null or undefined.");
|
||||
});
|
||||
|
||||
it("should return EncString when legacy key is set", async () => {
|
||||
// Arrange
|
||||
await stateProvider.setUserState(
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
mockUserKeyEncryptedPin,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(result?.encryptedString).toEqual(mockUserKeyEncryptedPin);
|
||||
});
|
||||
|
||||
test.each([null, undefined])("should return null when legacy key is %p", async (value) => {
|
||||
// Arrange
|
||||
await stateProvider.setUserState(PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT, value, mockUserId);
|
||||
|
||||
// Act
|
||||
const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setPinState()", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -464,22 +408,6 @@ describe("PinStateService", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("clears legacy PIN key encrypted user key persistent", async () => {
|
||||
// Arrange
|
||||
await stateProvider.setUserState(
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
mockUserKeyEncryptedPin,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Act
|
||||
await sut.clearPinState(mockUserId);
|
||||
|
||||
// Assert
|
||||
const result = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("clears all PIN state when all types are set", async () => {
|
||||
// Arrange - set up all possible PIN state
|
||||
await sut.setPinState(
|
||||
@@ -494,17 +422,11 @@ describe("PinStateService", () => {
|
||||
mockUserKeyEncryptedPin,
|
||||
"EPHEMERAL",
|
||||
);
|
||||
await stateProvider.setUserState(
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
mockUserKeyEncryptedPin,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Verify all state is set before clearing
|
||||
expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).not.toBeNull();
|
||||
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).not.toBeNull();
|
||||
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).not.toBeNull();
|
||||
expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).not.toBeNull();
|
||||
|
||||
// Act
|
||||
await sut.clearPinState(mockUserId);
|
||||
@@ -513,7 +435,6 @@ describe("PinStateService", () => {
|
||||
expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).toBeNull();
|
||||
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).toBeNull();
|
||||
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).toBeNull();
|
||||
expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).toBeNull();
|
||||
});
|
||||
|
||||
it("results in PIN lock type DISABLED after clearing", async () => {
|
||||
@@ -545,7 +466,6 @@ describe("PinStateService", () => {
|
||||
expect(await firstValueFrom(sut.userKeyEncryptedPin$(mockUserId))).toBeNull();
|
||||
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL")).toBeNull();
|
||||
expect(await sut.getPinProtectedUserKeyEnvelope(mockUserId, "PERSISTENT")).toBeNull();
|
||||
expect(await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId)).toBeNull();
|
||||
expect(await sut.getPinLockType(mockUserId)).toBe("DISABLED");
|
||||
});
|
||||
});
|
||||
@@ -623,32 +543,6 @@ describe("PinStateService", () => {
|
||||
expect(ephemeralResult).toBeNull();
|
||||
});
|
||||
|
||||
it("does not clear legacy PIN key encrypted user key persistent", async () => {
|
||||
// Arrange - set up ephemeral state and legacy state
|
||||
await sut.setPinState(
|
||||
mockUserId,
|
||||
mockEphemeralEnvelope,
|
||||
mockUserKeyEncryptedPin,
|
||||
"EPHEMERAL",
|
||||
);
|
||||
await stateProvider.setUserState(
|
||||
PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT,
|
||||
mockUserKeyEncryptedPin,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
// Act
|
||||
await sut.clearEphemeralPinState(mockUserId);
|
||||
|
||||
// Assert - legacy PIN should still be present
|
||||
const legacyResult = await sut.getLegacyPinKeyEncryptedUserKeyPersistent(mockUserId);
|
||||
expect(legacyResult?.encryptedString).toEqual(mockUserKeyEncryptedPin);
|
||||
|
||||
// Assert - ephemeral envelope should be cleared
|
||||
const ephemeralResult = await sut.getPinProtectedUserKeyEnvelope(mockUserId, "EPHEMERAL");
|
||||
expect(ephemeralResult).toBeNull();
|
||||
});
|
||||
|
||||
it("changes PIN lock type from EPHEMERAL to DISABLED when no other PIN state exists", async () => {
|
||||
// Arrange - set up only ephemeral PIN state
|
||||
await sut.setPinState(
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { UserId } from "../../types/guid";
|
||||
import { PinKey, UserKey } from "../../types/key";
|
||||
import { UserKey } from "../../types/key";
|
||||
|
||||
import { PinLockType } from "./pin-lock-type";
|
||||
|
||||
@@ -69,10 +66,4 @@ export abstract class PinServiceAbstraction {
|
||||
* @deprecated This is not deprecated, but only meant to be called by KeyService. DO NOT USE IT.
|
||||
*/
|
||||
abstract userUnlocked(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Makes a PinKey from the provided PIN.
|
||||
* @deprecated - Note: This is currently re-used by vault exports, which is still permitted but should be refactored out to use a different construct.
|
||||
*/
|
||||
abstract makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey>;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { assertNonNullish } from "../../auth/utils";
|
||||
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "../../key-management/crypto/models/enc-string";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { PinKey, UserKey } from "../../types/key";
|
||||
import { KeyGenerationService } from "../crypto";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { firstValueFromOrThrow } from "../utils";
|
||||
|
||||
import { PinLockType } from "./pin-lock-type";
|
||||
@@ -21,10 +18,7 @@ import { PinServiceAbstraction } from "./pin.service.abstraction";
|
||||
|
||||
export class PinService implements PinServiceAbstraction {
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private encryptService: EncryptService,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private keyGenerationService: KeyGenerationService,
|
||||
private logService: LogService,
|
||||
private keyService: KeyService,
|
||||
private sdkService: SdkService,
|
||||
@@ -56,19 +50,6 @@ export class PinService implements PinServiceAbstraction {
|
||||
// On first unlock, set the ephemeral pin envelope, if it is not set yet
|
||||
const pin = await this.getPin(userId);
|
||||
await this.setPin(pin, "EPHEMERAL", userId);
|
||||
} else if ((await this.pinStateService.getPinLockType(userId)) === "PERSISTENT") {
|
||||
// Encrypted migration for persistent pin unlock to pin envelopes.
|
||||
// This will be removed at the earliest in 2026.1.0
|
||||
//
|
||||
// ----- ENCRYPTION MIGRATION -----
|
||||
// Pin-key encrypted user-keys are eagerly migrated to the new pin-protected user key envelope format.
|
||||
if ((await this.pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent(userId)) != null) {
|
||||
this.logService.info(
|
||||
"[Pin Service] Migrating legacy PIN key to PinProtectedUserKeyEnvelope",
|
||||
);
|
||||
const pin = await this.getPin(userId);
|
||||
await this.setPin(pin, "PERSISTENT", userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,86 +125,30 @@ export class PinService implements PinServiceAbstraction {
|
||||
assertNonNullish(pin, "pin");
|
||||
assertNonNullish(userId, "userId");
|
||||
|
||||
const hasPinProtectedKeyEnvelopeSet =
|
||||
(await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, "EPHEMERAL")) != null ||
|
||||
(await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, "PERSISTENT")) != null;
|
||||
this.logService.info("[Pin Service] Pin-unlock via PinProtectedUserKeyEnvelope");
|
||||
|
||||
if (hasPinProtectedKeyEnvelopeSet) {
|
||||
this.logService.info("[Pin Service] Pin-unlock via PinProtectedUserKeyEnvelope");
|
||||
const pinLockType = await this.pinStateService.getPinLockType(userId);
|
||||
const envelope = await this.pinStateService.getPinProtectedUserKeyEnvelope(userId, pinLockType);
|
||||
|
||||
const pinLockType = await this.pinStateService.getPinLockType(userId);
|
||||
const envelope = await this.pinStateService.getPinProtectedUserKeyEnvelope(
|
||||
userId,
|
||||
pinLockType,
|
||||
try {
|
||||
// Use the sdk to create an enrollment, not yet persisting it to state
|
||||
const startTime = performance.now();
|
||||
const userKeyBytes = await firstValueFrom(
|
||||
this.sdkService.client$.pipe(
|
||||
map((sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
return sdk.crypto().unseal_password_protected_key_envelope(pin, envelope!);
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.logService.measure(startTime, "Crypto", "PinService", "UnsealPinEnvelope");
|
||||
|
||||
try {
|
||||
// Use the sdk to create an enrollment, not yet persisting it to state
|
||||
const startTime = performance.now();
|
||||
const userKeyBytes = await firstValueFrom(
|
||||
this.sdkService.client$.pipe(
|
||||
map((sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
return sdk.crypto().unseal_password_protected_key_envelope(pin, envelope!);
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.logService.measure(startTime, "Crypto", "PinService", "UnsealPinEnvelope");
|
||||
|
||||
return new SymmetricCryptoKey(userKeyBytes) as UserKey;
|
||||
} catch (error) {
|
||||
this.logService.error(`Failed to unseal pin: ${error}`);
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
this.logService.info("[Pin Service] Pin-unlock via legacy PinKeyEncryptedUserKey");
|
||||
|
||||
// This branch is deprecated and will be removed in the future, but is kept for migration.
|
||||
try {
|
||||
const pinKeyEncryptedUserKey =
|
||||
await this.pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent(userId);
|
||||
const email = await firstValueFrom(
|
||||
this.accountService.accounts$.pipe(map((accounts) => accounts[userId].email)),
|
||||
);
|
||||
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||
return await this.decryptUserKey(pin, email, kdfConfig, pinKeyEncryptedUserKey!);
|
||||
} catch (error) {
|
||||
this.logService.error(`Error decrypting user key with pin: ${error}`);
|
||||
return null;
|
||||
}
|
||||
return new SymmetricCryptoKey(userKeyBytes) as UserKey;
|
||||
} catch (error) {
|
||||
this.logService.error(`Failed to unseal pin: ${error}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Anything below here is deprecated and will be removed subsequently
|
||||
|
||||
async makePinKey(pin: string, salt: string, kdfConfig: KdfConfig): Promise<PinKey> {
|
||||
const startTime = performance.now();
|
||||
const pinKey = await this.keyGenerationService.deriveKeyFromPassword(pin, salt, kdfConfig);
|
||||
this.logService.measure(startTime, "Crypto", "PinService", "makePinKey");
|
||||
|
||||
return (await this.keyGenerationService.stretchKey(pinKey)) as PinKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts the UserKey with the provided PIN.
|
||||
* @deprecated
|
||||
* @throws If the PIN does not match the PIN that was used to encrypt the user key
|
||||
* @throws If the salt, or KDF don't match the salt / KDF used to encrypt the user key
|
||||
*/
|
||||
private async decryptUserKey(
|
||||
pin: string,
|
||||
salt: string,
|
||||
kdfConfig: KdfConfig,
|
||||
pinKeyEncryptedUserKey: EncString,
|
||||
): Promise<UserKey> {
|
||||
assertNonNullish(pin, "pin");
|
||||
assertNonNullish(salt, "salt");
|
||||
assertNonNullish(kdfConfig, "kdfConfig");
|
||||
assertNonNullish(pinKeyEncryptedUserKey, "pinKeyEncryptedUserKey");
|
||||
const pinKey = await this.makePinKey(pin, salt, kdfConfig);
|
||||
const userKey = await this.encryptService.unwrapSymmetricKey(pinKeyEncryptedUserKey, pinKey);
|
||||
return userKey as UserKey;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,15 @@ import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, filter } from "rxjs";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { DEFAULT_KDF_CONFIG, KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { MockSdkService } from "../..//platform/spec/mock-sdk.service";
|
||||
import { FakeAccountService, mockAccountServiceWith, mockEnc } from "../../../spec";
|
||||
import { LogService } from "../../platform/abstractions/log.service";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { PinKey, UserKey } from "../../types/key";
|
||||
import { KeyGenerationService } from "../crypto";
|
||||
import { UserKey } from "../../types/key";
|
||||
import { EncryptService } from "../crypto/abstractions/encrypt.service";
|
||||
import { EncryptedString, EncString } from "../crypto/models/enc-string";
|
||||
|
||||
@@ -22,16 +20,10 @@ import { PinService } from "./pin.service.implementation";
|
||||
describe("PinService", () => {
|
||||
let sut: PinService;
|
||||
|
||||
let accountService: FakeAccountService;
|
||||
|
||||
const encryptService = mock<EncryptService>();
|
||||
const kdfConfigService = mock<KdfConfigService>();
|
||||
const keyGenerationService = mock<KeyGenerationService>();
|
||||
const logService = mock<LogService>();
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const mockPinKey = new SymmetricCryptoKey(randomBytes(32)) as PinKey;
|
||||
const mockUserEmail = "user@example.com";
|
||||
const mockPin = "1234";
|
||||
const mockUserKeyEncryptedPin = new EncString("userKeyEncryptedPin");
|
||||
const mockEphemeralEnvelope = "mock-ephemeral-envelope" as PasswordProtectedKeyEnvelope;
|
||||
@@ -42,7 +34,6 @@ describe("PinService", () => {
|
||||
const behaviorSubject = new BehaviorSubject<{ userId: UserId; userKey: UserKey }>(null);
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mockAccountServiceWith(mockUserId, { email: mockUserEmail });
|
||||
(keyService as any)["unlockedUserKeys$"] = behaviorSubject
|
||||
.asObservable()
|
||||
.pipe(filter((x) => x != null));
|
||||
@@ -50,16 +41,7 @@ describe("PinService", () => {
|
||||
.mockDeep()
|
||||
.unseal_password_protected_key_envelope.mockReturnValue(new Uint8Array(64));
|
||||
|
||||
sut = new PinService(
|
||||
accountService,
|
||||
encryptService,
|
||||
kdfConfigService,
|
||||
keyGenerationService,
|
||||
logService,
|
||||
keyService,
|
||||
sdkService,
|
||||
pinStateService,
|
||||
);
|
||||
sut = new PinService(encryptService, logService, keyService, sdkService, pinStateService);
|
||||
});
|
||||
|
||||
it("should instantiate the PinService", () => {
|
||||
@@ -89,26 +71,6 @@ describe("PinService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should migrate legacy persistent PIN if needed", async () => {
|
||||
// Arrange
|
||||
pinStateService.getPinLockType.mockResolvedValue("PERSISTENT");
|
||||
pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockResolvedValue(
|
||||
mockEnc("legacy-key"),
|
||||
);
|
||||
const getPinSpy = jest.spyOn(sut, "getPin").mockResolvedValue(mockPin);
|
||||
const setPinSpy = jest.spyOn(sut, "setPin").mockResolvedValue();
|
||||
|
||||
// Act
|
||||
await sut.userUnlocked(mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(getPinSpy).toHaveBeenCalledWith(mockUserId);
|
||||
expect(setPinSpy).toHaveBeenCalledWith(mockPin, "PERSISTENT", mockUserId);
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
"[Pin Service] Migrating legacy PIN key to PinProtectedUserKeyEnvelope",
|
||||
);
|
||||
});
|
||||
|
||||
it("should do nothing if no migration or setup is needed", async () => {
|
||||
// Arrange
|
||||
pinStateService.getPinLockType.mockResolvedValue("DISABLED");
|
||||
@@ -124,28 +86,6 @@ describe("PinService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("makePinKey()", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should make a PinKey", async () => {
|
||||
// Arrange
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(mockPinKey);
|
||||
|
||||
// Act
|
||||
await sut.makePinKey(mockPin, mockUserEmail, DEFAULT_KDF_CONFIG);
|
||||
|
||||
// Assert
|
||||
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
|
||||
mockPin,
|
||||
mockUserEmail,
|
||||
DEFAULT_KDF_CONFIG,
|
||||
);
|
||||
expect(keyGenerationService.stretchKey).toHaveBeenCalledWith(mockPinKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPin()", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -383,7 +323,6 @@ describe("PinService", () => {
|
||||
jest.clearAllMocks();
|
||||
pinStateService.userKeyEncryptedPin$.mockReset();
|
||||
pinStateService.getPinProtectedUserKeyEnvelope.mockReset();
|
||||
pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockReset();
|
||||
});
|
||||
|
||||
it("should throw an error if userId is null", async () => {
|
||||
@@ -423,32 +362,5 @@ describe("PinService", () => {
|
||||
// Assert
|
||||
expect(result).toEqual(mockUserKey);
|
||||
});
|
||||
|
||||
it("should return userkey with legacy pin PERSISTENT", async () => {
|
||||
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(mockPinKey);
|
||||
keyGenerationService.stretchKey.mockResolvedValue(mockPinKey);
|
||||
kdfConfigService.getKdfConfig.mockResolvedValue(DEFAULT_KDF_CONFIG);
|
||||
encryptService.unwrapSymmetricKey.mockResolvedValue(mockUserKey);
|
||||
|
||||
// Arrange
|
||||
const mockPin = "1234";
|
||||
pinStateService.userKeyEncryptedPin$.mockReturnValueOnce(
|
||||
new BehaviorSubject(mockUserKeyEncryptedPin),
|
||||
);
|
||||
pinStateService.getLegacyPinKeyEncryptedUserKeyPersistent.mockResolvedValueOnce(
|
||||
mockUserKeyEncryptedPin,
|
||||
);
|
||||
|
||||
// Act
|
||||
const result = await sut.decryptUserKeyWithPin(mockPin, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(result).toEqual(mockUserKey);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test helpers
|
||||
function randomBytes(length: number): Uint8Array {
|
||||
return new Uint8Array(Array.from({ length }, (_, k) => k % 255));
|
||||
}
|
||||
|
||||
@@ -3,22 +3,6 @@ import { PasswordProtectedKeyEnvelope } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncryptedString } from "../crypto/models/enc-string";
|
||||
|
||||
/**
|
||||
* The persistent (stored on disk) version of the UserKey, encrypted by the PinKey.
|
||||
*
|
||||
* @deprecated
|
||||
* @remarks Persists through a client reset. Used when `requireMasterPasswordOnClientRestart` is disabled.
|
||||
* @see SetPinComponent.setPinForm.requireMasterPasswordOnClientRestart
|
||||
*/
|
||||
export const PIN_KEY_ENCRYPTED_USER_KEY_PERSISTENT = new UserKeyDefinition<EncryptedString>(
|
||||
PIN_DISK,
|
||||
"pinKeyEncryptedUserKeyPersistent",
|
||||
{
|
||||
deserializer: (jsonValue) => jsonValue,
|
||||
clearOn: ["logout"],
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* The persistent (stored on disk) version of the UserKey, stored in a `PasswordProtectedKeyEnvelope`.
|
||||
*
|
||||
|
||||
@@ -56,7 +56,7 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract
|
||||
return;
|
||||
}
|
||||
|
||||
// If there is an active user, check if they have a pinKeyEncryptedUserKeyEphemeral. If so, prevent process reload upon lock.
|
||||
// If there is an active user, check if they have an ephemeral PIN. If so, prevent process reload upon lock.
|
||||
const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
if (userId != null) {
|
||||
if ((await this.pinService.getPinLockType(userId)) === "EPHEMERAL") {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { BehaviorSubject, from, of } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LockService, LogoutService } from "@bitwarden/auth/common";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
|
||||
import { FakeAccountService, mockAccountServiceWith, mockAccountInfoWith } from "../../../../spec";
|
||||
import { AccountInfo } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
@@ -109,19 +109,19 @@ describe("VaultTimeoutService", () => {
|
||||
if (globalSetups?.userId) {
|
||||
accountService.activeAccountSubject.next({
|
||||
id: globalSetups.userId as UserId,
|
||||
email: null,
|
||||
emailVerified: false,
|
||||
name: null,
|
||||
...mockAccountInfoWith({
|
||||
email: null,
|
||||
name: null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
accountService.accounts$ = of(
|
||||
Object.entries(accounts).reduce(
|
||||
(agg, [id]) => {
|
||||
agg[id] = {
|
||||
agg[id] = mockAccountInfoWith({
|
||||
email: "",
|
||||
emailVerified: true,
|
||||
name: "",
|
||||
};
|
||||
});
|
||||
return agg;
|
||||
},
|
||||
{} as Record<string, AccountInfo>,
|
||||
|
||||
@@ -5,8 +5,10 @@ export interface IpcMessage {
|
||||
message: SerializedOutgoingMessage;
|
||||
}
|
||||
|
||||
export interface SerializedOutgoingMessage
|
||||
extends Omit<OutgoingMessage, typeof Symbol.dispose | "free" | "payload"> {
|
||||
export interface SerializedOutgoingMessage extends Omit<
|
||||
OutgoingMessage,
|
||||
typeof Symbol.dispose | "free" | "payload"
|
||||
> {
|
||||
payload: number[];
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
|
||||
import { mockAccountInfoWith } from "../../../../spec";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
@@ -163,9 +164,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
} else {
|
||||
activeUserAccount$.next({
|
||||
id: userId,
|
||||
email: "email",
|
||||
name: "Test Name",
|
||||
emailVerified: true,
|
||||
...mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "Test Name",
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -174,7 +176,10 @@ describe("DefaultServerNotificationsService (multi-user)", () => {
|
||||
const currentAccounts = (userAccounts$.getValue() as Record<string, any>) ?? {};
|
||||
userAccounts$.next({
|
||||
...currentAccounts,
|
||||
[userId]: { email: "email", name: "Test Name", emailVerified: true },
|
||||
[userId]: mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "Test Name",
|
||||
}),
|
||||
} as any);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { InternalPolicyService } from "@bitwarden/common/admin-console/abstracti
|
||||
import { PolicyType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AuthRequestAnsweringServiceAbstraction } from "@bitwarden/common/auth/abstractions/auth-request-answering/auth-request-answering.service.abstraction";
|
||||
|
||||
import { awaitAsync } from "../../../../spec";
|
||||
import { awaitAsync, mockAccountInfoWith } from "../../../../spec";
|
||||
import { Matrix } from "../../../../spec/matrix";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
@@ -139,11 +139,18 @@ describe("NotificationsService", () => {
|
||||
activeAccount.next(null);
|
||||
accounts.next({} as any);
|
||||
} else {
|
||||
activeAccount.next({ id: userId, email: "email", name: "Test Name", emailVerified: true });
|
||||
const accountInfo = mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "Test Name",
|
||||
});
|
||||
activeAccount.next({
|
||||
id: userId,
|
||||
...accountInfo,
|
||||
});
|
||||
const current = (accounts.getValue() as Record<string, any>) ?? {};
|
||||
accounts.next({
|
||||
...current,
|
||||
[userId]: { email: "email", name: "Test Name", emailVerified: true },
|
||||
[userId]: accountInfo,
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
@@ -349,7 +356,13 @@ describe("NotificationsService", () => {
|
||||
describe("processNotification", () => {
|
||||
beforeEach(async () => {
|
||||
appIdService.getAppId.mockResolvedValue("test-app-id");
|
||||
activeAccount.next({ id: mockUser1, email: "email", name: "Test Name", emailVerified: true });
|
||||
activeAccount.next({
|
||||
id: mockUser1,
|
||||
...mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "Test Name",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe("NotificationType.LogOut", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, awaitAsync } from "../../../spec";
|
||||
import { FakeStateProvider, awaitAsync, mockAccountInfoWith } from "../../../spec";
|
||||
import { FakeAccountService } from "../../../spec/fake-account-service";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { CloudRegion, Region } from "../abstractions/environment.service";
|
||||
@@ -28,16 +28,14 @@ describe("EnvironmentService", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
accountService = new FakeAccountService({
|
||||
[testUser]: {
|
||||
[testUser]: mockAccountInfoWith({
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
},
|
||||
[alternateTestUser]: {
|
||||
}),
|
||||
[alternateTestUser]: mockAccountInfoWith({
|
||||
name: "name",
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
|
||||
@@ -47,9 +45,10 @@ describe("EnvironmentService", () => {
|
||||
const switchUser = async (userId: UserId) => {
|
||||
accountService.activeAccountSubject.next({
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
name: `Test Name ${userId}`,
|
||||
emailVerified: false,
|
||||
...mockAccountInfoWith({
|
||||
email: "test@example.com",
|
||||
name: `Test Name ${userId}`,
|
||||
}),
|
||||
});
|
||||
await awaitAsync();
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { TextEncoder } from "util";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { mockAccountServiceWith } from "../../../../spec";
|
||||
import { mockAccountServiceWith, mockAccountInfoWith } from "../../../../spec";
|
||||
import { Account } from "../../../auth/abstractions/account.service";
|
||||
import { CipherId, UserId } from "../../../types/guid";
|
||||
import { CipherService, EncryptionContext } from "../../../vault/abstractions/cipher.service";
|
||||
@@ -40,9 +40,10 @@ describe("FidoAuthenticatorService", () => {
|
||||
const userId = "testId" as UserId;
|
||||
const activeAccountSubject = new BehaviorSubject<Account | null>({
|
||||
id: userId,
|
||||
email: "test@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test User",
|
||||
...mockAccountInfoWith({
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
}),
|
||||
});
|
||||
|
||||
let cipherService!: MockProxy<CipherService>;
|
||||
|
||||
@@ -46,9 +46,9 @@ const KeyUsages: KeyUsage[] = ["sign"];
|
||||
*
|
||||
* It is highly recommended that the W3C specification is used a reference when reading this code.
|
||||
*/
|
||||
export class Fido2AuthenticatorService<ParentWindowReference>
|
||||
implements Fido2AuthenticatorServiceAbstraction<ParentWindowReference>
|
||||
{
|
||||
export class Fido2AuthenticatorService<
|
||||
ParentWindowReference,
|
||||
> implements Fido2AuthenticatorServiceAbstraction<ParentWindowReference> {
|
||||
constructor(
|
||||
private cipherService: CipherService,
|
||||
private userInterface: Fido2UserInterfaceService<ParentWindowReference>,
|
||||
|
||||
@@ -47,9 +47,9 @@ import { guidToRawFormat } from "./guid-utils";
|
||||
*
|
||||
* It is highly recommended that the W3C specification is used a reference when reading this code.
|
||||
*/
|
||||
export class Fido2ClientService<ParentWindowReference>
|
||||
implements Fido2ClientServiceAbstraction<ParentWindowReference>
|
||||
{
|
||||
export class Fido2ClientService<
|
||||
ParentWindowReference,
|
||||
> implements Fido2ClientServiceAbstraction<ParentWindowReference> {
|
||||
private timeoutAbortController: AbortController;
|
||||
private readonly TIMEOUTS = {
|
||||
NO_VERIFICATION: {
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
mockAccountServiceWith,
|
||||
mockAccountInfoWith,
|
||||
} from "../../../../spec";
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountInfo } from "../../../auth/abstractions/account.service";
|
||||
import { EncryptedString } from "../../../key-management/crypto/models/enc-string";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey } from "../../../types/key";
|
||||
@@ -92,7 +92,10 @@ describe("DefaultSdkService", () => {
|
||||
.calledWith(userId)
|
||||
.mockReturnValue(new BehaviorSubject(mock<Environment>()));
|
||||
accountService.accounts$ = of({
|
||||
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
|
||||
[userId]: mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
}),
|
||||
});
|
||||
kdfConfigService.getKdfConfig$
|
||||
.calledWith(userId)
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
mockAccountServiceWith,
|
||||
mockAccountInfoWith,
|
||||
} from "../../../../spec";
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountInfo } from "../../../auth/abstractions/account.service";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
import { Environment, EnvironmentService } from "../../abstractions/environment.service";
|
||||
@@ -76,7 +76,10 @@ describe("DefaultRegisterSdkService", () => {
|
||||
.calledWith(userId)
|
||||
.mockReturnValue(new BehaviorSubject(mock<Environment>()));
|
||||
accountService.accounts$ = of({
|
||||
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
|
||||
[userId]: mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,7 +128,10 @@ describe("DefaultRegisterSdkService", () => {
|
||||
|
||||
it("destroys the internal SDK client when the account is removed (logout)", async () => {
|
||||
const accounts$ = new BehaviorSubject({
|
||||
[userId]: { email: "email", emailVerified: true, name: "name" } as AccountInfo,
|
||||
[userId]: mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
}),
|
||||
});
|
||||
accountService.accounts$ = accounts$;
|
||||
|
||||
|
||||
@@ -272,6 +272,7 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
await this.tokenService.setSecurityStamp(response.securityStamp, response.id);
|
||||
await this.accountService.setAccountEmailVerified(response.id, response.emailVerified);
|
||||
await this.accountService.setAccountVerifyNewDeviceLogin(response.id, response.verifyDevices);
|
||||
await this.accountService.setAccountCreationDate(response.id, response.creationDate);
|
||||
|
||||
await this.billingAccountProfileStateService.setHasPremium(
|
||||
response.premiumPersonally,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ObservedValueOf, of } from "rxjs";
|
||||
import { LogoutReason } from "@bitwarden/auth/common";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { mockAccountInfoWith } from "../../spec";
|
||||
import { AccountService } from "../auth/abstractions/account.service";
|
||||
import { TokenService } from "../auth/abstractions/token.service";
|
||||
import { DeviceType } from "../enums";
|
||||
@@ -55,9 +56,10 @@ describe("ApiService", () => {
|
||||
|
||||
accountService.activeAccount$ = of({
|
||||
id: testActiveUser,
|
||||
email: "user1@example.com",
|
||||
emailVerified: true,
|
||||
name: "Test Name",
|
||||
...mockAccountInfoWith({
|
||||
email: "user1@example.com",
|
||||
name: "Test Name",
|
||||
}),
|
||||
} satisfies ObservedValueOf<AccountService["activeAccount$"]>);
|
||||
|
||||
httpOperations = mock();
|
||||
|
||||
@@ -63,6 +63,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ
|
||||
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
|
||||
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
|
||||
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
|
||||
import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response";
|
||||
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
|
||||
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
|
||||
@@ -165,7 +166,10 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
| SsoTokenRequest
|
||||
| WebAuthnLoginTokenRequest,
|
||||
): Promise<
|
||||
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityDeviceVerificationResponse
|
||||
| IdentitySsoRequiredResponse
|
||||
> {
|
||||
const headers = new Headers({
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
||||
@@ -212,6 +216,8 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
responseJson?.ErrorModel?.Message === ApiService.NEW_DEVICE_VERIFICATION_REQUIRED_MESSAGE
|
||||
) {
|
||||
return new IdentityDeviceVerificationResponse(responseJson);
|
||||
} else if (response.status === 400 && responseJson?.SsoOrganizationIdentifier) {
|
||||
return new IdentitySsoRequiredResponse(responseJson);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { FakeAccountService, FakeStateProvider, awaitAsync } from "../../../spec";
|
||||
import {
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountInfoWith,
|
||||
} from "../../../spec";
|
||||
import { Account } from "../../auth/abstractions/account.service";
|
||||
import { EXTENSION_DISK, UserKeyDefinition } from "../../platform/state";
|
||||
import { UserId } from "../../types/guid";
|
||||
@@ -21,9 +26,10 @@ import { SimpleLogin } from "./vendor/simplelogin";
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomeAccount = {
|
||||
id: SomeUser,
|
||||
email: "someone@example.com",
|
||||
emailVerified: true,
|
||||
name: "Someone",
|
||||
...mockAccountInfoWith({
|
||||
email: "someone@example.com",
|
||||
name: "Someone",
|
||||
}),
|
||||
};
|
||||
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
FakeStateProvider,
|
||||
awaitAsync,
|
||||
mockAccountServiceWith,
|
||||
mockAccountInfoWith,
|
||||
} from "../../../../spec";
|
||||
import { KeyGenerationService } from "../../../key-management/crypto";
|
||||
import { EncryptService } from "../../../key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -71,9 +72,10 @@ describe("SendService", () => {
|
||||
|
||||
accountService.activeAccountSubject.next({
|
||||
id: mockUserId,
|
||||
email: "email",
|
||||
emailVerified: false,
|
||||
name: "name",
|
||||
...mockAccountInfoWith({
|
||||
email: "email",
|
||||
name: "name",
|
||||
}),
|
||||
});
|
||||
|
||||
// Initial encrypted state
|
||||
|
||||
@@ -14,9 +14,11 @@ import { Classifier } from "./classifier";
|
||||
* Data that cannot be serialized by JSON.stringify() should
|
||||
* be excluded.
|
||||
*/
|
||||
export class SecretClassifier<Plaintext extends object, Disclosed, Secret>
|
||||
implements Classifier<Plaintext, Disclosed, Secret>
|
||||
{
|
||||
export class SecretClassifier<Plaintext extends object, Disclosed, Secret> implements Classifier<
|
||||
Plaintext,
|
||||
Disclosed,
|
||||
Secret
|
||||
> {
|
||||
private constructor(
|
||||
disclosed: readonly (keyof Jsonify<Disclosed> & keyof Jsonify<Plaintext>)[],
|
||||
excluded: readonly (keyof Plaintext)[],
|
||||
|
||||
@@ -25,9 +25,13 @@ const ONE_MINUTE = 1000 * 60;
|
||||
*
|
||||
* DO NOT USE THIS for synchronized data.
|
||||
*/
|
||||
export class SecretState<Outer, Id, Plaintext extends object, Disclosed, Secret>
|
||||
implements SingleUserState<Outer>
|
||||
{
|
||||
export class SecretState<
|
||||
Outer,
|
||||
Id,
|
||||
Plaintext extends object,
|
||||
Disclosed,
|
||||
Secret,
|
||||
> implements SingleUserState<Outer> {
|
||||
// The constructor is private to avoid creating a circular dependency when
|
||||
// wiring the derived and secret states together.
|
||||
private constructor(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
awaitAsync,
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
mockAccountInfoWith,
|
||||
ObservableTracker,
|
||||
} from "../../../spec";
|
||||
import { Account } from "../../auth/abstractions/account.service";
|
||||
@@ -23,17 +24,19 @@ import { UserStateSubject } from "./user-state-subject";
|
||||
const SomeUser = "some user" as UserId;
|
||||
const SomeAccount = {
|
||||
id: SomeUser,
|
||||
email: "someone@example.com",
|
||||
emailVerified: true,
|
||||
name: "Someone",
|
||||
...mockAccountInfoWith({
|
||||
email: "someone@example.com",
|
||||
name: "Someone",
|
||||
}),
|
||||
};
|
||||
const SomeAccount$ = new BehaviorSubject<Account>(SomeAccount);
|
||||
|
||||
const SomeOtherAccount = {
|
||||
id: "some other user" as UserId,
|
||||
email: "someone@example.com",
|
||||
emailVerified: true,
|
||||
name: "Someone",
|
||||
...mockAccountInfoWith({
|
||||
email: "someone@example.com",
|
||||
name: "Someone",
|
||||
}),
|
||||
};
|
||||
|
||||
type TestType = { foo: string };
|
||||
|
||||
@@ -79,11 +79,11 @@ const DEFAULT_FRAME_SIZE = 32;
|
||||
* @template Dependencies use-specific dependencies provided by the user.
|
||||
*/
|
||||
export class UserStateSubject<
|
||||
State extends object,
|
||||
Secret = State,
|
||||
Disclosed = Record<string, never>,
|
||||
Dependencies = null,
|
||||
>
|
||||
State extends object,
|
||||
Secret = State,
|
||||
Disclosed = Record<string, never>,
|
||||
Dependencies = null,
|
||||
>
|
||||
extends Observable<State>
|
||||
implements SubjectLike<State>
|
||||
{
|
||||
|
||||
@@ -9,8 +9,6 @@ export type PrfKey = Opaque<SymmetricCryptoKey, "PrfKey">;
|
||||
export type UserKey = Opaque<SymmetricCryptoKey, "UserKey">;
|
||||
/** @deprecated Interacting with the master key directly is prohibited. Use a high level function from MasterPasswordService instead. */
|
||||
export type MasterKey = Opaque<SymmetricCryptoKey, "MasterKey">;
|
||||
/** @deprecated */
|
||||
export type PinKey = Opaque<SymmetricCryptoKey, "PinKey">;
|
||||
export type OrgKey = Opaque<SymmetricCryptoKey, "OrgKey">;
|
||||
export type ProviderKey = Opaque<SymmetricCryptoKey, "ProviderKey">;
|
||||
export type CipherKey = Opaque<SymmetricCryptoKey, "CipherKey">;
|
||||
|
||||
@@ -13,18 +13,19 @@ describe("buildCipherIcon", () => {
|
||||
},
|
||||
} as any as CipherView;
|
||||
|
||||
it.each([true, false])("handles android app URIs for showFavicon setting %s", (showFavicon) => {
|
||||
setUri("androidapp://test.example");
|
||||
// @TODO Uncomment once we have Android and iOS icons https://bitwarden.atlassian.net/browse/PM-29028
|
||||
// it.each([true, false])("handles android app URIs for showFavicon setting %s", (showFavicon) => {
|
||||
// setUri("androidapp://test.example");
|
||||
|
||||
const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon);
|
||||
// const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon);
|
||||
|
||||
expect(iconDetails).toEqual({
|
||||
icon: "bwi-android",
|
||||
image: null,
|
||||
fallbackImage: "",
|
||||
imageEnabled: showFavicon,
|
||||
});
|
||||
});
|
||||
// expect(iconDetails).toEqual({
|
||||
// icon: "bwi-android",
|
||||
// image: null,
|
||||
// fallbackImage: "",
|
||||
// imageEnabled: showFavicon,
|
||||
// });
|
||||
// });
|
||||
|
||||
it("does not mark as an android app if the protocol is not androidapp", () => {
|
||||
// This weird URI points to test.androidapp with a default port and path of /.example
|
||||
@@ -40,18 +41,18 @@ describe("buildCipherIcon", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([true, false])("handles ios app URIs for showFavicon setting %s", (showFavicon) => {
|
||||
setUri("iosapp://test.example");
|
||||
// @TODO Uncomment once we have Android and iOS icons https://bitwarden.atlassian.net/browse/PM-29028
|
||||
// it.each([true, false])("handles ios app URIs for showFavicon setting %s", (showFavicon) => {
|
||||
// setUri("iosapp://test.example");
|
||||
|
||||
const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon);
|
||||
|
||||
expect(iconDetails).toEqual({
|
||||
icon: "bwi-apple",
|
||||
image: null,
|
||||
fallbackImage: "",
|
||||
imageEnabled: showFavicon,
|
||||
});
|
||||
});
|
||||
// const iconDetails = buildCipherIcon(iconServerUrl, cipher, showFavicon);
|
||||
// expect(iconDetails).toEqual({
|
||||
// icon: "bwi-apple",
|
||||
// image: null,
|
||||
// fallbackImage: "",
|
||||
// imageEnabled: showFavicon,
|
||||
// });
|
||||
// });
|
||||
|
||||
it("does not mark as an ios app if the protocol is not iosapp", () => {
|
||||
// This weird URI points to test.iosapp with a default port and path of /.example
|
||||
|
||||
@@ -49,10 +49,12 @@ export function buildCipherIcon(
|
||||
let isWebsite = false;
|
||||
|
||||
if (hostnameUri.indexOf("androidapp://") === 0) {
|
||||
icon = "bwi-android";
|
||||
// @TODO Re-add once we have Android icon https://bitwarden.atlassian.net/browse/PM-29028
|
||||
// icon = "bwi-android";
|
||||
image = null;
|
||||
} else if (hostnameUri.indexOf("iosapp://") === 0) {
|
||||
icon = "bwi-apple";
|
||||
// @TODO Re-add once we have iOS icon https://bitwarden.atlassian.net/browse/PM-29028
|
||||
// icon = "bwi-apple";
|
||||
image = null;
|
||||
} else if (
|
||||
showFavicon &&
|
||||
|
||||
@@ -155,7 +155,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
if (this.login != null) {
|
||||
model.login = await this.login.decrypt(
|
||||
bypassValidation,
|
||||
userKeyOrOrgKey,
|
||||
cipherDecryptionKey,
|
||||
`Cipher Id: ${this.id}`,
|
||||
);
|
||||
}
|
||||
@@ -167,17 +167,20 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
break;
|
||||
case CipherType.Card:
|
||||
if (this.card != null) {
|
||||
model.card = await this.card.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
|
||||
model.card = await this.card.decrypt(cipherDecryptionKey, `Cipher Id: ${this.id}`);
|
||||
}
|
||||
break;
|
||||
case CipherType.Identity:
|
||||
if (this.identity != null) {
|
||||
model.identity = await this.identity.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
|
||||
model.identity = await this.identity.decrypt(
|
||||
cipherDecryptionKey,
|
||||
`Cipher Id: ${this.id}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case CipherType.SshKey:
|
||||
if (this.sshKey != null) {
|
||||
model.sshKey = await this.sshKey.decrypt(userKeyOrOrgKey, `Cipher Id: ${this.id}`);
|
||||
model.sshKey = await this.sshKey.decrypt(cipherDecryptionKey, `Cipher Id: ${this.id}`);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -188,7 +191,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
const attachments: AttachmentView[] = [];
|
||||
for (const attachment of this.attachments) {
|
||||
const decryptedAttachment = await attachment.decrypt(
|
||||
userKeyOrOrgKey,
|
||||
cipherDecryptionKey,
|
||||
`Cipher Id: ${this.id}`,
|
||||
);
|
||||
attachments.push(decryptedAttachment);
|
||||
@@ -199,7 +202,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
if (this.fields != null && this.fields.length > 0) {
|
||||
const fields: FieldView[] = [];
|
||||
for (const field of this.fields) {
|
||||
const decryptedField = await field.decrypt(userKeyOrOrgKey);
|
||||
const decryptedField = await field.decrypt(cipherDecryptionKey);
|
||||
fields.push(decryptedField);
|
||||
}
|
||||
model.fields = fields;
|
||||
@@ -208,7 +211,7 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
|
||||
if (this.passwordHistory != null && this.passwordHistory.length > 0) {
|
||||
const passwordHistory: PasswordHistoryView[] = [];
|
||||
for (const ph of this.passwordHistory) {
|
||||
const decryptedPh = await ph.decrypt(userKeyOrOrgKey);
|
||||
const decryptedPh = await ph.decrypt(cipherDecryptionKey);
|
||||
passwordHistory.push(decryptedPh);
|
||||
}
|
||||
model.passwordHistory = passwordHistory;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[hideCardWrapper]="hideCardWrapper"
|
||||
[hideBackgroundIllustration]="hideBackgroundIllustration"
|
||||
>
|
||||
<router-outlet slot="header-actions" name="header-actions"></router-outlet>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet slot="secondary" name="secondary"></router-outlet>
|
||||
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>
|
||||
|
||||
@@ -130,6 +130,15 @@ export class DefaultSecondaryOutletExampleComponent {}
|
||||
})
|
||||
export class DefaultEnvSelectorOutletExampleComponent {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-header-actions-outlet-example-component",
|
||||
template: "<p>Header Actions Outlet Example: <br> your header actions component goes here</p>",
|
||||
standalone: false,
|
||||
})
|
||||
export class DefaultHeaderActionsOutletExampleComponent {}
|
||||
|
||||
export const DefaultContentExample: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
@@ -171,6 +180,11 @@ export const DefaultContentExample: Story = {
|
||||
component: DefaultEnvSelectorOutletExampleComponent,
|
||||
outlet: "environment-selector",
|
||||
},
|
||||
{
|
||||
path: "",
|
||||
component: DefaultHeaderActionsOutletExampleComponent,
|
||||
outlet: "header-actions",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -5,13 +5,19 @@
|
||||
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
|
||||
}"
|
||||
>
|
||||
<a
|
||||
*ngIf="!hideLogo()"
|
||||
[routerLink]="['/']"
|
||||
class="tw-w-[200px] tw-block tw-mb-12 [&>*]:tw-align-top"
|
||||
>
|
||||
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
|
||||
</a>
|
||||
<div class="tw-flex tw-justify-between tw-items-center tw-w-full tw-mb-12">
|
||||
@if (!hideLogo()) {
|
||||
<a
|
||||
[routerLink]="['/']"
|
||||
class="tw-w-32 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
|
||||
>
|
||||
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
|
||||
</a>
|
||||
}
|
||||
<div class="tw-ms-auto">
|
||||
<ng-content select="[slot=header-actions]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
|
||||
@let iconInput = icon();
|
||||
@@ -25,7 +31,7 @@
|
||||
<bit-icon [icon]="iconInput"></bit-icon>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="title()">
|
||||
@if (title()) {
|
||||
<!-- Small screens -->
|
||||
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
|
||||
{{ title() }}
|
||||
@@ -34,9 +40,11 @@
|
||||
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
|
||||
{{ title() }}
|
||||
</h1>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<div *ngIf="subtitle()" class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
|
||||
@if (subtitle()) {
|
||||
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -57,18 +65,20 @@
|
||||
<ng-content select="[slot=secondary]"></ng-content>
|
||||
</div>
|
||||
|
||||
<footer *ngIf="!hideFooter()" class="tw-text-center tw-mt-4 sm:tw-mt-6">
|
||||
<div *ngIf="showReadonlyHostname()" bitTypography="body2">
|
||||
{{ "accessing" | i18n }} {{ hostname }}
|
||||
</div>
|
||||
<ng-container *ngIf="!showReadonlyHostname()">
|
||||
<ng-content select="[slot=environment-selector]"></ng-content>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!hideYearAndVersion">
|
||||
<div bitTypography="body2">© {{ year }} Bitwarden Inc.</div>
|
||||
<div bitTypography="body2">{{ version }}</div>
|
||||
</ng-container>
|
||||
</footer>
|
||||
@if (!hideFooter()) {
|
||||
<footer class="tw-text-center tw-mt-4 sm:tw-mt-6">
|
||||
@if (showReadonlyHostname()) {
|
||||
<div bitTypography="body2">{{ "accessing" | i18n }} {{ hostname }}</div>
|
||||
} @else {
|
||||
<ng-content select="[slot=environment-selector]"></ng-content>
|
||||
}
|
||||
|
||||
@if (!hideYearAndVersion) {
|
||||
<div bitTypography="body2">© {{ year }} Bitwarden Inc.</div>
|
||||
<div bitTypography="body2">{{ version }}</div>
|
||||
}
|
||||
</footer>
|
||||
}
|
||||
|
||||
@if (!hideBackgroundIllustration()) {
|
||||
<div
|
||||
|
||||
@@ -8,6 +8,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { AvatarModule } from "../avatar";
|
||||
import { ButtonModule } from "../button";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
@@ -23,6 +24,7 @@ type StoryArgs = AnonLayoutComponent & {
|
||||
showSecondary: boolean;
|
||||
useDefaultIcon: boolean;
|
||||
icon: Icon;
|
||||
includeHeaderActions: boolean;
|
||||
};
|
||||
|
||||
export default {
|
||||
@@ -30,7 +32,7 @@ export default {
|
||||
component: AnonLayoutComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ButtonModule, RouterModule],
|
||||
imports: [ButtonModule, RouterModule, AvatarModule],
|
||||
providers: [
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
@@ -76,6 +78,14 @@ export default {
|
||||
[hideFooter]="hideFooter"
|
||||
[hideBackgroundIllustration]="hideBackgroundIllustration"
|
||||
>
|
||||
@if (includeHeaderActions) {
|
||||
<div slot="header-actions" class="tw-flex tw-items-center tw-gap-2">
|
||||
<bit-avatar
|
||||
size="small"
|
||||
text="Bob Loblaw"
|
||||
></bit-avatar>
|
||||
</div>
|
||||
}
|
||||
<ng-container [ngSwitch]="contentLength">
|
||||
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-medium">Thin Content</div></div>
|
||||
<div *ngSwitchCase="'long'">
|
||||
@@ -116,7 +126,7 @@ export default {
|
||||
hideLogo: { control: "boolean" },
|
||||
hideFooter: { control: "boolean" },
|
||||
hideBackgroundIllustration: { control: "boolean" },
|
||||
|
||||
includeHeaderActions: { control: "boolean" },
|
||||
contentLength: {
|
||||
control: "radio",
|
||||
options: ["normal", "long", "thin"],
|
||||
@@ -138,6 +148,7 @@ export default {
|
||||
hideBackgroundIllustration: false,
|
||||
contentLength: "normal",
|
||||
showSecondary: false,
|
||||
includeHeaderActions: false,
|
||||
},
|
||||
} satisfies Meta<StoryArgs>;
|
||||
|
||||
@@ -188,6 +199,12 @@ export const SecondaryContent: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHeaderActions: Story = {
|
||||
args: {
|
||||
includeHeaderActions: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoTitle: Story = { args: { title: undefined } };
|
||||
|
||||
export const NoSubtitle: Story = { args: { subtitle: undefined } };
|
||||
|
||||
@@ -451,6 +451,24 @@ describe("ChipSelectComponent", () => {
|
||||
|
||||
expect(disabledMenuItem?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle writeValue called before options are initialized", async () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
|
||||
component["rootTree"] = null;
|
||||
|
||||
component.writeValue("opt1");
|
||||
|
||||
expect(component["pendingValue"]).toBe("opt1");
|
||||
expect(component["selectedOption"]).toBeUndefined();
|
||||
|
||||
testApp.options.set(testOptions);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(component["selectedOption"]?.value).toBe("opt1");
|
||||
expect(component["pendingValue"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -100,10 +100,21 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
|
||||
/** Tree constructed from `this.options` */
|
||||
private rootTree?: ChipSelectOption<T> | null;
|
||||
|
||||
/** Store the pending value when writeValue is called before options are initialized */
|
||||
private pendingValue?: T;
|
||||
|
||||
constructor() {
|
||||
// Initialize the root tree whenever options change
|
||||
effect(() => {
|
||||
this.initializeRootTree(this.options());
|
||||
|
||||
// If there's a pending value, apply it now that options are available
|
||||
if (this.pendingValue !== undefined) {
|
||||
this.selectedOption = this.findOption(this.rootTree, this.pendingValue);
|
||||
this.setOrResetRenderedOptions();
|
||||
this.pendingValue = undefined;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the first menu item when menuItems change (e.g., navigating submenus)
|
||||
@@ -255,6 +266,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
|
||||
|
||||
/** Implemented as part of NG_VALUE_ACCESSOR */
|
||||
writeValue(obj: T): void {
|
||||
// If rootTree is not yet initialized, store the value to apply it later
|
||||
if (!this.rootTree) {
|
||||
this.pendingValue = obj;
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectedOption = this.findOption(this.rootTree, obj);
|
||||
this.setOrResetRenderedOptions();
|
||||
// OnPush components require manual change detection when writeValue() is called
|
||||
|
||||
@@ -43,9 +43,10 @@ class CustomBlockScrollStrategy implements ScrollStrategy {
|
||||
detach() {}
|
||||
}
|
||||
|
||||
export abstract class DialogRef<R = unknown, C = unknown>
|
||||
implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance">
|
||||
{
|
||||
export abstract class DialogRef<R = unknown, C = unknown> implements Pick<
|
||||
CdkDialogRef<R, C>,
|
||||
"close" | "closed" | "disableClose" | "componentInstance"
|
||||
> {
|
||||
abstract readonly isDrawer?: boolean;
|
||||
|
||||
// --- From CdkDialogRef ---
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<ng-container *ngTemplateOutlet="labelContent"></ng-container>
|
||||
</label>
|
||||
<div
|
||||
class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid"
|
||||
class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid tw-items-center"
|
||||
[ngClass]="{
|
||||
'tw-border-secondary-300/50 tw-border-b tw-pb-[2px]': !disableReadOnlyBorder,
|
||||
'tw-border-transparent tw-pb-[3px]': disableReadOnlyBorder,
|
||||
|
||||
@@ -84,6 +84,11 @@ export const Default: StoryObj<NavGroupComponent> = {
|
||||
<bit-nav-item text="Child B"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group open="true" text="Lorem Ipsum (Button)" icon="bwi-filter">
|
||||
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
|
||||
<bit-nav-item text="Child B"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-side-nav>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -2,16 +2,12 @@
|
||||
@let open = sideNavService.open$ | async;
|
||||
@if (open || icon()) {
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'padding-inline-start': navItemIndentationPadding(),
|
||||
}"
|
||||
[style.padding-inline-start]="navItemIndentationPadding()"
|
||||
class="tw-relative tw-rounded-md tw-h-10"
|
||||
[ngClass]="[
|
||||
showActiveStyles
|
||||
? 'tw-bg-background-alt4'
|
||||
: 'tw-bg-background-alt3 hover:tw-bg-hover-contrast',
|
||||
fvwStyles$ | async,
|
||||
]"
|
||||
[class.tw-bg-background-alt4]="showActiveStyles"
|
||||
[class.tw-bg-background-alt3]="!showActiveStyles"
|
||||
[class.hover:tw-bg-hover-contrast]="!showActiveStyles"
|
||||
[class]="fvwStyles$ | async"
|
||||
>
|
||||
<div class="tw-relative tw-flex tw-items-center tw-h-full">
|
||||
@if (open) {
|
||||
@@ -26,13 +22,12 @@
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[ngClass]="[
|
||||
variant() === 'tree' || treeDepth() > 0 ? 'tw-py-0' : 'tw-py-2',
|
||||
open ? 'tw-pe-4' : 'tw-text-center',
|
||||
]"
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
||||
[ngClass]="{ 'tw-justify-center': !open }"
|
||||
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
|
||||
[class.tw-text-center]="!open"
|
||||
[class.tw-justify-center]="!open"
|
||||
>
|
||||
@if (icon()) {
|
||||
<i
|
||||
@@ -53,7 +48,7 @@
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
<a
|
||||
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
[ngClass]="{ 'tw-ps-0': variant() === 'tree' || treeDepth() > 0 }"
|
||||
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
data-fvw
|
||||
[routerLink]="route()"
|
||||
[relativeTo]="relativeTo()"
|
||||
@@ -74,7 +69,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="tw-size-full tw-px-4 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
[ngClass]="open ? 'tw-pe-3' : 'tw-pe-4'"
|
||||
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
data-fvw
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
|
||||
@@ -7,8 +7,8 @@ import { safeProvider, SafeProvider } from "@bitwarden/angular/platform/utils/sa
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.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";
|
||||
@@ -84,7 +84,7 @@ export const ImporterProviders: SafeProvider[] = [
|
||||
CollectionService,
|
||||
KeyService,
|
||||
EncryptService,
|
||||
PinServiceAbstraction,
|
||||
KeyGenerationService,
|
||||
AccountService,
|
||||
RestrictedItemTypesService,
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user