mirror of
https://github.com/bitwarden/browser
synced 2026-02-15 07:54:55 +00:00
Merge branch 'main' into tools/pm-30675/account-restricted-export-incorrect-data
This commit is contained in:
@@ -886,7 +886,7 @@ const safeProviders: SafeProvider[] = [
|
||||
FolderApiServiceAbstraction,
|
||||
InternalOrganizationServiceAbstraction,
|
||||
SendApiServiceAbstraction,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
AvatarServiceAbstraction,
|
||||
LOGOUT_CALLBACK,
|
||||
BillingAccountProfileStateService,
|
||||
|
||||
@@ -194,12 +194,7 @@ export class VaultItemsComponent<C extends CipherViewLike> implements OnDestroy
|
||||
return this.searchService.searchCiphers(
|
||||
userId,
|
||||
searchText,
|
||||
[
|
||||
filter,
|
||||
this.deletedFilter,
|
||||
...(this.deleted ? [] : [this.archivedFilter]),
|
||||
restrictedTypeFilter,
|
||||
],
|
||||
[filter, restrictedTypeFilter],
|
||||
allCiphers,
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -54,6 +54,12 @@ export class VaultFilter {
|
||||
cipherPassesFilter =
|
||||
CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
|
||||
if (this.status !== "archive" && this.status !== "trash" && cipherPassesFilter) {
|
||||
cipherPassesFilter =
|
||||
!CipherViewLikeUtils.isArchived(cipher) && !CipherViewLikeUtils.isDeleted(cipher);
|
||||
}
|
||||
|
||||
if (this.cipherType != null && cipherPassesFilter) {
|
||||
cipherPassesFilter = CipherViewLikeUtils.getType(cipher) === this.cipherType;
|
||||
}
|
||||
|
||||
@@ -175,6 +175,8 @@ describe("WebAuthnLoginStrategy", () => {
|
||||
WebAuthnPrfOption: {
|
||||
EncryptedPrivateKey: mockEncPrfPrivateKey,
|
||||
EncryptedUserKey: mockEncUserKey,
|
||||
CredentialId: "mockCredentialId",
|
||||
Transports: ["usb", "nfc"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -73,14 +73,15 @@ export class WebAuthnLoginStrategy extends LoginStrategy {
|
||||
const userDecryptionOptions = idTokenResponse?.userDecryptionOptions;
|
||||
|
||||
if (userDecryptionOptions?.webAuthnPrfOption) {
|
||||
const webAuthnPrfOption = idTokenResponse.userDecryptionOptions?.webAuthnPrfOption;
|
||||
|
||||
const credentials = this.cache.value.credentials;
|
||||
|
||||
// confirm we still have the prf key
|
||||
if (!credentials.prfKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webAuthnPrfOption = userDecryptionOptions.webAuthnPrfOption;
|
||||
|
||||
// decrypt prf encrypted private key
|
||||
const privateKey = await this.encryptService.unwrapDecapsulationKey(
|
||||
webAuthnPrfOption.encryptedPrivateKey,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Jsonify } from "type-fest";
|
||||
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
|
||||
import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response";
|
||||
import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response";
|
||||
import { WebAuthnPrfDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
|
||||
|
||||
/**
|
||||
* Key Connector decryption options. Intended to be sent to the client for use after authentication.
|
||||
@@ -45,6 +46,61 @@ export class KeyConnectorUserDecryptionOption {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
|
||||
* @see {@link UserDecryptionOptions}
|
||||
*/
|
||||
/**
|
||||
* WebAuthn PRF decryption options. Intended to be sent to the client for use after authentication.
|
||||
* @see {@link UserDecryptionOptions}
|
||||
*/
|
||||
export class WebAuthnPrfUserDecryptionOption {
|
||||
/** The encrypted private key that can be decrypted with the PRF key. */
|
||||
encryptedPrivateKey: string;
|
||||
/** The encrypted user key that can be decrypted with the private key. */
|
||||
encryptedUserKey: string;
|
||||
/** The credential ID for this WebAuthn PRF credential. */
|
||||
credentialId: string;
|
||||
/** The transports supported by this credential. */
|
||||
transports: string[];
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the WebAuthnPrfUserDecryptionOption from a response object.
|
||||
* @param response The WebAuthn PRF user decryption option response object.
|
||||
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `response` is nullish.
|
||||
*/
|
||||
static fromResponse(
|
||||
response: WebAuthnPrfDecryptionOptionResponse,
|
||||
): WebAuthnPrfUserDecryptionOption | undefined {
|
||||
if (response == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (!response.encryptedPrivateKey || !response.encryptedUserKey) {
|
||||
return undefined;
|
||||
}
|
||||
const options = new WebAuthnPrfUserDecryptionOption();
|
||||
options.encryptedPrivateKey = response.encryptedPrivateKey.encryptedString;
|
||||
options.encryptedUserKey = response.encryptedUserKey.encryptedString;
|
||||
options.credentialId = response.credentialId;
|
||||
options.transports = response.transports || [];
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a new instance of a WebAuthnPrfUserDecryptionOption from a JSON object.
|
||||
* @param obj JSON object to deserialize.
|
||||
* @returns A new instance of the WebAuthnPrfUserDecryptionOption or undefined if `obj` is nullish.
|
||||
*/
|
||||
static fromJSON(
|
||||
obj: Jsonify<WebAuthnPrfUserDecryptionOption>,
|
||||
): WebAuthnPrfUserDecryptionOption | undefined {
|
||||
if (obj == null) {
|
||||
return undefined;
|
||||
}
|
||||
return Object.assign(new WebAuthnPrfUserDecryptionOption(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trusted device decryption options. Intended to be sent to the client for use after authentication.
|
||||
* @see {@link UserDecryptionOptions}
|
||||
@@ -104,6 +160,8 @@ export class UserDecryptionOptions {
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOption;
|
||||
/** {@link KeyConnectorUserDecryptionOption} */
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOption;
|
||||
/** Array of {@link WebAuthnPrfUserDecryptionOption} */
|
||||
webAuthnPrfOptions?: WebAuthnPrfUserDecryptionOption[];
|
||||
|
||||
/**
|
||||
* Initializes a new instance of the UserDecryptionOptions from a response object.
|
||||
@@ -134,6 +192,18 @@ export class UserDecryptionOptions {
|
||||
decryptionOptions.keyConnectorOption = KeyConnectorUserDecryptionOption.fromResponse(
|
||||
responseOptions.keyConnectorOption,
|
||||
);
|
||||
|
||||
// The IdTokenResponse only returns a single WebAuthn PRF option to support immediate unlock after logging in
|
||||
// with the same PRF passkey.
|
||||
// Since our domain model supports multiple WebAuthn PRF options, we convert the single option into an array.
|
||||
if (responseOptions.webAuthnPrfOption) {
|
||||
const option = WebAuthnPrfUserDecryptionOption.fromResponse(
|
||||
responseOptions.webAuthnPrfOption,
|
||||
);
|
||||
if (option) {
|
||||
decryptionOptions.webAuthnPrfOptions = [option];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
"User Decryption Options are required for client initialization. userDecryptionOptions is missing in response.",
|
||||
@@ -158,6 +228,12 @@ export class UserDecryptionOptions {
|
||||
obj?.keyConnectorOption,
|
||||
);
|
||||
|
||||
if (obj?.webAuthnPrfOptions && Array.isArray(obj.webAuthnPrfOptions)) {
|
||||
decryptionOptions.webAuthnPrfOptions = obj.webAuthnPrfOptions
|
||||
.map((option) => WebAuthnPrfUserDecryptionOption.fromJSON(option))
|
||||
.filter((option) => option !== undefined);
|
||||
}
|
||||
|
||||
return decryptionOptions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
|
||||
masterPasswordUnlock?: MasterPasswordUnlockResponse;
|
||||
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
|
||||
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
|
||||
/**
|
||||
* The IdTokenresponse only returns a single WebAuthn PRF option.
|
||||
* To support immediate unlock after logging in with the same PRF passkey.
|
||||
*/
|
||||
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
|
||||
|
||||
constructor(response: IUserDecryptionOptionsServerResponse) {
|
||||
|
||||
@@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response";
|
||||
export interface IWebAuthnPrfDecryptionOptionServerResponse {
|
||||
EncryptedPrivateKey: string;
|
||||
EncryptedUserKey: string;
|
||||
CredentialId: string;
|
||||
Transports: string[];
|
||||
}
|
||||
|
||||
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
|
||||
encryptedPrivateKey: EncString;
|
||||
encryptedUserKey: EncString;
|
||||
credentialId: string;
|
||||
transports: string[];
|
||||
|
||||
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
|
||||
super(response);
|
||||
if (response.EncryptedPrivateKey) {
|
||||
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
|
||||
|
||||
const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
|
||||
if (encPrivateKey) {
|
||||
this.encryptedPrivateKey = new EncString(encPrivateKey);
|
||||
}
|
||||
if (response.EncryptedUserKey) {
|
||||
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
|
||||
|
||||
const encUserKey = this.getResponseProperty("EncryptedUserKey");
|
||||
if (encUserKey) {
|
||||
this.encryptedUserKey = new EncString(encUserKey);
|
||||
}
|
||||
|
||||
this.credentialId = this.getResponseProperty("CredentialId");
|
||||
this.transports = this.getResponseProperty("Transports") || [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@ export enum FeatureFlag {
|
||||
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
|
||||
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
|
||||
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
|
||||
PasskeyUnlock = "pm-2035-passkey-unlock",
|
||||
DataRecoveryTool = "pm-28813-data-recovery-tool",
|
||||
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
|
||||
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
|
||||
@@ -56,6 +57,7 @@ export enum FeatureFlag {
|
||||
|
||||
/* DIRT */
|
||||
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
|
||||
EventManagementForHuntress = "event-management-for-huntress",
|
||||
PhishingDetection = "phishing-detection",
|
||||
|
||||
/* Vault */
|
||||
@@ -119,6 +121,7 @@ export const DefaultFeatureFlagValue = {
|
||||
|
||||
/* DIRT */
|
||||
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
|
||||
[FeatureFlag.EventManagementForHuntress]: FALSE,
|
||||
[FeatureFlag.PhishingDetection]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
@@ -151,6 +154,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
|
||||
[FeatureFlag.LinuxBiometricsV2]: FALSE,
|
||||
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
|
||||
[FeatureFlag.PasskeyUnlock]: FALSE,
|
||||
[FeatureFlag.DataRecoveryTool]: FALSE,
|
||||
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
|
||||
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { WebAuthnPrfDecryptionOptionResponse } from "../../../auth/models/response/user-decryption-options/webauthn-prf-decryption-option.response";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response";
|
||||
|
||||
export class UserDecryptionResponse extends BaseResponse {
|
||||
masterPasswordUnlock?: MasterPasswordUnlockResponse;
|
||||
|
||||
/**
|
||||
* The sync service returns an array of WebAuthn PRF options.
|
||||
*/
|
||||
webAuthnPrfOptions?: WebAuthnPrfDecryptionOptionResponse[];
|
||||
|
||||
constructor(response: unknown) {
|
||||
super(response);
|
||||
|
||||
@@ -11,5 +17,12 @@ export class UserDecryptionResponse extends BaseResponse {
|
||||
if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") {
|
||||
this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock);
|
||||
}
|
||||
|
||||
const webAuthnPrfOptions = this.getResponseProperty("WebAuthnPrfOptions");
|
||||
if (webAuthnPrfOptions != null && Array.isArray(webAuthnPrfOptions)) {
|
||||
this.webAuthnPrfOptions = webAuthnPrfOptions.map(
|
||||
(option) => new WebAuthnPrfDecryptionOptionResponse(option),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +260,13 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
});
|
||||
|
||||
describe("getVaultTimeoutByUserId$", () => {
|
||||
beforeEach(() => {
|
||||
// Return the input value unchanged
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation(
|
||||
async (timeout) => timeout,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if no user id is provided", async () => {
|
||||
expect(() => vaultTimeoutSettingsService.getVaultTimeoutByUserId$(null)).toThrow(
|
||||
"User id required. Cannot get vault timeout.",
|
||||
@@ -277,6 +284,9 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
defaultVaultTimeout,
|
||||
);
|
||||
expect(result).toBe(defaultVaultTimeout);
|
||||
});
|
||||
|
||||
@@ -299,8 +309,31 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
vaultTimeout,
|
||||
);
|
||||
expect(result).toBe(vaultTimeout);
|
||||
});
|
||||
|
||||
it("promotes timeout when unavailable on client", async () => {
|
||||
const determinedTimeout = VaultTimeoutNumberType.OnMinute;
|
||||
const promotedValue = VaultTimeoutStringType.OnRestart;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(of([]));
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
determinedTimeout,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: custom", () => {
|
||||
@@ -327,6 +360,9 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
policyMinutes,
|
||||
);
|
||||
expect(result).toBe(policyMinutes);
|
||||
},
|
||||
);
|
||||
@@ -345,6 +381,9 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
vaultTimeout,
|
||||
);
|
||||
expect(result).toBe(vaultTimeout);
|
||||
},
|
||||
);
|
||||
@@ -365,8 +404,36 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
);
|
||||
expect(result).toBe(VaultTimeoutNumberType.Immediately);
|
||||
});
|
||||
|
||||
it("promotes policy minutes when unavailable on client", async () => {
|
||||
const promotedValue = VaultTimeoutStringType.Never;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(
|
||||
VAULT_TIMEOUT,
|
||||
VaultTimeoutNumberType.EightHours,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
policyMinutes,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: immediately", () => {
|
||||
@@ -383,7 +450,6 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"when current timeout is %s, returns immediately or promoted value",
|
||||
async (currentTimeout) => {
|
||||
const expectedTimeout = VaultTimeoutNumberType.Immediately;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
|
||||
);
|
||||
@@ -400,6 +466,26 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
expect(result).toBe(expectedTimeout);
|
||||
},
|
||||
);
|
||||
|
||||
it("promotes immediately when unavailable on client", async () => {
|
||||
const promotedValue = VaultTimeoutNumberType.OnMinute;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "immediately" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: onSystemLock", () => {
|
||||
@@ -413,7 +499,6 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"when current timeout is %s, returns onLocked or promoted value",
|
||||
async (currentTimeout) => {
|
||||
const expectedTimeout = VaultTimeoutStringType.OnLocked;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
|
||||
);
|
||||
@@ -446,9 +531,31 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
currentTimeout,
|
||||
);
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
|
||||
it("promotes onLocked when unavailable on client", async () => {
|
||||
const promotedValue = VaultTimeoutStringType.OnRestart;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: onAppRestart", () => {
|
||||
@@ -468,7 +575,9 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
);
|
||||
expect(result).toBe(VaultTimeoutStringType.OnRestart);
|
||||
});
|
||||
|
||||
@@ -488,32 +597,40 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
currentTimeout,
|
||||
);
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: never", () => {
|
||||
it("when current timeout is never, returns never or promoted value", async () => {
|
||||
const expectedTimeout = VaultTimeoutStringType.Never;
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(expectedTimeout);
|
||||
it("promotes onRestart when unavailable on client", async () => {
|
||||
const promotedValue = VaultTimeoutStringType.Never;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "never" } }] as unknown as Policy[]),
|
||||
of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId);
|
||||
await stateProvider.setUserState(
|
||||
VAULT_TIMEOUT,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
);
|
||||
expect(result).toBe(expectedTimeout);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policy type: never", () => {
|
||||
it.each([
|
||||
VaultTimeoutStringType.Never,
|
||||
VaultTimeoutStringType.OnRestart,
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
VaultTimeoutStringType.OnIdle,
|
||||
@@ -532,9 +649,32 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled();
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
currentTimeout,
|
||||
);
|
||||
expect(result).toBe(currentTimeout);
|
||||
});
|
||||
|
||||
it("promotes timeout when unavailable on client", async () => {
|
||||
const determinedTimeout = VaultTimeoutStringType.Never;
|
||||
const promotedValue = VaultTimeoutStringType.OnRestart;
|
||||
|
||||
sessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(promotedValue);
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of([{ data: { type: "never" } }] as unknown as Policy[]),
|
||||
);
|
||||
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, determinedTimeout, mockUserId);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId),
|
||||
);
|
||||
|
||||
expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith(
|
||||
determinedTimeout,
|
||||
);
|
||||
expect(result).toBe(promotedValue);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -179,7 +179,20 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
private async determineVaultTimeout(
|
||||
currentVaultTimeout: VaultTimeout | null,
|
||||
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
|
||||
): Promise<VaultTimeout | null> {
|
||||
): Promise<VaultTimeout> {
|
||||
const determinedTimeout = await this.determineVaultTimeoutInternal(
|
||||
currentVaultTimeout,
|
||||
maxSessionTimeoutPolicyData,
|
||||
);
|
||||
|
||||
// Ensures the timeout is available on this client
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(determinedTimeout);
|
||||
}
|
||||
|
||||
private async determineVaultTimeoutInternal(
|
||||
currentVaultTimeout: VaultTimeout | null,
|
||||
maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null,
|
||||
): Promise<VaultTimeout> {
|
||||
// if current vault timeout is null, apply the client specific default
|
||||
currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout;
|
||||
|
||||
@@ -190,9 +203,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
|
||||
switch (maxSessionTimeoutPolicyData.type) {
|
||||
case "immediately":
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutNumberType.Immediately,
|
||||
);
|
||||
return VaultTimeoutNumberType.Immediately;
|
||||
case "custom":
|
||||
case null:
|
||||
case undefined:
|
||||
@@ -211,9 +222,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnIdle ||
|
||||
currentVaultTimeout === VaultTimeoutStringType.OnSleep
|
||||
) {
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutStringType.OnLocked,
|
||||
);
|
||||
return VaultTimeoutStringType.OnLocked;
|
||||
}
|
||||
break;
|
||||
case "onAppRestart":
|
||||
@@ -227,11 +236,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
}
|
||||
break;
|
||||
case "never":
|
||||
if (currentVaultTimeout === VaultTimeoutStringType.Never) {
|
||||
return await this.sessionTimeoutTypeService.getOrPromoteToAvailable(
|
||||
VaultTimeoutStringType.Never,
|
||||
);
|
||||
}
|
||||
// Policy doesn't override user preference for "never"
|
||||
break;
|
||||
}
|
||||
return currentVaultTimeout;
|
||||
|
||||
@@ -42,6 +42,7 @@ export class Utils {
|
||||
static readonly validHosts: string[] = ["localhost"];
|
||||
static readonly originalMinimumPasswordLength = 8;
|
||||
static readonly minimumPasswordLength = 12;
|
||||
static readonly maximumPasswordLength = 128;
|
||||
static readonly DomainMatchBlacklist = new Map<string, Set<string>>([
|
||||
["google.com", new Set(["script.google.com"])],
|
||||
]);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
LogoutReason,
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
} from "@bitwarden/auth/common";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
@@ -68,7 +68,7 @@ describe("DefaultSyncService", () => {
|
||||
let folderApiService: MockProxy<FolderApiServiceAbstraction>;
|
||||
let organizationService: MockProxy<InternalOrganizationServiceAbstraction>;
|
||||
let sendApiService: MockProxy<SendApiService>;
|
||||
let userDecryptionOptionsService: MockProxy<UserDecryptionOptionsServiceAbstraction>;
|
||||
let userDecryptionOptionsService: MockProxy<InternalUserDecryptionOptionsServiceAbstraction>;
|
||||
let avatarService: MockProxy<AvatarService>;
|
||||
let logoutCallback: jest.Mock<Promise<void>, [logoutReason: LogoutReason, userId?: UserId]>;
|
||||
let billingAccountProfileStateService: MockProxy<BillingAccountProfileStateService>;
|
||||
|
||||
@@ -6,8 +6,8 @@ import { firstValueFrom, map } from "rxjs";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { CollectionService } from "@bitwarden/admin-console/common";
|
||||
import {
|
||||
CollectionDetailsResponse,
|
||||
CollectionData,
|
||||
CollectionDetailsResponse,
|
||||
} from "@bitwarden/common/admin-console/models/collections";
|
||||
import { AccountCryptographicStateService } from "@bitwarden/common/key-management/account-cryptography/account-cryptographic-state.service";
|
||||
import { SecurityStateService } from "@bitwarden/common/key-management/security-state/abstractions/security-state.service";
|
||||
@@ -15,9 +15,13 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security-
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
// FIXME: remove `src` and fix import
|
||||
// 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 { UserDecryptionOptionsServiceAbstraction } from "../../../../auth/src/common/abstractions";
|
||||
import {
|
||||
InternalUserDecryptionOptionsServiceAbstraction,
|
||||
UserDecryptionOptions,
|
||||
WebAuthnPrfUserDecryptionOption,
|
||||
} from "../../../../auth/src/common";
|
||||
// FIXME: remove `src` and fix import
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { LogoutReason } from "../../../../auth/src/common/types";
|
||||
@@ -93,7 +97,7 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
folderApiService: FolderApiServiceAbstraction,
|
||||
private organizationService: InternalOrganizationServiceAbstraction,
|
||||
sendApiService: SendApiService,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction,
|
||||
private avatarService: AvatarService,
|
||||
private logoutCallback: (logoutReason: LogoutReason, userId?: UserId) => Promise<void>,
|
||||
private billingAccountProfileStateService: BillingAccountProfileStateService,
|
||||
@@ -450,5 +454,43 @@ export class DefaultSyncService extends CoreSyncService {
|
||||
);
|
||||
await this.kdfConfigService.setKdfConfig(userId, masterPasswordUnlockData.kdf);
|
||||
}
|
||||
|
||||
// Update WebAuthn PRF options if present
|
||||
if (userDecryption.webAuthnPrfOptions != null && userDecryption.webAuthnPrfOptions.length > 0) {
|
||||
try {
|
||||
// Only update if this is the active user, since setUserDecryptionOptions()
|
||||
// operates on the active user's state
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
|
||||
if (activeAccount?.id !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current options without blocking if they don't exist yet
|
||||
const currentUserDecryptionOptions = await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
).catch((): UserDecryptionOptions | null => {
|
||||
return null;
|
||||
});
|
||||
|
||||
if (currentUserDecryptionOptions != null) {
|
||||
// Update the PRF options while preserving other decryption options
|
||||
const updatedOptions = Object.assign(
|
||||
new UserDecryptionOptions(),
|
||||
currentUserDecryptionOptions,
|
||||
);
|
||||
updatedOptions.webAuthnPrfOptions = userDecryption.webAuthnPrfOptions
|
||||
.map((option) => WebAuthnPrfUserDecryptionOption.fromResponse(option))
|
||||
.filter((option) => option !== undefined);
|
||||
|
||||
await this.userDecryptionOptionsService.setUserDecryptionOptionsById(
|
||||
activeAccount.id,
|
||||
updatedOptions,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logService.error("[Sync] Failed to update WebAuthn PRF options:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full"
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (data.open) {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
export { LockComponent } from "./lock/components/lock.component";
|
||||
export { LockComponentService, UnlockOptions } from "./lock/services/lock-component.service";
|
||||
export { WebAuthnPrfUnlockService } from "./lock/services/webauthn-prf-unlock.service";
|
||||
export { DefaultWebAuthnPrfUnlockService } from "./lock/services/default-webauthn-prf-unlock.service";
|
||||
export { KeyRotationTrustInfoComponent } from "./key-rotation/key-rotation-trust-info.component";
|
||||
export { AccountRecoveryTrustComponent } from "./trust/account-recovery-trust.component";
|
||||
export { EmergencyAccessTrustComponent } from "./trust/emergency-access-trust.component";
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<bit-unlock-via-prf (unlockSuccess)="onPrfUnlockSuccess($event)"></bit-unlock-via-prf>
|
||||
|
||||
<button type="button" bitButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
@@ -113,6 +115,11 @@
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
<bit-unlock-via-prf
|
||||
[formButton]="true"
|
||||
(unlockSuccess)="onPrfUnlockSuccess($event)"
|
||||
></bit-unlock-via-prf>
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
@@ -127,6 +134,7 @@
|
||||
[unlockOptions]="unlockOptions"
|
||||
[biometricUnlockBtnText]="biometricUnlockBtnText"
|
||||
(successfulUnlock)="successfulMasterPasswordUnlock($event)"
|
||||
(prfUnlockSuccess)="onPrfUnlockSuccess($event)"
|
||||
(logOut)="logOut()"
|
||||
></bit-master-password-lock>
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
UnlockOptionValue,
|
||||
UnlockOptions,
|
||||
} from "../services/lock-component.service";
|
||||
import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service";
|
||||
|
||||
import { LockComponent } from "./lock.component";
|
||||
|
||||
@@ -84,6 +85,7 @@ describe("LockComponent", () => {
|
||||
const mockLockComponentService = mock<LockComponentService>();
|
||||
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
|
||||
const mockBroadcasterService = mock<BroadcasterService>();
|
||||
const mockWebAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
|
||||
const mockEncryptedMigrator = mock<EncryptedMigrator>();
|
||||
const mockActivatedRoute = {
|
||||
snapshot: {
|
||||
@@ -149,6 +151,7 @@ describe("LockComponent", () => {
|
||||
{ provide: LockComponentService, useValue: mockLockComponentService },
|
||||
{ provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService },
|
||||
{ provide: BroadcasterService, useValue: mockBroadcasterService },
|
||||
{ provide: WebAuthnPrfUnlockService, useValue: mockWebAuthnPrfUnlockService },
|
||||
{ provide: ActivatedRoute, useValue: mockActivatedRoute },
|
||||
{ provide: EncryptedMigrator, useValue: mockEncryptedMigrator },
|
||||
],
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from "../services/lock-component.service";
|
||||
|
||||
import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component";
|
||||
import { UnlockViaPrfComponent } from "./unlock-via-prf.component";
|
||||
|
||||
const BroadcasterSubscriptionId = "LockComponent";
|
||||
|
||||
@@ -98,6 +99,7 @@ const BIOMETRIC_UNLOCK_TEMPORARY_UNAVAILABLE_STATUSES = [
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
UnlockViaPrfComponent,
|
||||
MasterPasswordLockComponent,
|
||||
TooltipDirective,
|
||||
],
|
||||
@@ -460,6 +462,14 @@ export class LockComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async onPrfUnlockSuccess(userKey: UserKey): Promise<void> {
|
||||
await this.setUserKeyAndContinue(userKey);
|
||||
}
|
||||
|
||||
togglePassword() {
|
||||
this.showPassword = !this.showPassword;
|
||||
}
|
||||
|
||||
private validatePin(): boolean {
|
||||
if (this.formGroup?.invalid) {
|
||||
this.toastService.showToast({
|
||||
|
||||
@@ -54,6 +54,11 @@
|
||||
</button>
|
||||
}
|
||||
|
||||
<bit-unlock-via-prf
|
||||
[formButton]="true"
|
||||
(unlockSuccess)="onPrfUnlockSuccess($event)"
|
||||
></bit-unlock-via-prf>
|
||||
|
||||
<button type="button" bitButton bitFormButton block (click)="logOut.emit()">
|
||||
{{ "logOut" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { UserKey } from "@bitwarden/common/types/key";
|
||||
import {
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
DialogService,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ToastService,
|
||||
@@ -27,6 +28,7 @@ import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { UnlockOption, UnlockOptions } from "../../services/lock-component.service";
|
||||
import { WebAuthnPrfUnlockService } from "../../services/webauthn-prf-unlock.service";
|
||||
|
||||
import { MasterPasswordLockComponent } from "./master-password-lock.component";
|
||||
|
||||
@@ -41,6 +43,8 @@ describe("MasterPasswordLockComponent", () => {
|
||||
const logService = mock<LogService>();
|
||||
const platformUtilsService = mock<PlatformUtilsService>();
|
||||
const messageListener = mock<MessageListener>();
|
||||
const webAuthnPrfUnlockService = mock<WebAuthnPrfUnlockService>();
|
||||
const dialogService = mock<DialogService>();
|
||||
|
||||
const mockMasterPassword = "testExample";
|
||||
const activeAccount: Account = {
|
||||
@@ -64,6 +68,7 @@ describe("MasterPasswordLockComponent", () => {
|
||||
enabled: false,
|
||||
biometricsStatus: BiometricsStatus.NotEnabledLocally,
|
||||
},
|
||||
prf: { enabled: false },
|
||||
};
|
||||
|
||||
accountService.activeAccount$ = of(account);
|
||||
@@ -110,6 +115,8 @@ describe("MasterPasswordLockComponent", () => {
|
||||
{ provide: LogService, useValue: logService },
|
||||
{ provide: PlatformUtilsService, useValue: platformUtilsService },
|
||||
{ provide: MessageListener, useValue: messageListener },
|
||||
{ provide: WebAuthnPrfUnlockService, useValue: webAuthnPrfUnlockService },
|
||||
{ provide: DialogService, useValue: dialogService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
UnlockOptions,
|
||||
UnlockOptionValue,
|
||||
} from "../../services/lock-component.service";
|
||||
import { UnlockViaPrfComponent } from "../unlock-via-prf.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@@ -49,6 +50,7 @@ import {
|
||||
FormFieldModule,
|
||||
AsyncActionsModule,
|
||||
IconButtonModule,
|
||||
UnlockViaPrfComponent,
|
||||
],
|
||||
})
|
||||
export class MasterPasswordLockComponent implements OnInit, OnDestroy {
|
||||
@@ -76,6 +78,7 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
|
||||
successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>();
|
||||
prfUnlockSuccess = output<UserKey>();
|
||||
logOut = output<void>();
|
||||
|
||||
protected showPassword = false;
|
||||
@@ -143,4 +146,8 @@ export class MasterPasswordLockComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPrfUnlockSuccess(userKey: UserKey): void {
|
||||
this.prfUnlockSuccess.emit(userKey);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, OnInit, input, output } from "@angular/core";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { AsyncActionsModule, ButtonModule, DialogService } from "@bitwarden/components";
|
||||
|
||||
import { WebAuthnPrfUnlockService } from "../services/webauthn-prf-unlock.service";
|
||||
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-unlock-via-prf",
|
||||
standalone: true,
|
||||
imports: [CommonModule, JslibModule, ButtonModule, AsyncActionsModule],
|
||||
template: `
|
||||
@if (isAvailable) {
|
||||
@if (formButton()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="unlockViaPrf()"
|
||||
[disabled]="unlocking"
|
||||
[loading]="unlocking"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "unlockWithPasskey" | i18n }}
|
||||
</button>
|
||||
}
|
||||
@if (!formButton()) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
block
|
||||
(click)="unlockViaPrf()"
|
||||
[disabled]="unlocking"
|
||||
[loading]="unlocking"
|
||||
>
|
||||
<i class="bwi bwi-passkey tw-mr-1" aria-hidden="true"></i>
|
||||
{{ "unlockWithPasskey" | i18n }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UnlockViaPrfComponent implements OnInit {
|
||||
readonly formButton = input<boolean>(false);
|
||||
readonly unlockSuccess = output<UserKey>();
|
||||
|
||||
unlocking = false;
|
||||
isAvailable = false;
|
||||
private userId: UserId | null = null;
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private webAuthnPrfUnlockService: WebAuthnPrfUnlockService,
|
||||
private dialogService: DialogService,
|
||||
private i18nService: I18nService,
|
||||
private logService: LogService,
|
||||
) {}
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
|
||||
if (activeAccount?.id) {
|
||||
this.userId = activeAccount.id;
|
||||
this.isAvailable = await this.webAuthnPrfUnlockService.isPrfUnlockAvailable(this.userId);
|
||||
}
|
||||
}
|
||||
|
||||
async unlockViaPrf(): Promise<void> {
|
||||
if (!this.userId || !this.isAvailable) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.unlocking = true;
|
||||
|
||||
try {
|
||||
const userKey = await this.webAuthnPrfUnlockService.unlockVaultWithPrf(this.userId);
|
||||
this.unlockSuccess.emit(userKey);
|
||||
} catch (error) {
|
||||
this.logService.error("[UnlockViaPrfComponent] Failed to unlock via PRF:", error);
|
||||
|
||||
let errorMessage = this.i18nService.t("unexpectedError");
|
||||
|
||||
// Handle specific PRF error cases
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("No PRF credentials")) {
|
||||
errorMessage = this.i18nService.t("noPrfCredentialsAvailable");
|
||||
} else if (error.message.includes("canceled")) {
|
||||
// User canceled the operation, don't show error
|
||||
this.unlocking = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.dialogService.openSimpleDialog({
|
||||
title: { key: "error" },
|
||||
content: errorMessage,
|
||||
acceptButtonText: { key: "ok" },
|
||||
type: "danger",
|
||||
});
|
||||
} finally {
|
||||
this.unlocking = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
UserDecryptionOptions,
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
WebAuthnPrfUserDecryptionOption,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { PrfKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { WebAuthnPrfUnlockService } from "./webauthn-prf-unlock.service";
|
||||
|
||||
export class DefaultWebAuthnPrfUnlockService implements WebAuthnPrfUnlockService {
|
||||
private navigatorCredentials: CredentialsContainer;
|
||||
|
||||
constructor(
|
||||
private webAuthnLoginPrfKeyService: WebAuthnLoginPrfKeyServiceAbstraction,
|
||||
private keyService: KeyService,
|
||||
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
|
||||
private encryptService: EncryptService,
|
||||
private environmentService: EnvironmentService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
private window: Window,
|
||||
private logService: LogService,
|
||||
private configService: ConfigService,
|
||||
) {
|
||||
this.navigatorCredentials = this.window.navigator.credentials;
|
||||
}
|
||||
|
||||
async isPrfUnlockAvailable(userId: UserId): Promise<boolean> {
|
||||
try {
|
||||
// Check if feature flag is enabled
|
||||
const passkeyUnlockEnabled = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.PasskeyUnlock,
|
||||
);
|
||||
if (!passkeyUnlockEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if browser supports WebAuthn
|
||||
if (!this.navigatorCredentials || !this.navigatorCredentials.get) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If we're in the browser extension, check if we're in a Chromium browser
|
||||
if (
|
||||
this.platformUtilsService.getClientType() === ClientType.Browser &&
|
||||
!this.platformUtilsService.isChromium()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if user has any WebAuthn PRF credentials registered
|
||||
const credentials = await this.getPrfUnlockCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logService.error("Error checking PRF unlock availability:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async getPrfUnlockCredentials(
|
||||
userId: UserId,
|
||||
): Promise<{ credentialId: string; transports: string[] }[]> {
|
||||
try {
|
||||
const userDecryptionOptions = await this.getUserDecryptionOptions(userId);
|
||||
if (!userDecryptionOptions?.webAuthnPrfOptions) {
|
||||
return [];
|
||||
}
|
||||
return userDecryptionOptions.webAuthnPrfOptions.map((option) => ({
|
||||
credentialId: option.credentialId,
|
||||
transports: option.transports,
|
||||
}));
|
||||
} catch (error) {
|
||||
this.logService.error("Error getting PRF unlock credentials:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlocks the vault using WebAuthn PRF.
|
||||
*
|
||||
* @param userId The user ID to unlock vault for
|
||||
* @returns Promise<UserKey> the decrypted user key
|
||||
* @throws Error if unlock fails for any reason
|
||||
*/
|
||||
async unlockVaultWithPrf(userId: UserId): Promise<UserKey> {
|
||||
// Get offline PRF credentials from user decryption options
|
||||
const credentials = await this.getPrfUnlockCredentials(userId);
|
||||
if (credentials.length === 0) {
|
||||
throw new Error("No PRF credentials available for unlock");
|
||||
}
|
||||
|
||||
const response = await this.performWebAuthnGetWithPrf(credentials, userId);
|
||||
const prfKey = await this.createPrfKeyFromResponse(response);
|
||||
const prfOption = await this.getPrfOptionForCredential(response.id, userId);
|
||||
|
||||
// PRF unlock follows the same key derivation process as PRF login:
|
||||
// PRF key → decrypt private key → use private key to decrypt user key
|
||||
|
||||
// Step 1: Decrypt PRF encrypted private key using the PRF key
|
||||
const privateKey = await this.encryptService.unwrapDecapsulationKey(
|
||||
new EncString(prfOption.encryptedPrivateKey),
|
||||
prfKey,
|
||||
);
|
||||
|
||||
// Step 2: Use private key to decrypt user key
|
||||
const userKey = await this.encryptService.decapsulateKeyUnsigned(
|
||||
new EncString(prfOption.encryptedUserKey),
|
||||
privateKey,
|
||||
);
|
||||
|
||||
if (!userKey) {
|
||||
throw new Error("Failed to decrypt user key from private key");
|
||||
}
|
||||
|
||||
return userKey as UserKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs WebAuthn get operation with PRF extension.
|
||||
*
|
||||
* @param credentials Available PRF credentials for the user
|
||||
* @returns PublicKeyCredential response from the authenticator
|
||||
* @throws Error if WebAuthn operation fails or returns invalid response
|
||||
*/
|
||||
private async performWebAuthnGetWithPrf(
|
||||
credentials: { credentialId: string; transports: string[] }[],
|
||||
userId: UserId,
|
||||
): Promise<PublicKeyCredential> {
|
||||
const rpId = await this.getRpIdForUser(userId);
|
||||
const prfSalt = await this.getUnlockWithPrfSalt();
|
||||
|
||||
const options: CredentialRequestOptions = {
|
||||
publicKey: {
|
||||
challenge: new Uint8Array(32),
|
||||
allowCredentials: credentials.map(({ credentialId, transports }) => {
|
||||
// The credential ID is already base64url encoded from login storage
|
||||
// We need to decode it to ArrayBuffer for WebAuthn
|
||||
const decodedId = Fido2Utils.stringToBuffer(credentialId);
|
||||
return {
|
||||
type: "public-key",
|
||||
id: decodedId,
|
||||
transports: (transports || []) as AuthenticatorTransport[],
|
||||
};
|
||||
}),
|
||||
rpId,
|
||||
userVerification: "preferred", // Allow platform authenticators to work properly
|
||||
extensions: {
|
||||
prf: { eval: { first: prfSalt } },
|
||||
} as any,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await this.navigatorCredentials.get(options);
|
||||
|
||||
if (!response) {
|
||||
throw new Error("WebAuthn get() returned null/undefined");
|
||||
}
|
||||
|
||||
if (!(response instanceof PublicKeyCredential)) {
|
||||
throw new Error("Failed to get PRF credential for unlock");
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts PRF result from WebAuthn response and creates a PrfKey.
|
||||
*
|
||||
* @param response PublicKeyCredential response from authenticator
|
||||
* @returns PrfKey derived from the PRF extension output
|
||||
* @throws Error if no PRF result is present in the response
|
||||
*/
|
||||
private async createPrfKeyFromResponse(response: PublicKeyCredential): Promise<PrfKey> {
|
||||
// Extract PRF result
|
||||
// TODO: Remove `any` when typescript typings add support for PRF
|
||||
const extensionResults = response.getClientExtensionResults() as any;
|
||||
const prfResult = extensionResults.prf?.results?.first;
|
||||
if (!prfResult) {
|
||||
throw new Error("No PRF result received from authenticator");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.webAuthnLoginPrfKeyService.createSymmetricKeyFromPrf(prfResult);
|
||||
} catch (error) {
|
||||
this.logService.error("Failed to create unlock key from PRF:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the WebAuthn PRF option that matches the credential used in the response.
|
||||
*
|
||||
* @param credentialId Credential ID to match
|
||||
* @param userId User ID to get decryption options for
|
||||
* @returns Matching WebAuthnPrfUserDecryptionOption with encrypted keys
|
||||
* @throws Error if no PRF options exist or no matching option is found
|
||||
*/
|
||||
private async getPrfOptionForCredential(
|
||||
credentialId: string,
|
||||
userId: UserId,
|
||||
): Promise<WebAuthnPrfUserDecryptionOption> {
|
||||
const userDecryptionOptions = await this.getUserDecryptionOptions(userId);
|
||||
|
||||
if (
|
||||
!userDecryptionOptions?.webAuthnPrfOptions ||
|
||||
userDecryptionOptions.webAuthnPrfOptions.length === 0
|
||||
) {
|
||||
throw new Error("No WebAuthn PRF option found for user - cannot perform PRF unlock");
|
||||
}
|
||||
|
||||
const prfOption = userDecryptionOptions.webAuthnPrfOptions.find(
|
||||
(option) => option.credentialId === credentialId,
|
||||
);
|
||||
|
||||
if (!prfOption) {
|
||||
throw new Error("No matching WebAuthn PRF option found for this credential");
|
||||
}
|
||||
|
||||
return prfOption;
|
||||
}
|
||||
|
||||
private async getUnlockWithPrfSalt(): Promise<ArrayBuffer> {
|
||||
try {
|
||||
// Use the same salt as login to ensure PRF keys match
|
||||
return await this.webAuthnLoginPrfKeyService.getLoginWithPrfSalt();
|
||||
} catch (error) {
|
||||
this.logService.error("Error getting unlock PRF salt:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get user decryption options for a user
|
||||
*/
|
||||
private async getUserDecryptionOptions(userId: UserId): Promise<UserDecryptionOptions | null> {
|
||||
try {
|
||||
return (await firstValueFrom(
|
||||
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
|
||||
)) as UserDecryptionOptions;
|
||||
} catch (error) {
|
||||
this.logService.error("Error getting user decryption options:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to get the appropriate rpId for WebAuthn PRF operations
|
||||
* Returns the hostname from the user's environment configuration
|
||||
*/
|
||||
private async getRpIdForUser(userId: UserId): Promise<string | undefined> {
|
||||
try {
|
||||
const environment = await firstValueFrom(this.environmentService.getEnvironment$(userId));
|
||||
const hostname = environment.getHostname();
|
||||
|
||||
// The navigator.credentials.get call will fail if rpId is set but is null/empty. Undefined uses the current host.
|
||||
if (!hostname) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Extract hostname using URL parsing to handle IPv6 and ports correctly
|
||||
// This removes ports etc.
|
||||
const url = new URL(`https://${hostname}`);
|
||||
const rpId = url.hostname;
|
||||
|
||||
return rpId;
|
||||
} catch (error) {
|
||||
this.logService.error("Error getting rpId", error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const UnlockOption = Object.freeze({
|
||||
MasterPassword: "masterPassword",
|
||||
Pin: "pin",
|
||||
Biometrics: "biometrics",
|
||||
Prf: "prf",
|
||||
}) satisfies { [Prop in keyof UnlockOptions as Capitalize<Prop>]: Prop };
|
||||
|
||||
export type UnlockOptions = {
|
||||
@@ -23,6 +24,9 @@ export type UnlockOptions = {
|
||||
enabled: boolean;
|
||||
biometricsStatus: BiometricsStatus;
|
||||
};
|
||||
prf: {
|
||||
enabled: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { UserKey } from "@bitwarden/common/types/key";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
/**
|
||||
* Service for unlocking vault using WebAuthn PRF.
|
||||
* Provides offline vault unlock capabilities by deriving unlock keys from PRF outputs.
|
||||
*/
|
||||
export abstract class WebAuthnPrfUnlockService {
|
||||
/**
|
||||
* Check if PRF unlock is available for the current user
|
||||
* @param userId The user ID to check PRF unlock availability for
|
||||
* @returns Promise<boolean> true if PRF unlock is available
|
||||
*/
|
||||
abstract isPrfUnlockAvailable(userId: UserId): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Attempt to unlock the vault using WebAuthn PRF
|
||||
* @param userId The user ID to unlock vault for
|
||||
* @returns Promise<UserKey> the decrypted user key
|
||||
* @throws Error if no PRF credentials are available
|
||||
* @throws Error if the authenticator returns no PRF result
|
||||
* @throws Error if the user cancels the WebAuthn operation
|
||||
* @throws Error if decryption of the user key fails
|
||||
* @throws Error if no matching PRF option is found for the credential
|
||||
*/
|
||||
abstract unlockVaultWithPrf(userId: UserId): Promise<UserKey>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, bufferCount, firstValueFrom, lastValueFrom, of, take } from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
|
||||
import { KeyGenerationService } from "@bitwarden/common/key-management/crypto";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
@@ -259,7 +260,18 @@ describe("keyService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the Auto key if vault timeout is set to anything other than null", async () => {
|
||||
it("sets an Auto key if vault timeout is set to 10 minutes and is Cli", async () => {
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
|
||||
platformUtilService.getClientType.mockReturnValue(ClientType.Cli);
|
||||
|
||||
await keyService.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(mockUserKey.keyB64, {
|
||||
userId: mockUserId,
|
||||
});
|
||||
});
|
||||
|
||||
it("clears the Auto key if vault timeout is set to 10 minutes", async () => {
|
||||
await stateProvider.setUserState(VAULT_TIMEOUT, 10, mockUserId);
|
||||
|
||||
await keyService.setUserKey(mockUserKey, mockUserId);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
import { ClientType } from "@bitwarden/client-type";
|
||||
import { EncryptedOrganizationKeyData } from "@bitwarden/common/admin-console/models/data/encrypted-organization-key.data";
|
||||
import { BaseEncryptedOrganizationKey } from "@bitwarden/common/admin-console/models/domain/encrypted-organization-key";
|
||||
import { ProfileOrganizationResponse } from "@bitwarden/common/admin-console/models/response/profile-organization.response";
|
||||
@@ -671,9 +672,13 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
}
|
||||
|
||||
protected async shouldStoreKey(keySuffix: KeySuffixOptions, userId: UserId) {
|
||||
let shouldStoreKey = false;
|
||||
switch (keySuffix) {
|
||||
case KeySuffixOptions.Auto: {
|
||||
// Cli has fixed Never vault timeout, and it should not be affected by a policy.
|
||||
if (this.platformUtilService.getClientType() == ClientType.Cli) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// TODO: Sharing the UserKeyDefinition is temporary to get around a circ dep issue between
|
||||
// the VaultTimeoutSettingsSvc and this service.
|
||||
// This should be fixed as part of the PM-7082 - Auto Key Service work.
|
||||
@@ -683,11 +688,14 @@ export class DefaultKeyService implements KeyServiceAbstraction {
|
||||
.pipe(filter((timeout) => timeout != null)),
|
||||
);
|
||||
|
||||
shouldStoreKey = vaultTimeout == VaultTimeoutStringType.Never;
|
||||
break;
|
||||
this.logService.debug(
|
||||
`[KeyService] Should store auto key for vault timeout ${vaultTimeout}`,
|
||||
);
|
||||
|
||||
return vaultTimeout == VaultTimeoutStringType.Never;
|
||||
}
|
||||
}
|
||||
return shouldStoreKey;
|
||||
return false;
|
||||
}
|
||||
|
||||
protected async getKeyFromStorage(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { Component, ChangeDetectionStrategy } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { By } from "@angular/platform-browser";
|
||||
|
||||
@@ -7,11 +7,10 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
|
||||
import { VaultCarouselComponent } from "./carousel.component";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "app-test-carousel-slide",
|
||||
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<vault-carousel label="Storybook Demo">
|
||||
<vault-carousel-slide label="First Slide">
|
||||
@@ -93,8 +92,7 @@ describe("VaultCarouselComponent", () => {
|
||||
const backButton = fixture.debugElement.queryAll(By.css("button"))[0];
|
||||
|
||||
middleSlideButton.nativeElement.click();
|
||||
await new Promise((r) => setTimeout(r, 100)); // Give time for the DOM to update.
|
||||
|
||||
fixture.detectChanges();
|
||||
jest.spyOn(component.slideChange, "emit");
|
||||
|
||||
backButton.nativeElement.click();
|
||||
|
||||
@@ -22,7 +22,6 @@ import { take } from "rxjs";
|
||||
|
||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||
import { ButtonModule, IconButtonModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component";
|
||||
import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component";
|
||||
@@ -41,7 +40,6 @@ import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.com
|
||||
ButtonModule,
|
||||
VaultCarouselContentComponent,
|
||||
VaultCarouselButtonComponent,
|
||||
I18nPipe,
|
||||
],
|
||||
})
|
||||
export class VaultCarouselComponent implements AfterViewInit {
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { DialogRef, DialogService, ToastService } from "@bitwarden/components";
|
||||
import { LogService } from "@bitwarden/logging";
|
||||
@@ -43,6 +44,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
let mockEventCollectionService: MockProxy<EventCollectionService>;
|
||||
let mockConfigService: MockProxy<ConfigService>;
|
||||
let mockOrganizationUserApiService: MockProxy<OrganizationUserApiService>;
|
||||
let mockSyncService: MockProxy<SyncService>;
|
||||
|
||||
const userId = "user-id" as UserId;
|
||||
const organizationId = "org-id" as OrganizationId;
|
||||
@@ -79,6 +81,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockEventCollectionService = mock<EventCollectionService>();
|
||||
mockConfigService = mock<ConfigService>();
|
||||
mockOrganizationUserApiService = mock<OrganizationUserApiService>();
|
||||
mockSyncService = mock<SyncService>();
|
||||
|
||||
mockI18nService.t.mockImplementation((key) => key);
|
||||
transferInProgressValues = [];
|
||||
@@ -95,6 +98,7 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockEventCollectionService,
|
||||
mockConfigService,
|
||||
mockOrganizationUserApiService,
|
||||
mockSyncService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -557,6 +561,8 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockOrganizationService.organizations$.mockReturnValue(of(options.organizations ?? []));
|
||||
mockCipherService.cipherViews$.mockReturnValue(of(options.ciphers ?? []));
|
||||
mockCollectionService.defaultUserCollection$.mockReturnValue(of(options.defaultCollection));
|
||||
mockSyncService.fullSync.mockResolvedValue(true);
|
||||
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
it("does nothing when feature flag is disabled", async () => {
|
||||
@@ -635,11 +641,11 @@ describe("DefaultVaultItemsTransferService", () => {
|
||||
mockDialogService.open
|
||||
.mockReturnValueOnce(createMockDialogRef(TransferItemsDialogResult.Declined))
|
||||
.mockReturnValueOnce(createMockDialogRef(LeaveConfirmationDialogResult.Confirmed));
|
||||
mockOrganizationUserApiService.revokeSelf.mockResolvedValue(undefined);
|
||||
|
||||
await service.enforceOrganizationDataOwnership(userId);
|
||||
|
||||
expect(mockOrganizationUserApiService.revokeSelf).toHaveBeenCalledWith(organizationId);
|
||||
expect(mockSyncService.fullSync).toHaveBeenCalledWith(true);
|
||||
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||
variant: "success",
|
||||
message: "leftOrganization",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { getById } from "@bitwarden/common/platform/misc";
|
||||
import { OrganizationId, CollectionId } from "@bitwarden/common/types/guid";
|
||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||
import { SyncService } from "@bitwarden/common/vault/abstractions/sync/sync.service.abstraction";
|
||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
|
||||
import { DialogService, ToastService } from "@bitwarden/components";
|
||||
@@ -54,6 +55,7 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
private eventCollectionService: EventCollectionService,
|
||||
private configService: ConfigService,
|
||||
private organizationUserApiService: OrganizationUserApiService,
|
||||
private syncService: SyncService,
|
||||
) {}
|
||||
|
||||
private _transferInProgressSubject = new BehaviorSubject(false);
|
||||
@@ -164,7 +166,6 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
|
||||
if (!userAcceptedTransfer) {
|
||||
await this.organizationUserApiService.revokeSelf(migrationInfo.enforcingOrganization.id);
|
||||
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
message: this.i18nService.t("leftOrganization"),
|
||||
@@ -176,6 +177,8 @@ export class DefaultVaultItemsTransferService implements VaultItemsTransferServi
|
||||
undefined,
|
||||
migrationInfo.enforcingOrganization.id,
|
||||
);
|
||||
// Sync to reflect organization removal
|
||||
await this.syncService.fullSync(true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user