mirror of
https://github.com/bitwarden/browser
synced 2026-02-27 01:53:23 +00:00
Merge branch 'main' into auth/add-logout-reason
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story } from "@storybook/addon-docs";
|
||||
import { Meta, Story } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./input-password.stories.ts";
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { importProvidersFrom } from "@angular/core";
|
||||
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
|
||||
import { action } from "@storybook/addon-actions";
|
||||
import { Meta, StoryObj, applicationConfig } from "@storybook/angular";
|
||||
import { of } from "rxjs";
|
||||
import { action } from "storybook/actions";
|
||||
import { ZXCVBNResult } from "zxcvbn";
|
||||
|
||||
import { AuditService } from "@bitwarden/common/abstractions/audit.service";
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
// Mock asUuid to return the input value for test consistency
|
||||
jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk.service", () => ({
|
||||
asUuid: (x: any) => x,
|
||||
}));
|
||||
|
||||
import { DestroyRef } from "@angular/core";
|
||||
import { FormBuilder } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import {
|
||||
LoginEmailServiceAbstraction,
|
||||
LogoutService,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import { SignedSecurityState } from "@bitwarden/common/key-management/types";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { AnonLayoutWrapperDataService, DialogService, ToastService } from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { LoginDecryptionOptionsComponent } from "./login-decryption-options.component";
|
||||
import { LoginDecryptionOptionsService } from "./login-decryption-options.service";
|
||||
|
||||
describe("LoginDecryptionOptionsComponent", () => {
|
||||
let component: LoginDecryptionOptionsComponent;
|
||||
let accountService: MockProxy<AccountService>;
|
||||
let anonLayoutWrapperDataService: MockProxy<AnonLayoutWrapperDataService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let destroyRef: MockProxy<DestroyRef>;
|
||||
let deviceTrustService: MockProxy<DeviceTrustServiceAbstraction>;
|
||||
let dialogService: MockProxy<DialogService>;
|
||||
let formBuilder: FormBuilder;
|
||||
let i18nService: MockProxy<I18nService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let loginDecryptionOptionsService: MockProxy<LoginDecryptionOptionsService>;
|
||||
let loginEmailService: MockProxy<LoginEmailServiceAbstraction>;
|
||||
let messagingService: MockProxy<MessagingService>;
|
||||
let organizationApiService: MockProxy<OrganizationApiServiceAbstraction>;
|
||||
let passwordResetEnrollmentService: MockProxy<PasswordResetEnrollmentServiceAbstraction>;
|
||||
let platformUtilsService: MockProxy<PlatformUtilsService>;
|
||||
let router: MockProxy<Router>;
|
||||
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
let toastService: MockProxy<ToastService>;
|
||||
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let validationService: MockProxy<ValidationService>;
|
||||
let logoutService: MockProxy<LogoutService>;
|
||||
let registerSdkService: MockProxy<RegisterSdkService>;
|
||||
let securityStateService: MockProxy<SecurityStateService>;
|
||||
let appIdService: MockProxy<AppIdService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<any>;
|
||||
|
||||
const mockUserId = "user-id-123" as UserId;
|
||||
const mockEmail = "test@example.com";
|
||||
const mockOrgId = "org-id-456";
|
||||
|
||||
beforeEach(() => {
|
||||
accountService = mock<AccountService>();
|
||||
anonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
||||
apiService = mock<ApiService>();
|
||||
destroyRef = mock<DestroyRef>();
|
||||
deviceTrustService = mock<DeviceTrustServiceAbstraction>();
|
||||
dialogService = mock<DialogService>();
|
||||
formBuilder = new FormBuilder();
|
||||
i18nService = mock<I18nService>();
|
||||
keyService = mock<KeyService>();
|
||||
loginDecryptionOptionsService = mock<LoginDecryptionOptionsService>();
|
||||
loginEmailService = mock<LoginEmailServiceAbstraction>();
|
||||
messagingService = mock<MessagingService>();
|
||||
organizationApiService = mock<OrganizationApiServiceAbstraction>();
|
||||
passwordResetEnrollmentService = mock<PasswordResetEnrollmentServiceAbstraction>();
|
||||
platformUtilsService = mock<PlatformUtilsService>();
|
||||
router = mock<Router>();
|
||||
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
toastService = mock<ToastService>();
|
||||
userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
||||
validationService = mock<ValidationService>();
|
||||
logoutService = mock<LogoutService>();
|
||||
registerSdkService = mock<RegisterSdkService>();
|
||||
securityStateService = mock<SecurityStateService>();
|
||||
appIdService = mock<AppIdService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountCryptographicStateService = mock();
|
||||
|
||||
// Setup default mocks
|
||||
accountService.activeAccount$ = new BehaviorSubject({
|
||||
id: mockUserId,
|
||||
email: mockEmail,
|
||||
name: "Test User",
|
||||
emailVerified: true,
|
||||
creationDate: new Date(),
|
||||
});
|
||||
platformUtilsService.getClientType.mockReturnValue(ClientType.Browser);
|
||||
deviceTrustService.getShouldTrustDevice.mockResolvedValue(true);
|
||||
i18nService.t.mockImplementation((key: string) => key);
|
||||
|
||||
component = new LoginDecryptionOptionsComponent(
|
||||
accountService,
|
||||
anonLayoutWrapperDataService,
|
||||
apiService,
|
||||
destroyRef,
|
||||
deviceTrustService,
|
||||
dialogService,
|
||||
formBuilder,
|
||||
i18nService,
|
||||
keyService,
|
||||
loginDecryptionOptionsService,
|
||||
loginEmailService,
|
||||
messagingService,
|
||||
organizationApiService,
|
||||
passwordResetEnrollmentService,
|
||||
platformUtilsService,
|
||||
router,
|
||||
ssoLoginService,
|
||||
toastService,
|
||||
userDecryptionOptionsService,
|
||||
validationService,
|
||||
logoutService,
|
||||
registerSdkService,
|
||||
securityStateService,
|
||||
appIdService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("createUser with feature flag enabled", () => {
|
||||
let mockPostKeysForTdeRegistration: jest.Mock;
|
||||
let mockRegistration: any;
|
||||
let mockAuth: any;
|
||||
let mockSdkValue: any;
|
||||
let mockSdkRef: any;
|
||||
let mockSdk: any;
|
||||
let mockDeviceKey: string;
|
||||
let mockDeviceKeyObj: SymmetricCryptoKey;
|
||||
let mockUserKeyBytes: Uint8Array;
|
||||
let mockPrivateKey: string;
|
||||
let mockSignedPublicKey: string;
|
||||
let mockSigningKey: string;
|
||||
let mockSecurityState: SignedSecurityState;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Mock asUuid to return the input value for test consistency
|
||||
jest.mock("@bitwarden/common/platform/abstractions/sdk/sdk.service", () => ({
|
||||
asUuid: (x: any) => x,
|
||||
}));
|
||||
(Symbol as any).dispose = Symbol("dispose");
|
||||
|
||||
mockPrivateKey = "mock-private-key";
|
||||
mockSignedPublicKey = "mock-signed-public-key";
|
||||
mockSigningKey = "mock-signing-key";
|
||||
mockSecurityState = {
|
||||
signature: "mock-signature",
|
||||
payload: {
|
||||
version: 2,
|
||||
timestamp: Date.now(),
|
||||
privateKeyHash: "mock-hash",
|
||||
},
|
||||
} as any;
|
||||
const deviceKeyBytes = new Uint8Array(32).fill(5);
|
||||
mockDeviceKey = Buffer.from(deviceKeyBytes).toString("base64");
|
||||
mockDeviceKeyObj = SymmetricCryptoKey.fromString(mockDeviceKey);
|
||||
mockUserKeyBytes = new Uint8Array(64);
|
||||
|
||||
mockPostKeysForTdeRegistration = jest.fn().mockResolvedValue({
|
||||
account_cryptographic_state: {
|
||||
V2: {
|
||||
private_key: mockPrivateKey,
|
||||
signed_public_key: mockSignedPublicKey,
|
||||
signing_key: mockSigningKey,
|
||||
security_state: mockSecurityState,
|
||||
},
|
||||
},
|
||||
device_key: mockDeviceKey,
|
||||
user_key: mockUserKeyBytes,
|
||||
});
|
||||
|
||||
mockRegistration = {
|
||||
post_keys_for_tde_registration: mockPostKeysForTdeRegistration,
|
||||
};
|
||||
|
||||
mockAuth = {
|
||||
registration: jest.fn().mockReturnValue(mockRegistration),
|
||||
};
|
||||
|
||||
mockSdkValue = {
|
||||
auth: jest.fn().mockReturnValue(mockAuth),
|
||||
};
|
||||
|
||||
mockSdkRef = {
|
||||
value: mockSdkValue,
|
||||
[Symbol.dispose]: jest.fn(),
|
||||
};
|
||||
|
||||
mockSdk = {
|
||||
take: jest.fn().mockReturnValue(mockSdkRef),
|
||||
};
|
||||
|
||||
registerSdkService.registerClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
|
||||
|
||||
// Setup for new user state
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
of({
|
||||
trustedDeviceOption: {
|
||||
hasAdminApproval: false,
|
||||
hasLoginApprovingDevice: false,
|
||||
hasManageResetPasswordPermission: false,
|
||||
isTdeOffboarding: false,
|
||||
},
|
||||
hasMasterPassword: false,
|
||||
keyConnectorOption: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
ssoLoginService.getActiveUserOrganizationSsoIdentifier.mockResolvedValue("org-identifier");
|
||||
organizationApiService.getAutoEnrollStatus.mockResolvedValue({
|
||||
id: mockOrgId,
|
||||
resetPasswordEnabled: true,
|
||||
} as any);
|
||||
|
||||
// Initialize component to set up new user state
|
||||
await component.ngOnInit();
|
||||
});
|
||||
|
||||
it("should use SDK v2 registration when feature flag is enabled", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
loginDecryptionOptionsService.handleCreateUserSuccess.mockResolvedValue(undefined);
|
||||
router.navigate.mockResolvedValue(true);
|
||||
appIdService.getAppId.mockResolvedValue("mock-app-id");
|
||||
organizationApiService.getKeys.mockResolvedValue({
|
||||
publicKey: "mock-org-public-key",
|
||||
privateKey: "mock-org-private-key",
|
||||
} as any);
|
||||
|
||||
// Act
|
||||
await component["createUser"]();
|
||||
|
||||
// Assert
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM27279_V2RegistrationTdeJit,
|
||||
);
|
||||
expect(appIdService.getAppId).toHaveBeenCalled();
|
||||
expect(organizationApiService.getKeys).toHaveBeenCalledWith(mockOrgId);
|
||||
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(mockUserId);
|
||||
|
||||
// Verify SDK registration was called with correct parameters
|
||||
expect(mockSdkValue.auth).toHaveBeenCalled();
|
||||
expect(mockAuth.registration).toHaveBeenCalled();
|
||||
expect(mockPostKeysForTdeRegistration).toHaveBeenCalledWith({
|
||||
org_id: mockOrgId,
|
||||
org_public_key: "mock-org-public-key",
|
||||
user_id: mockUserId,
|
||||
device_identifier: "mock-app-id",
|
||||
trust_device: true,
|
||||
});
|
||||
|
||||
const expectedDeviceKey = mockDeviceKeyObj;
|
||||
const expectedUserKey = new SymmetricCryptoKey(new Uint8Array(mockUserKeyBytes));
|
||||
|
||||
// Verify keys were set
|
||||
expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId);
|
||||
expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId);
|
||||
expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId);
|
||||
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
|
||||
mockSecurityState,
|
||||
mockUserId,
|
||||
);
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
V2: {
|
||||
private_key: mockPrivateKey,
|
||||
signed_public_key: mockSignedPublicKey,
|
||||
signing_key: mockSigningKey,
|
||||
security_state: mockSecurityState,
|
||||
},
|
||||
}),
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(validationService.showError).not.toHaveBeenCalled();
|
||||
|
||||
// Verify device and user keys were persisted
|
||||
expect(deviceTrustService.setDeviceKey).toHaveBeenCalledWith(
|
||||
mockUserId,
|
||||
expect.any(SymmetricCryptoKey),
|
||||
);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(
|
||||
expect.any(SymmetricCryptoKey),
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const [, deviceKeyArg] = deviceTrustService.setDeviceKey.mock.calls[0];
|
||||
const [userKeyArg] = keyService.setUserKey.mock.calls[0];
|
||||
|
||||
expect((deviceKeyArg as SymmetricCryptoKey).keyB64).toBe(expectedDeviceKey.keyB64);
|
||||
expect((userKeyArg as SymmetricCryptoKey).keyB64).toBe(expectedUserKey.keyB64);
|
||||
|
||||
// Verify success toast and navigation
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: "accountSuccessfullyCreated",
|
||||
});
|
||||
expect(loginDecryptionOptionsService.handleCreateUserSuccess).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/tabs/vault"]);
|
||||
});
|
||||
|
||||
it("should use legacy registration when feature flag is disabled", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const mockPublicKey = "mock-public-key";
|
||||
const mockPrivateKey = {
|
||||
encryptedString: "mock-encrypted-private-key",
|
||||
} as any;
|
||||
|
||||
keyService.initAccount.mockResolvedValue({
|
||||
publicKey: mockPublicKey,
|
||||
privateKey: mockPrivateKey,
|
||||
} as any);
|
||||
|
||||
apiService.postAccountKeys.mockResolvedValue(undefined);
|
||||
passwordResetEnrollmentService.enroll.mockResolvedValue(undefined);
|
||||
deviceTrustService.trustDevice.mockResolvedValue(undefined);
|
||||
loginDecryptionOptionsService.handleCreateUserSuccess.mockResolvedValue(undefined);
|
||||
router.navigate.mockResolvedValue(true);
|
||||
|
||||
// Act
|
||||
await component["createUser"]();
|
||||
|
||||
// Assert
|
||||
expect(configService.getFeatureFlag).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM27279_V2RegistrationTdeJit,
|
||||
);
|
||||
expect(keyService.initAccount).toHaveBeenCalledWith(mockUserId);
|
||||
expect(apiService.postAccountKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
publicKey: mockPublicKey,
|
||||
encryptedPrivateKey: mockPrivateKey.encryptedString,
|
||||
}),
|
||||
);
|
||||
expect(passwordResetEnrollmentService.enroll).toHaveBeenCalledWith(mockOrgId);
|
||||
expect(deviceTrustService.trustDevice).toHaveBeenCalledWith(mockUserId);
|
||||
|
||||
// Verify success toast
|
||||
expect(toastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
title: null,
|
||||
message: "accountSuccessfullyCreated",
|
||||
});
|
||||
|
||||
// Verify navigation
|
||||
expect(loginDecryptionOptionsService.handleCreateUserSuccess).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(["/tabs/vault"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,17 @@ import { Component, DestroyRef, OnInit } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { FormBuilder, FormControl, ReactiveFormsModule } from "@angular/forms";
|
||||
import { Router } from "@angular/router";
|
||||
import { catchError, defer, firstValueFrom, from, map, of, switchMap, throwError } from "rxjs";
|
||||
import {
|
||||
catchError,
|
||||
concatMap,
|
||||
defer,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
of,
|
||||
switchMap,
|
||||
throwError,
|
||||
} from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import {
|
||||
@@ -20,13 +30,27 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
|
||||
import { PasswordResetEnrollmentServiceAbstraction } from "@bitwarden/common/auth/abstractions/password-reset-enrollment.service.abstraction";
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
import {
|
||||
SignedPublicKey,
|
||||
SignedSecurityState,
|
||||
WrappedSigningKey,
|
||||
} from "@bitwarden/common/key-management/types";
|
||||
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
|
||||
import { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { RegisterSdkService } from "@bitwarden/common/platform/abstractions/sdk/register-sdk.service";
|
||||
import { asUuid } from "@bitwarden/common/platform/abstractions/sdk/sdk.service";
|
||||
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DeviceKey, UserKey } from "@bitwarden/common/types/key";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
@@ -40,6 +64,7 @@ import {
|
||||
TypographyModule,
|
||||
} from "@bitwarden/components";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
import { OrganizationId as SdkOrganizationId, UserId as SdkUserId } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { LoginDecryptionOptionsService } from "./login-decryption-options.service";
|
||||
|
||||
@@ -112,6 +137,11 @@ export class LoginDecryptionOptionsComponent implements OnInit {
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private validationService: ValidationService,
|
||||
private logoutService: LogoutService,
|
||||
private registerSdkService: RegisterSdkService,
|
||||
private securityStateService: SecurityStateService,
|
||||
private appIdService: AppIdService,
|
||||
private configService: ConfigService,
|
||||
private accountCryptographicStateService: AccountCryptographicStateService,
|
||||
) {
|
||||
this.clientType = this.platformUtilsService.getClientType();
|
||||
}
|
||||
@@ -135,7 +165,7 @@ export class LoginDecryptionOptionsComponent implements OnInit {
|
||||
|
||||
try {
|
||||
const userDecryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(this.activeAccountId),
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -251,9 +281,85 @@ export class LoginDecryptionOptionsComponent implements OnInit {
|
||||
}
|
||||
|
||||
try {
|
||||
const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId);
|
||||
const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString);
|
||||
await this.apiService.postAccountKeys(keysRequest);
|
||||
const useSdkV2Creation = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PM27279_V2RegistrationTdeJit,
|
||||
);
|
||||
if (useSdkV2Creation) {
|
||||
const deviceIdentifier = await this.appIdService.getAppId();
|
||||
const userId = this.activeAccountId;
|
||||
const organizationId = this.newUserOrgId;
|
||||
|
||||
const orgKeyResponse = await this.organizationApiService.getKeys(organizationId);
|
||||
const register_result = await firstValueFrom(
|
||||
this.registerSdkService.registerClient$(userId).pipe(
|
||||
concatMap(async (sdk) => {
|
||||
if (!sdk) {
|
||||
throw new Error("SDK not available");
|
||||
}
|
||||
|
||||
using ref = sdk.take();
|
||||
return await ref.value
|
||||
.auth()
|
||||
.registration()
|
||||
.post_keys_for_tde_registration({
|
||||
org_id: asUuid<SdkOrganizationId>(organizationId),
|
||||
org_public_key: orgKeyResponse.publicKey,
|
||||
user_id: asUuid<SdkUserId>(userId),
|
||||
device_identifier: deviceIdentifier,
|
||||
trust_device: this.formGroup.value.rememberDevice,
|
||||
});
|
||||
}),
|
||||
),
|
||||
);
|
||||
// The keys returned here can only be v2 keys, since the SDK only implements returning V2 keys.
|
||||
if ("V1" in register_result.account_cryptographic_state) {
|
||||
throw new Error("Unexpected V1 account cryptographic state");
|
||||
}
|
||||
|
||||
// Note: When SDK state management matures, these should be moved into post_keys_for_tde_registration
|
||||
// Set account cryptography state
|
||||
await this.accountCryptographicStateService.setAccountCryptographicState(
|
||||
register_result.account_cryptographic_state,
|
||||
userId,
|
||||
);
|
||||
// Legacy individual states
|
||||
await this.keyService.setPrivateKey(
|
||||
register_result.account_cryptographic_state.V2.private_key,
|
||||
userId,
|
||||
);
|
||||
await this.keyService.setSignedPublicKey(
|
||||
register_result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey,
|
||||
userId,
|
||||
);
|
||||
await this.keyService.setUserSigningKey(
|
||||
register_result.account_cryptographic_state.V2.signing_key as WrappedSigningKey,
|
||||
userId,
|
||||
);
|
||||
await this.securityStateService.setAccountSecurityState(
|
||||
register_result.account_cryptographic_state.V2.security_state as SignedSecurityState,
|
||||
userId,
|
||||
);
|
||||
|
||||
// TDE unlock
|
||||
await this.deviceTrustService.setDeviceKey(
|
||||
userId,
|
||||
SymmetricCryptoKey.fromString(register_result.device_key) as DeviceKey,
|
||||
);
|
||||
|
||||
// Set user key - user is now unlocked
|
||||
await this.keyService.setUserKey(
|
||||
SymmetricCryptoKey.fromString(register_result.user_key) as UserKey,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
const { publicKey, privateKey } = await this.keyService.initAccount(this.activeAccountId);
|
||||
const keysRequest = new KeysRequest(publicKey, privateKey.encryptedString);
|
||||
await this.apiService.postAccountKeys(keysRequest);
|
||||
await this.passwordResetEnrollmentService.enroll(this.newUserOrgId);
|
||||
if (this.formGroup.value.rememberDevice) {
|
||||
await this.deviceTrustService.trustDevice(this.activeAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
@@ -261,12 +367,6 @@ export class LoginDecryptionOptionsComponent implements OnInit {
|
||||
message: this.i18nService.t("accountSuccessfullyCreated"),
|
||||
});
|
||||
|
||||
await this.passwordResetEnrollmentService.enroll(this.newUserOrgId);
|
||||
|
||||
if (this.formGroup.value.rememberDevice) {
|
||||
await this.deviceTrustService.trustDevice(this.activeAccountId);
|
||||
}
|
||||
|
||||
await this.loginDecryptionOptionsService.handleCreateUserSuccess();
|
||||
|
||||
if (this.clientType === ClientType.Desktop) {
|
||||
|
||||
@@ -822,7 +822,7 @@ export class LoginViaAuthRequestComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
private async handleSuccessfulLoginNavigation(userId: UserId) {
|
||||
await this.loginSuccessHandlerService.run(userId);
|
||||
await this.loginSuccessHandlerService.run(userId, null);
|
||||
await this.router.navigate(["vault"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,8 +381,26 @@ 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);
|
||||
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
|
||||
|
||||
// Determine where to send the user next
|
||||
// The AuthGuard will handle routing to change-password based on state
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -152,9 +152,7 @@ export class NewDeviceVerificationComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
this.loginSuccessHandlerService.run(authResult.userId);
|
||||
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
|
||||
|
||||
// TODO: PM-22663 use the new service to handle routing.
|
||||
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
|
||||
@@ -206,7 +206,10 @@ export class RegistrationFinishComponent implements OnInit, OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.loginSuccessHandlerService.run(authenticationResult.userId);
|
||||
await this.loginSuccessHandlerService.run(
|
||||
authenticationResult.userId,
|
||||
authenticationResult.masterPassword ?? null,
|
||||
);
|
||||
|
||||
if (this.premiumInterest) {
|
||||
await this.premiumInterestStateService.setPremiumInterest(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Meta, Story, Controls } from "@storybook/addon-docs";
|
||||
import { Meta, Story, Controls } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./registration-start.stories";
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@ export class SsoComponent implements OnInit {
|
||||
|
||||
// Everything after the 2FA check is considered a successful login
|
||||
// Just have to figure out where to send the user
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
await this.loginSuccessHandlerService.run(authResult.userId, null);
|
||||
|
||||
// Save off the OrgSsoIdentifier for use in the TDE flows (or elsewhere)
|
||||
// - TDE login decryption options component
|
||||
@@ -460,7 +460,7 @@ export class SsoComponent implements OnInit {
|
||||
|
||||
// must come after 2fa check since user decryption options aren't available if 2fa is required
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(authResult.userId),
|
||||
);
|
||||
|
||||
const tdeEnabled = userDecryptionOpts.trustedDeviceOption
|
||||
@@ -478,7 +478,7 @@ export class SsoComponent implements OnInit {
|
||||
!userDecryptionOpts.hasMasterPassword &&
|
||||
userDecryptionOpts.keyConnectorOption === undefined;
|
||||
|
||||
if (requireSetPassword || authResult.resetMasterPassword) {
|
||||
if (requireSetPassword) {
|
||||
// Change implies going no password -> password in this case
|
||||
return await this.handleChangePasswordRequired(orgSsoIdentifier);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -176,7 +176,9 @@ describe("TwoFactorAuthComponent", () => {
|
||||
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
|
||||
mockUserDecryptionOpts.withMasterPassword,
|
||||
);
|
||||
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
|
||||
mockUserDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
selectedUserDecryptionOptions,
|
||||
);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [TestTwoFactorComponent],
|
||||
@@ -419,6 +421,7 @@ describe("TwoFactorAuthComponent", () => {
|
||||
keyConnectorUrl:
|
||||
mockUserDecryptionOpts.noMasterPasswordWithKeyConnector.keyConnectorOption!
|
||||
.keyConnectorUrl,
|
||||
organizationSsoIdentifier: "test-sso-id",
|
||||
}),
|
||||
);
|
||||
const authResult = new AuthResult();
|
||||
|
||||
@@ -450,7 +450,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// User is fully logged in so handle any post login logic before executing navigation
|
||||
await this.loginSuccessHandlerService.run(authResult.userId);
|
||||
await this.loginSuccessHandlerService.run(authResult.userId, authResult.masterPassword);
|
||||
|
||||
// Save off the OrgSsoIdentifier for use in the TDE flows
|
||||
// - TDE login decryption options component
|
||||
@@ -473,7 +473,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
const userDecryptionOpts = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(authResult.userId),
|
||||
);
|
||||
|
||||
const tdeEnabled = await this.isTrustedDeviceEncEnabled(userDecryptionOpts.trustedDeviceOption);
|
||||
@@ -487,7 +487,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
|
||||
!userDecryptionOpts.hasMasterPassword && userDecryptionOpts.keyConnectorOption === undefined;
|
||||
|
||||
// New users without a master password must set a master password before advancing.
|
||||
if (requireSetPassword || authResult.resetMasterPassword) {
|
||||
if (requireSetPassword) {
|
||||
// Change implies going no password -> password in this case
|
||||
return await this.handleChangePasswordRequired(this.orgSsoIdentifier);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export abstract class LoginSuccessHandlerService {
|
||||
* Runs any service calls required after a successful login.
|
||||
* Service calls that should be included in this method are only those required to be awaited after successful login.
|
||||
* @param userId The user id.
|
||||
* @param masterPassword The master password, if available. Null when logging in with SSO or other non-master-password methods.
|
||||
*/
|
||||
abstract run(userId: UserId): Promise<void>;
|
||||
abstract run(userId: UserId, masterPassword: string | null): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,45 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { UserDecryptionOptions } from "../models";
|
||||
|
||||
/**
|
||||
* Public service for reading user decryption options.
|
||||
* For use in components and services that need to evaluate user decryption settings.
|
||||
*/
|
||||
export abstract class UserDecryptionOptionsServiceAbstraction {
|
||||
/**
|
||||
* Returns what decryption options are available for the current user.
|
||||
* @remark This is sent from the server on authentication.
|
||||
* Returns the user decryption options for the given user id.
|
||||
* Will only emit when options are set (does not emit null/undefined
|
||||
* for an unpopulated state), and should not be called in an unauthenticated context.
|
||||
* @param userId The user id to check.
|
||||
*/
|
||||
abstract userDecryptionOptions$: Observable<UserDecryptionOptions>;
|
||||
abstract userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions>;
|
||||
/**
|
||||
* Uses user decryption options to determine if current user has a master password.
|
||||
* @remark This is sent from the server, and does not indicate if the master password
|
||||
* was used to login and/or if a master key is saved locally.
|
||||
*/
|
||||
abstract hasMasterPassword$: Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Returns the user decryption options for the given user id.
|
||||
* @param userId The user id to check.
|
||||
*/
|
||||
abstract userDecryptionOptionsById$(userId: string): Observable<UserDecryptionOptions>;
|
||||
abstract hasMasterPasswordById$(userId: UserId): Observable<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal service for managing user decryption options.
|
||||
* For use only in authentication flows that need to update decryption options
|
||||
* (e.g., login strategies). Extends consumer methods from {@link UserDecryptionOptionsServiceAbstraction}.
|
||||
* @remarks Most consumers should use UserDecryptionOptionsServiceAbstraction instead.
|
||||
*/
|
||||
export abstract class InternalUserDecryptionOptionsServiceAbstraction extends UserDecryptionOptionsServiceAbstraction {
|
||||
/**
|
||||
* Sets the current decryption options for the user, contains the current configuration
|
||||
* Sets the current decryption options for the user. Contains the current configuration
|
||||
* of the users account related to how they can decrypt their vault.
|
||||
* @remark Intended to be used when user decryption options are received from server, does
|
||||
* not update the server. Consider syncing instead of updating locally.
|
||||
* @param userDecryptionOptions Current user decryption options received from server.
|
||||
*/
|
||||
abstract setUserDecryptionOptions(userDecryptionOptions: UserDecryptionOptions): Promise<void>;
|
||||
abstract setUserDecryptionOptionsById(
|
||||
userId: UserId,
|
||||
userDecryptionOptions: UserDecryptionOptions,
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TokenService } from "@bitwarden/common/auth/abstractions/token.service"
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
@@ -22,7 +23,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { makeEncString, FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
@@ -57,6 +58,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
|
||||
|
||||
const mockUserId = Utils.newGuid() as UserId;
|
||||
let accountService: FakeAccountService;
|
||||
@@ -94,6 +96,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
|
||||
accountService = mockAccountServiceWith(mockUserId);
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
@@ -125,6 +128,7 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
|
||||
tokenResponse = identityTokenResponseFactory();
|
||||
@@ -208,4 +212,41 @@ describe("AuthRequestLoginStrategy", () => {
|
||||
// trustDeviceIfRequired should be called
|
||||
expect(deviceTrustService.trustDeviceIfRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
|
||||
const accountKeysData = {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
publicKey: "testPublicKey",
|
||||
wrappedPrivateKey: "testPrivateKey",
|
||||
},
|
||||
};
|
||||
|
||||
tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.key = makeEncString("mockEncryptedUserKey");
|
||||
// Add accountKeysResponseModel to the response
|
||||
(tokenResponse as any).accountKeysResponseModel = {
|
||||
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
|
||||
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
masterPasswordService.masterKeySubject.next(decMasterKey);
|
||||
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(decUserKey);
|
||||
|
||||
await authRequestLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
|
||||
{
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
},
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,6 +128,12 @@ export class AuthRequestLoginStrategy extends LoginStrategy {
|
||||
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
|
||||
userId,
|
||||
);
|
||||
if (response.accountKeysResponseModel) {
|
||||
await this.accountCryptographicStateService.setAccountCryptographicState(
|
||||
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
exportCache(): CacheData {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/resp
|
||||
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
@@ -101,7 +102,6 @@ export function identityTokenResponseFactory(
|
||||
KdfIterations: kdfIterations,
|
||||
Key: encryptedUserKey,
|
||||
PrivateKey: privateKey,
|
||||
ResetMasterPassword: false,
|
||||
access_token: accessToken,
|
||||
expires_in: 3600,
|
||||
refresh_token: refreshToken,
|
||||
@@ -137,6 +137,7 @@ describe("LoginStrategy", () => {
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
|
||||
|
||||
let passwordLoginStrategy: PasswordLoginStrategy;
|
||||
let credentials: PasswordLoginCredentials;
|
||||
@@ -163,6 +164,7 @@ describe("LoginStrategy", () => {
|
||||
billingAccountProfileStateService = mock<BillingAccountProfileStateService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
|
||||
@@ -193,6 +195,7 @@ describe("LoginStrategy", () => {
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
credentials = new PasswordLoginCredentials(email, masterPassword);
|
||||
});
|
||||
@@ -257,8 +260,9 @@ describe("LoginStrategy", () => {
|
||||
|
||||
expect(environmentService.seedUserEnvironment).toHaveBeenCalled();
|
||||
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
|
||||
UserDecryptionOptions.fromResponse(idTokenResponse),
|
||||
expect(userDecryptionOptionsService.setUserDecryptionOptionsById).toHaveBeenCalledWith(
|
||||
userId,
|
||||
UserDecryptionOptions.fromIdentityTokenResponse(idTokenResponse),
|
||||
);
|
||||
expect(masterPasswordService.mock.setMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
new MasterPasswordUnlockData(
|
||||
@@ -300,15 +304,14 @@ describe("LoginStrategy", () => {
|
||||
it("builds AuthResult", async () => {
|
||||
const tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.forcePasswordReset = true;
|
||||
tokenResponse.resetMasterPassword = true;
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
|
||||
const result = await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
const expected = new AuthResult();
|
||||
expected.masterPassword = "password";
|
||||
expected.userId = userId;
|
||||
expected.resetMasterPassword = true;
|
||||
expected.twoFactorProviders = null;
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
@@ -322,8 +325,8 @@ describe("LoginStrategy", () => {
|
||||
const result = await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
const expected = new AuthResult();
|
||||
expected.masterPassword = "password";
|
||||
expected.userId = userId;
|
||||
expected.resetMasterPassword = false;
|
||||
expected.twoFactorProviders = null;
|
||||
expect(result).toEqual(expected);
|
||||
|
||||
@@ -519,6 +522,7 @@ describe("LoginStrategy", () => {
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(identityTokenResponseFactory());
|
||||
@@ -580,6 +584,7 @@ describe("LoginStrategy", () => {
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
|
||||
const result = await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
@@ -13,10 +13,12 @@ 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";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import {
|
||||
@@ -49,7 +51,8 @@ import { CacheData } from "../services/login-strategies/login-strategy.state";
|
||||
type IdentityResponse =
|
||||
| IdentityTokenResponse
|
||||
| IdentityTwoFactorResponse
|
||||
| IdentityDeviceVerificationResponse;
|
||||
| IdentityDeviceVerificationResponse
|
||||
| IdentitySsoRequiredResponse;
|
||||
|
||||
export abstract class LoginStrategyData {
|
||||
tokenRequest:
|
||||
@@ -87,6 +90,7 @@ export abstract class LoginStrategy {
|
||||
protected KdfConfigService: KdfConfigService,
|
||||
protected environmentService: EnvironmentService,
|
||||
protected configService: ConfigService,
|
||||
protected accountCryptographicStateService: AccountCryptographicStateService,
|
||||
) {}
|
||||
|
||||
abstract exportCache(): CacheData;
|
||||
@@ -108,6 +112,8 @@ export abstract class LoginStrategy {
|
||||
data.tokenRequest.setTwoFactor(twoFactor);
|
||||
this.cache.next(data);
|
||||
const [authResult] = await this.startLogIn();
|
||||
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
|
||||
authResult.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
|
||||
return authResult;
|
||||
}
|
||||
|
||||
@@ -126,6 +132,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.");
|
||||
@@ -183,6 +191,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
|
||||
@@ -195,8 +204,9 @@ export abstract class LoginStrategy {
|
||||
|
||||
// We must set user decryption options before retrieving vault timeout settings
|
||||
// as the user decryption options help determine the available timeout actions.
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptions(
|
||||
UserDecryptionOptions.fromResponse(tokenResponse),
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
|
||||
userId,
|
||||
UserDecryptionOptions.fromIdentityTokenResponse(tokenResponse),
|
||||
);
|
||||
|
||||
if (tokenResponse.userDecryptionOptions?.masterPasswordUnlock != null) {
|
||||
@@ -246,8 +256,6 @@ export abstract class LoginStrategy {
|
||||
const userId = await this.saveAccountInformation(response);
|
||||
result.userId = userId;
|
||||
|
||||
result.resetMasterPassword = response.resetMasterPassword;
|
||||
|
||||
if (response.twoFactorToken != null) {
|
||||
// note: we can read email from access token b/c it was saved in saveAccountInformation
|
||||
const userEmail = await this.tokenService.getEmail();
|
||||
@@ -263,6 +271,9 @@ export abstract class LoginStrategy {
|
||||
await this.processForceSetPasswordReason(response.forcePasswordReset, userId);
|
||||
|
||||
this.messagingService.send("loggedIn");
|
||||
// There is an import cycle between PasswordLoginStrategyData and LoginStrategy, which means this cast is necessary, which is solved by extracting the data classes.
|
||||
// TODO: https://bitwarden.atlassian.net/browse/PM-27573
|
||||
result.masterPassword = (this.cache.value as any)["masterPassword"] ?? null;
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -392,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/respons
|
||||
import { MasterPasswordPolicyResponse } from "@bitwarden/common/auth/models/response/master-password-policy.response";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import {
|
||||
@@ -28,7 +29,7 @@ import { StateService } from "@bitwarden/common/platform/abstractions/state.serv
|
||||
import { HashPurpose } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { FakeAccountService, makeEncString, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import {
|
||||
PasswordStrengthServiceAbstraction,
|
||||
PasswordStrengthService,
|
||||
@@ -85,6 +86,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
|
||||
|
||||
let passwordLoginStrategy: PasswordLoginStrategy;
|
||||
let credentials: PasswordLoginCredentials;
|
||||
@@ -113,6 +115,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
tokenService.decodeAccessToken.mockResolvedValue({
|
||||
@@ -153,6 +156,7 @@ describe("PasswordLoginStrategy", () => {
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
credentials = new PasswordLoginCredentials(email, masterPassword);
|
||||
tokenResponse = identityTokenResponseFactory(masterPasswordPolicyResponse);
|
||||
@@ -390,7 +394,45 @@ describe("PasswordLoginStrategy", () => {
|
||||
newDeviceOtp: deviceVerificationOtp,
|
||||
}),
|
||||
);
|
||||
expect(result.resetMasterPassword).toBe(false);
|
||||
expect(result.userId).toBe(userId);
|
||||
});
|
||||
|
||||
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
|
||||
const accountKeysData = {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
publicKey: "testPublicKey",
|
||||
wrappedPrivateKey: "testPrivateKey",
|
||||
},
|
||||
};
|
||||
|
||||
tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.key = makeEncString("mockEncryptedUserKey");
|
||||
// Add accountKeysResponseModel to the response
|
||||
(tokenResponse as any).accountKeysResponseModel = {
|
||||
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
|
||||
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(
|
||||
new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey,
|
||||
);
|
||||
|
||||
await passwordLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
|
||||
{
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
},
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
@@ -33,6 +34,8 @@ export class PasswordLoginStrategyData implements LoginStrategyData {
|
||||
localMasterKeyHash: string;
|
||||
/** The user's master key */
|
||||
masterKey: MasterKey;
|
||||
/** The user's master password */
|
||||
masterPassword: string;
|
||||
/**
|
||||
* Tracks if the user needs to update their password due to
|
||||
* a password that does not meet an organization's master password policy.
|
||||
@@ -83,6 +86,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
masterPassword,
|
||||
email,
|
||||
);
|
||||
data.masterPassword = masterPassword;
|
||||
data.userEnteredEmail = email;
|
||||
|
||||
// Hash the password early (before authentication) so we don't persist it in memory in plaintext
|
||||
@@ -152,6 +156,12 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
|
||||
userId,
|
||||
);
|
||||
if (response.accountKeysResponseModel) {
|
||||
await this.accountCryptographicStateService.setAccountCryptographicState(
|
||||
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected override encryptionKeyMigrationRequired(response: IdentityTokenResponse): boolean {
|
||||
@@ -162,14 +172,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;
|
||||
}
|
||||
|
||||
@@ -251,6 +267,7 @@ export class PasswordLoginStrategy extends LoginStrategy {
|
||||
this.cache.next(data);
|
||||
|
||||
const [authResult] = await this.startLogIn();
|
||||
authResult.masterPassword = this.cache.value["masterPassword"] ?? null;
|
||||
return authResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/id
|
||||
import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncryptedString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
@@ -30,7 +31,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
|
||||
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { FakeAccountService, makeEncString, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { DeviceKey, MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
@@ -70,6 +71,7 @@ describe("SsoLoginStrategy", () => {
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
|
||||
|
||||
let ssoLoginStrategy: SsoLoginStrategy;
|
||||
let credentials: SsoLoginCredentials;
|
||||
@@ -108,6 +110,7 @@ describe("SsoLoginStrategy", () => {
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
|
||||
tokenService.getTwoFactorToken.mockResolvedValue(null);
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
@@ -134,7 +137,9 @@ describe("SsoLoginStrategy", () => {
|
||||
);
|
||||
|
||||
const userDecryptionOptions = new UserDecryptionOptions();
|
||||
userDecryptionOptionsService.userDecryptionOptions$ = of(userDecryptionOptions);
|
||||
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
|
||||
of(userDecryptionOptions),
|
||||
);
|
||||
|
||||
ssoLoginStrategy = new SsoLoginStrategy(
|
||||
{} as SsoLoginStrategyData,
|
||||
@@ -160,6 +165,7 @@ describe("SsoLoginStrategy", () => {
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
credentials = new SsoLoginCredentials(ssoCode, ssoCodeVerifier, ssoRedirectUrl, ssoOrgId);
|
||||
});
|
||||
@@ -501,7 +507,6 @@ describe("SsoLoginStrategy", () => {
|
||||
HasMasterPassword: false,
|
||||
KeyConnectorOption: { KeyConnectorUrl: keyConnectorUrl },
|
||||
});
|
||||
tokenResponse.keyConnectorUrl = keyConnectorUrl;
|
||||
});
|
||||
|
||||
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
|
||||
@@ -556,63 +561,38 @@ describe("SsoLoginStrategy", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Key Connector Pre-TDE", () => {
|
||||
let tokenResponse: IdentityTokenResponse;
|
||||
beforeEach(() => {
|
||||
tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.userDecryptionOptions = null;
|
||||
tokenResponse.keyConnectorUrl = keyConnectorUrl;
|
||||
});
|
||||
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
|
||||
const accountKeysData = {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
publicKey: "testPublicKey",
|
||||
wrappedPrivateKey: "testPrivateKey",
|
||||
},
|
||||
};
|
||||
|
||||
it("gets and sets the master key if Key Connector is enabled and the user doesn't have a master password", async () => {
|
||||
const masterKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).buffer as CsprngArray,
|
||||
) as MasterKey;
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(keyConnectorService.setMasterKeyFromUrl).toHaveBeenCalledWith(keyConnectorUrl, userId);
|
||||
});
|
||||
|
||||
it("converts new SSO user with no master password to Key Connector on first login", async () => {
|
||||
tokenResponse.key = undefined;
|
||||
tokenResponse.kdfConfig = new Argon2KdfConfig(10, 64, 4);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(keyConnectorService.setNewSsoUserKeyConnectorConversionData).toHaveBeenCalledWith(
|
||||
{
|
||||
kdfConfig: new Argon2KdfConfig(10, 64, 4),
|
||||
keyConnectorUrl: keyConnectorUrl,
|
||||
organizationId: ssoOrgId,
|
||||
const tokenResponse = identityTokenResponseFactory();
|
||||
tokenResponse.key = makeEncString("mockEncryptedUserKey");
|
||||
// Add accountKeysResponseModel to the response
|
||||
(tokenResponse as any).accountKeysResponseModel = {
|
||||
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
|
||||
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
userId,
|
||||
);
|
||||
});
|
||||
}),
|
||||
};
|
||||
|
||||
it("decrypts and sets the user key if Key Connector is enabled and the user doesn't have a master password", async () => {
|
||||
const userKey = new SymmetricCryptoKey(new Uint8Array(64).buffer as CsprngArray) as UserKey;
|
||||
const masterKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).buffer as CsprngArray,
|
||||
) as MasterKey;
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
masterPasswordService.mock.decryptUserKeyWithMasterKey.mockResolvedValue(userKey);
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
await ssoLoginStrategy.logIn(credentials);
|
||||
|
||||
expect(masterPasswordService.mock.decryptUserKeyWithMasterKey).toHaveBeenCalledWith(
|
||||
masterKey,
|
||||
userId,
|
||||
undefined,
|
||||
);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
|
||||
});
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
|
||||
{
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
},
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,22 +157,12 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
// In order for us to set the master key from Key Connector, we need to have a Key Connector URL
|
||||
// and the user must not have a master password.
|
||||
return userHasKeyConnectorUrl && !userHasMasterPassword;
|
||||
} else {
|
||||
// In pre-TDE versions of the server, the userDecryptionOptions will not be present.
|
||||
// In this case, we can determine if the user has a master password and has a Key Connector URL by
|
||||
// just checking the keyConnectorUrl property. This is because the server short-circuits on the response
|
||||
// and will not pass back the URL in the response if the user has a master password.
|
||||
// TODO: remove compatibility check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
return tokenResponse.keyConnectorUrl != null;
|
||||
}
|
||||
}
|
||||
|
||||
private getKeyConnectorUrl(tokenResponse: IdentityTokenResponse): string {
|
||||
// TODO: remove tokenResponse.keyConnectorUrl reference after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
const userDecryptionOptions = tokenResponse?.userDecryptionOptions;
|
||||
return (
|
||||
tokenResponse.keyConnectorUrl ?? userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl
|
||||
);
|
||||
return userDecryptionOptions?.keyConnectorOption?.keyConnectorUrl;
|
||||
}
|
||||
|
||||
// TODO: future passkey login strategy will need to support setting user key (decrypting via TDE or admin approval request)
|
||||
@@ -349,6 +339,13 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
tokenResponse: IdentityTokenResponse,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
if (tokenResponse.accountKeysResponseModel) {
|
||||
await this.accountCryptographicStateService.setAccountCryptographicState(
|
||||
tokenResponse.accountKeysResponseModel.toWrappedAccountCryptographicState(),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
|
||||
if (tokenResponse.hasMasterKeyEncryptedUserKey()) {
|
||||
// User has masterKeyEncryptedUserKey, so set the userKeyEncryptedPrivateKey
|
||||
// Note: new JIT provisioned SSO users will not yet have a user asymmetric key pair
|
||||
@@ -393,7 +390,7 @@ export class SsoLoginStrategy extends LoginStrategy {
|
||||
|
||||
// Check for TDE-related conditions
|
||||
const userDecryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptions$,
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
);
|
||||
|
||||
if (!userDecryptionOptions) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
@@ -58,6 +59,7 @@ describe("UserApiLoginStrategy", () => {
|
||||
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
|
||||
|
||||
let apiLogInStrategy: UserApiLoginStrategy;
|
||||
let credentials: UserApiLoginCredentials;
|
||||
@@ -91,6 +93,7 @@ describe("UserApiLoginStrategy", () => {
|
||||
vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
tokenService.getTwoFactorToken.mockResolvedValue(null);
|
||||
@@ -119,6 +122,7 @@ describe("UserApiLoginStrategy", () => {
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
|
||||
credentials = new UserApiLoginCredentials(apiClientId, apiClientSecret);
|
||||
@@ -226,4 +230,38 @@ describe("UserApiLoginStrategy", () => {
|
||||
);
|
||||
expect(keyService.setUserKey).toHaveBeenCalledWith(userKey, userId);
|
||||
});
|
||||
|
||||
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
|
||||
const accountKeysData = {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
publicKey: "testPublicKey",
|
||||
wrappedPrivateKey: "testPrivateKey",
|
||||
},
|
||||
};
|
||||
|
||||
const tokenResponse = identityTokenResponseFactory();
|
||||
// Add accountKeysResponseModel to the response
|
||||
(tokenResponse as any).accountKeysResponseModel = {
|
||||
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
|
||||
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(tokenResponse);
|
||||
|
||||
await apiLogInStrategy.logIn(credentials);
|
||||
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
|
||||
{
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
},
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,12 @@ export class UserApiLoginStrategy extends LoginStrategy {
|
||||
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
|
||||
userId,
|
||||
);
|
||||
if (response.accountKeysResponseModel) {
|
||||
await this.accountCryptographicStateService.setAccountCryptographicState(
|
||||
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Overridden to save client ID and secret to token service
|
||||
|
||||
@@ -9,6 +9,7 @@ import { IUserDecryptionOptionsServerResponse } from "@bitwarden/common/auth/mod
|
||||
import { WebAuthnLoginAssertionResponseRequest } from "@bitwarden/common/auth/services/webauthn-login/request/webauthn-login-assertion-response.request";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import {
|
||||
@@ -56,6 +57,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let environmentService: MockProxy<EnvironmentService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<AccountCryptographicStateService>;
|
||||
|
||||
let webAuthnLoginStrategy!: WebAuthnLoginStrategy;
|
||||
|
||||
@@ -101,6 +103,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
environmentService = mock<EnvironmentService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountCryptographicStateService = mock<AccountCryptographicStateService>();
|
||||
|
||||
tokenService.getTwoFactorToken.mockResolvedValue(null);
|
||||
appIdService.getAppId.mockResolvedValue(deviceId);
|
||||
@@ -128,6 +131,7 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
kdfConfigService,
|
||||
environmentService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
|
||||
// Create credentials
|
||||
@@ -212,7 +216,6 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
|
||||
expect(authResult).toBeInstanceOf(AuthResult);
|
||||
expect(authResult).toMatchObject({
|
||||
resetMasterPassword: false,
|
||||
twoFactorProviders: null,
|
||||
requiresTwoFactor: false,
|
||||
});
|
||||
@@ -341,6 +344,53 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
// Assert
|
||||
expect(keyService.setUserKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets account cryptographic state when accountKeysResponseModel is present", async () => {
|
||||
// Arrange
|
||||
const accountKeysData = {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
publicKey: "testPublicKey",
|
||||
wrappedPrivateKey: "testPrivateKey",
|
||||
},
|
||||
};
|
||||
|
||||
const idTokenResponse: IdentityTokenResponse = identityTokenResponseFactory(
|
||||
null,
|
||||
userDecryptionOptsServerResponseWithWebAuthnPrfOption,
|
||||
);
|
||||
// Add accountKeysResponseModel to the response
|
||||
(idTokenResponse as any).accountKeysResponseModel = {
|
||||
publicKeyEncryptionKeyPair: accountKeysData.publicKeyEncryptionKeyPair,
|
||||
toWrappedAccountCryptographicState: jest.fn().mockReturnValue({
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
apiService.postIdentityToken.mockResolvedValue(idTokenResponse);
|
||||
|
||||
const mockPrfPrivateKey: Uint8Array = randomBytes(32);
|
||||
const mockUserKeyArray: Uint8Array = randomBytes(32);
|
||||
encryptService.unwrapDecapsulationKey.mockResolvedValue(mockPrfPrivateKey);
|
||||
encryptService.decapsulateKeyUnsigned.mockResolvedValue(
|
||||
new SymmetricCryptoKey(mockUserKeyArray),
|
||||
);
|
||||
|
||||
// Act
|
||||
await webAuthnLoginStrategy.logIn(webAuthnCredentials);
|
||||
|
||||
// Assert
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledTimes(1);
|
||||
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
|
||||
{
|
||||
V1: {
|
||||
private_key: "testPrivateKey",
|
||||
},
|
||||
},
|
||||
userId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Helpers and mocks
|
||||
|
||||
@@ -107,6 +107,12 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
response.privateKey ?? (await this.createKeyPairForOldAccount(userId)),
|
||||
userId,
|
||||
);
|
||||
if (response.accountKeysResponseModel) {
|
||||
await this.accountCryptographicStateService.setAccountCryptographicState(
|
||||
response.accountKeysResponseModel.toWrappedAccountCryptographicState(),
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
exportCache(): CacheData {
|
||||
|
||||
@@ -112,10 +112,11 @@ export class UserDecryptionOptions {
|
||||
* @throws If the response is nullish, this method will throw an error. User decryption options
|
||||
* are required for client initialization.
|
||||
*/
|
||||
// TODO: Change response type to `UserDecryptionOptionsResponse` after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
static fromResponse(response: IdentityTokenResponse): UserDecryptionOptions {
|
||||
static fromIdentityTokenResponse(response: IdentityTokenResponse): UserDecryptionOptions {
|
||||
if (response == null) {
|
||||
throw new Error("User Decryption Options are required for client initialization.");
|
||||
throw new Error(
|
||||
"User Decryption Options are required for client initialization. Response is nullish.",
|
||||
);
|
||||
}
|
||||
|
||||
const decryptionOptions = new UserDecryptionOptions();
|
||||
@@ -134,17 +135,9 @@ export class UserDecryptionOptions {
|
||||
responseOptions.keyConnectorOption,
|
||||
);
|
||||
} else {
|
||||
// If the response does not have userDecryptionOptions, this means it's on a pre-TDE server version and so
|
||||
// we must base our decryption options on the presence of the keyConnectorUrl.
|
||||
// Note that the presence of keyConnectorUrl implies that the user does not have a master password, as in pre-TDE
|
||||
// server versions, a master password short-circuited the addition of the keyConnectorUrl to the response.
|
||||
// TODO: remove this check after 2023.10 release (https://bitwarden.atlassian.net/browse/PM-3537)
|
||||
const usingKeyConnector = response.keyConnectorUrl != null;
|
||||
decryptionOptions.hasMasterPassword = !usingKeyConnector;
|
||||
if (usingKeyConnector) {
|
||||
decryptionOptions.keyConnectorOption = new KeyConnectorUserDecryptionOption();
|
||||
decryptionOptions.keyConnectorOption.keyConnectorUrl = response.keyConnectorUrl;
|
||||
}
|
||||
throw new Error(
|
||||
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
|
||||
);
|
||||
}
|
||||
return decryptionOptions;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -10,8 +10,10 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { IdentityTwoFactorResponse } from "@bitwarden/common/auth/models/response/identity-two-factor.response";
|
||||
import { PreloginResponse } from "@bitwarden/common/auth/models/response/prelogin.response";
|
||||
import { UserDecryptionOptionsResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/user-decryption-options.response";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { DefaultAccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/default-account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
@@ -83,6 +85,7 @@ describe("LoginStrategyService", () => {
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let taskSchedulerService: MockProxy<TaskSchedulerService>;
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let accountCryptographicStateService: MockProxy<DefaultAccountCryptographicStateService>;
|
||||
|
||||
let stateProvider: FakeGlobalStateProvider;
|
||||
let loginStrategyCacheExpirationState: FakeGlobalState<Date | null>;
|
||||
@@ -116,6 +119,7 @@ describe("LoginStrategyService", () => {
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
taskSchedulerService = mock<TaskSchedulerService>();
|
||||
configService = mock<ConfigService>();
|
||||
accountCryptographicStateService = mock<DefaultAccountCryptographicStateService>();
|
||||
|
||||
sut = new LoginStrategyService(
|
||||
accountService,
|
||||
@@ -144,6 +148,7 @@ describe("LoginStrategyService", () => {
|
||||
kdfConfigService,
|
||||
taskSchedulerService,
|
||||
configService,
|
||||
accountCryptographicStateService,
|
||||
);
|
||||
|
||||
loginStrategyCacheExpirationState = stateProvider.getFake(CACHE_EXPIRATION_KEY);
|
||||
@@ -490,12 +495,12 @@ describe("LoginStrategyService", () => {
|
||||
KdfParallelism: 1,
|
||||
Key: "KEY",
|
||||
PrivateKey: "PRIVATE_KEY",
|
||||
ResetMasterPassword: false,
|
||||
access_token: "ACCESS_TOKEN",
|
||||
expires_in: 3600,
|
||||
refresh_token: "REFRESH_TOKEN",
|
||||
scope: "api offline_access",
|
||||
token_type: "Bearer",
|
||||
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
|
||||
}),
|
||||
);
|
||||
apiService.postPrelogin.mockResolvedValue(
|
||||
@@ -557,12 +562,12 @@ describe("LoginStrategyService", () => {
|
||||
KdfParallelism: 1,
|
||||
Key: "KEY",
|
||||
PrivateKey: "PRIVATE_KEY",
|
||||
ResetMasterPassword: false,
|
||||
access_token: "ACCESS_TOKEN",
|
||||
expires_in: 3600,
|
||||
refresh_token: "REFRESH_TOKEN",
|
||||
scope: "api offline_access",
|
||||
token_type: "Bearer",
|
||||
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -622,7 +627,6 @@ describe("LoginStrategyService", () => {
|
||||
KdfIterations: PBKDF2KdfConfig.PRELOGIN_ITERATIONS_MIN - 1,
|
||||
Key: "KEY",
|
||||
PrivateKey: "PRIVATE_KEY",
|
||||
ResetMasterPassword: false,
|
||||
access_token: "ACCESS_TOKEN",
|
||||
expires_in: 3600,
|
||||
refresh_token: "REFRESH_TOKEN",
|
||||
@@ -686,12 +690,12 @@ describe("LoginStrategyService", () => {
|
||||
KdfParallelism: 1,
|
||||
Key: "KEY",
|
||||
PrivateKey: "PRIVATE_KEY",
|
||||
ResetMasterPassword: false,
|
||||
access_token: "ACCESS_TOKEN",
|
||||
expires_in: 3600,
|
||||
refresh_token: "REFRESH_TOKEN",
|
||||
scope: "api offline_access",
|
||||
token_type: "Bearer",
|
||||
userDecryptionOptions: new UserDecryptionOptionsResponse({ HasMasterPassword: true }),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import { TokenTwoFactorRequest } from "@bitwarden/common/auth/models/request/ide
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
|
||||
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
|
||||
@@ -160,6 +161,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
protected kdfConfigService: KdfConfigService,
|
||||
protected taskSchedulerService: TaskSchedulerService,
|
||||
protected configService: ConfigService,
|
||||
protected accountCryptographicStateService: AccountCryptographicStateService,
|
||||
) {
|
||||
this.currentAuthnTypeState = this.stateProvider.get(CURRENT_LOGIN_STRATEGY_KEY);
|
||||
this.loginStrategyCacheState = this.stateProvider.get(CACHE_KEY);
|
||||
@@ -509,6 +511,7 @@ export class LoginStrategyService implements LoginStrategyServiceAbstraction {
|
||||
this.kdfConfigService,
|
||||
this.environmentService,
|
||||
this.configService,
|
||||
this.accountCryptographicStateService,
|
||||
];
|
||||
|
||||
return source.pipe(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MockProxy, mock } from "jest-mock-extended";
|
||||
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -19,6 +20,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
let ssoLoginService: MockProxy<SsoLoginServiceAbstraction>;
|
||||
let syncService: MockProxy<SyncService>;
|
||||
let userAsymmetricKeysRegenerationService: MockProxy<UserAsymmetricKeysRegenerationService>;
|
||||
let encryptedMigrator: MockProxy<EncryptedMigrator>;
|
||||
let logService: MockProxy<LogService>;
|
||||
|
||||
const userId = "USER_ID" as UserId;
|
||||
@@ -30,6 +32,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
ssoLoginService = mock<SsoLoginServiceAbstraction>();
|
||||
syncService = mock<SyncService>();
|
||||
userAsymmetricKeysRegenerationService = mock<UserAsymmetricKeysRegenerationService>();
|
||||
encryptedMigrator = mock<EncryptedMigrator>();
|
||||
logService = mock<LogService>();
|
||||
|
||||
service = new DefaultLoginSuccessHandlerService(
|
||||
@@ -38,6 +41,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
ssoLoginService,
|
||||
syncService,
|
||||
userAsymmetricKeysRegenerationService,
|
||||
encryptedMigrator,
|
||||
logService,
|
||||
);
|
||||
|
||||
@@ -50,7 +54,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
|
||||
describe("run", () => {
|
||||
it("should call required services on successful login", async () => {
|
||||
await service.run(userId);
|
||||
await service.run(userId, null);
|
||||
|
||||
expect(syncService.fullSync).toHaveBeenCalledWith(true, { skipTokenRefresh: true });
|
||||
expect(userAsymmetricKeysRegenerationService.regenerateIfNeeded).toHaveBeenCalledWith(userId);
|
||||
@@ -58,7 +62,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
});
|
||||
|
||||
it("should get SSO email", async () => {
|
||||
await service.run(userId);
|
||||
await service.run(userId, null);
|
||||
|
||||
expect(ssoLoginService.getSsoEmail).toHaveBeenCalled();
|
||||
});
|
||||
@@ -68,10 +72,10 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
ssoLoginService.getSsoEmail.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("should log error and return early", async () => {
|
||||
await service.run(userId);
|
||||
it("should not check SSO requirements", async () => {
|
||||
await service.run(userId, null);
|
||||
|
||||
expect(logService.error).toHaveBeenCalledWith("SSO login email not found.");
|
||||
expect(logService.debug).toHaveBeenCalledWith("SSO login email not found.");
|
||||
expect(ssoLoginService.updateSsoRequiredCache).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -82,7 +86,7 @@ describe("DefaultLoginSuccessHandlerService", () => {
|
||||
});
|
||||
|
||||
it("should call updateSsoRequiredCache() and clearSsoEmail()", async () => {
|
||||
await service.run(userId);
|
||||
await service.run(userId, null);
|
||||
|
||||
expect(ssoLoginService.updateSsoRequiredCache).toHaveBeenCalledWith(testEmail, userId);
|
||||
expect(ssoLoginService.clearSsoEmail).toHaveBeenCalled();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { EncryptedMigrator } from "@bitwarden/common/key-management/encrypted-migrator/encrypted-migrator.abstraction";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
@@ -15,17 +16,24 @@ export class DefaultLoginSuccessHandlerService implements LoginSuccessHandlerSer
|
||||
private ssoLoginService: SsoLoginServiceAbstraction,
|
||||
private syncService: SyncService,
|
||||
private userAsymmetricKeysRegenerationService: UserAsymmetricKeysRegenerationService,
|
||||
private encryptedMigrator: EncryptedMigrator,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
async run(userId: UserId): Promise<void> {
|
||||
|
||||
async run(userId: UserId, masterPassword: string | null): Promise<void> {
|
||||
await this.syncService.fullSync(true, { skipTokenRefresh: true });
|
||||
await this.userAsymmetricKeysRegenerationService.regenerateIfNeeded(userId);
|
||||
await this.loginEmailService.clearLoginEmail();
|
||||
try {
|
||||
await this.encryptedMigrator.runMigrations(userId, masterPassword);
|
||||
} catch {
|
||||
// Don't block login success on migration failure
|
||||
}
|
||||
|
||||
const ssoLoginEmail = await this.ssoLoginService.getSsoEmail();
|
||||
|
||||
if (!ssoLoginEmail) {
|
||||
this.logService.error("SSO login email not found.");
|
||||
this.logService.debug("SSO login email not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import {
|
||||
FakeAccountService,
|
||||
FakeStateProvider,
|
||||
mockAccountServiceWith,
|
||||
} from "@bitwarden/common/spec";
|
||||
import { FakeSingleUserStateProvider } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
|
||||
import { UserDecryptionOptions } from "../../models/domain/user-decryption-options";
|
||||
|
||||
@@ -17,15 +13,10 @@ import {
|
||||
|
||||
describe("UserDecryptionOptionsService", () => {
|
||||
let sut: UserDecryptionOptionsService;
|
||||
|
||||
const fakeUserId = Utils.newGuid() as UserId;
|
||||
let fakeAccountService: FakeAccountService;
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
let fakeStateProvider: FakeSingleUserStateProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
fakeAccountService = mockAccountServiceWith(fakeUserId);
|
||||
fakeStateProvider = new FakeStateProvider(fakeAccountService);
|
||||
|
||||
fakeStateProvider = new FakeSingleUserStateProvider();
|
||||
sut = new UserDecryptionOptionsService(fakeStateProvider);
|
||||
});
|
||||
|
||||
@@ -42,55 +33,71 @@ describe("UserDecryptionOptionsService", () => {
|
||||
},
|
||||
};
|
||||
|
||||
describe("userDecryptionOptions$", () => {
|
||||
it("should return the active user's decryption options", async () => {
|
||||
await fakeStateProvider.setUserState(USER_DECRYPTION_OPTIONS, userDecryptionOptions);
|
||||
describe("userDecryptionOptionsById$", () => {
|
||||
it("should return user decryption options for a specific user", async () => {
|
||||
const userId = newGuid() as UserId;
|
||||
|
||||
const result = await firstValueFrom(sut.userDecryptionOptions$);
|
||||
fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS).nextState(userDecryptionOptions);
|
||||
|
||||
const result = await firstValueFrom(sut.userDecryptionOptionsById$(userId));
|
||||
|
||||
expect(result).toEqual(userDecryptionOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasMasterPassword$", () => {
|
||||
it("should return the hasMasterPassword property of the active user's decryption options", async () => {
|
||||
await fakeStateProvider.setUserState(USER_DECRYPTION_OPTIONS, userDecryptionOptions);
|
||||
describe("hasMasterPasswordById$", () => {
|
||||
it("should return true when user has a master password", async () => {
|
||||
const userId = newGuid() as UserId;
|
||||
|
||||
const result = await firstValueFrom(sut.hasMasterPassword$);
|
||||
fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS).nextState(userDecryptionOptions);
|
||||
|
||||
const result = await firstValueFrom(sut.hasMasterPasswordById$(userId));
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("userDecryptionOptionsById$", () => {
|
||||
it("should return the user decryption options for the given user", async () => {
|
||||
const givenUser = Utils.newGuid() as UserId;
|
||||
await fakeAccountService.addAccount(givenUser, {
|
||||
name: "Test User 1",
|
||||
email: "test1@email.com",
|
||||
emailVerified: false,
|
||||
});
|
||||
await fakeStateProvider.setUserState(
|
||||
USER_DECRYPTION_OPTIONS,
|
||||
userDecryptionOptions,
|
||||
givenUser,
|
||||
);
|
||||
it("should return false when user does not have a master password", async () => {
|
||||
const userId = newGuid() as UserId;
|
||||
const optionsWithoutMasterPassword = {
|
||||
...userDecryptionOptions,
|
||||
hasMasterPassword: false,
|
||||
};
|
||||
|
||||
const result = await firstValueFrom(sut.userDecryptionOptionsById$(givenUser));
|
||||
fakeStateProvider
|
||||
.getFake(userId, USER_DECRYPTION_OPTIONS)
|
||||
.nextState(optionsWithoutMasterPassword);
|
||||
|
||||
expect(result).toEqual(userDecryptionOptions);
|
||||
const result = await firstValueFrom(sut.hasMasterPasswordById$(userId));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setUserDecryptionOptions", () => {
|
||||
it("should set the active user's decryption options", async () => {
|
||||
await sut.setUserDecryptionOptions(userDecryptionOptions);
|
||||
describe("setUserDecryptionOptionsById", () => {
|
||||
it("should set user decryption options for a specific user", async () => {
|
||||
const userId = newGuid() as UserId;
|
||||
|
||||
const result = await firstValueFrom(
|
||||
fakeStateProvider.getActive(USER_DECRYPTION_OPTIONS).state$,
|
||||
);
|
||||
await sut.setUserDecryptionOptionsById(userId, userDecryptionOptions);
|
||||
|
||||
const fakeState = fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS);
|
||||
const result = await firstValueFrom(fakeState.state$);
|
||||
|
||||
expect(result).toEqual(userDecryptionOptions);
|
||||
});
|
||||
|
||||
it("should overwrite existing user decryption options", async () => {
|
||||
const userId = newGuid() as UserId;
|
||||
const initialOptions = { ...userDecryptionOptions, hasMasterPassword: false };
|
||||
const updatedOptions = { ...userDecryptionOptions, hasMasterPassword: true };
|
||||
|
||||
const fakeState = fakeStateProvider.getFake(userId, USER_DECRYPTION_OPTIONS);
|
||||
fakeState.nextState(initialOptions);
|
||||
|
||||
await sut.setUserDecryptionOptionsById(userId, updatedOptions);
|
||||
|
||||
const result = await firstValueFrom(fakeState.state$);
|
||||
|
||||
expect(result).toEqual(updatedOptions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, map } from "rxjs";
|
||||
import { Observable, filter, map } from "rxjs";
|
||||
|
||||
import {
|
||||
ActiveUserState,
|
||||
StateProvider,
|
||||
SingleUserStateProvider,
|
||||
USER_DECRYPTION_OPTIONS_DISK,
|
||||
UserKeyDefinition,
|
||||
} from "@bitwarden/common/platform/state";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { UserId } from "@bitwarden/common/src/types/guid";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { InternalUserDecryptionOptionsServiceAbstraction } from "../../abstractions/user-decryption-options.service.abstraction";
|
||||
import { UserDecryptionOptions } from "../../models";
|
||||
@@ -24,28 +19,27 @@ export const USER_DECRYPTION_OPTIONS = new UserKeyDefinition<UserDecryptionOptio
|
||||
},
|
||||
);
|
||||
|
||||
export class UserDecryptionOptionsService
|
||||
implements InternalUserDecryptionOptionsServiceAbstraction
|
||||
{
|
||||
private userDecryptionOptionsState: ActiveUserState<UserDecryptionOptions>;
|
||||
export class UserDecryptionOptionsService implements InternalUserDecryptionOptionsServiceAbstraction {
|
||||
constructor(private singleUserStateProvider: SingleUserStateProvider) {}
|
||||
|
||||
userDecryptionOptions$: Observable<UserDecryptionOptions>;
|
||||
hasMasterPassword$: Observable<boolean>;
|
||||
userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions> {
|
||||
return this.singleUserStateProvider
|
||||
.get(userId, USER_DECRYPTION_OPTIONS)
|
||||
.state$.pipe(filter((options): options is UserDecryptionOptions => options != null));
|
||||
}
|
||||
|
||||
constructor(private stateProvider: StateProvider) {
|
||||
this.userDecryptionOptionsState = this.stateProvider.getActive(USER_DECRYPTION_OPTIONS);
|
||||
|
||||
this.userDecryptionOptions$ = this.userDecryptionOptionsState.state$;
|
||||
this.hasMasterPassword$ = this.userDecryptionOptions$.pipe(
|
||||
map((options) => options?.hasMasterPassword ?? false),
|
||||
hasMasterPasswordById$(userId: UserId): Observable<boolean> {
|
||||
return this.userDecryptionOptionsById$(userId).pipe(
|
||||
map((options) => options.hasMasterPassword ?? false),
|
||||
);
|
||||
}
|
||||
|
||||
userDecryptionOptionsById$(userId: UserId): Observable<UserDecryptionOptions> {
|
||||
return this.stateProvider.getUser(userId, USER_DECRYPTION_OPTIONS).state$;
|
||||
}
|
||||
|
||||
async setUserDecryptionOptions(userDecryptionOptions: UserDecryptionOptions): Promise<void> {
|
||||
await this.userDecryptionOptionsState.update((_) => userDecryptionOptions);
|
||||
async setUserDecryptionOptionsById(
|
||||
userId: UserId,
|
||||
userDecryptionOptions: UserDecryptionOptions,
|
||||
): Promise<void> {
|
||||
await this.singleUserStateProvider
|
||||
.get(userId, USER_DECRYPTION_OPTIONS)
|
||||
.update((_) => userDecryptionOptions);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export type LogoutReason =
|
||||
| "accessTokenUnableToBeDecrypted"
|
||||
| "accountDeleted"
|
||||
| "invalidAccessToken"
|
||||
| "accessTokenUnableToBeDecrypted"
|
||||
| "accountDeleted"
|
||||
| "invalidAccessToken"
|
||||
|
||||
Reference in New Issue
Block a user