From bbea11388aed06eeb24c8f3efaff2da94f12f2f4 Mon Sep 17 00:00:00 2001 From: Maciej Zieniuk <167752252+mzieniukbw@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:55:59 +0100 Subject: [PATCH 001/136] [PM-26057] Enforce session timeout policy (#17424) * enforce session timeout policy * better angular validation * lint fix * missing switch break * fallback when timeout not supported with highest available timeout * failing unit tests * incorrect policy message * vault timeout type adjustments * fallback to "on browser refresh" for browser, when policy is set to "on system locked", but not available (Safari) * docs, naming improvements * fallback for current user session timeout to "on refresh", when policy is set to "on system locked", but not available. * don't display policy message when the policy does not affect available timeout options * 8 hours default when changing from non-numeric timeout to Custom. * failing unit test * missing locales, changing functions access to private, docs * removal of redundant magic number * missing await * await once for available timeout options * adjusted messaging * unit test coverage * vault timeout numeric module exports * unit test coverage --- apps/browser/src/_locales/en/messages.json | 44 + .../settings/account-security.component.html | 4 +- .../settings/account-security.component.ts | 4 +- .../browser/src/background/main.background.ts | 6 + ...timeout-settings-component.service.spec.ts | 57 ++ ...sion-timeout-settings-component.service.ts | 52 +- ...owser-session-timeout-type.service.spec.ts | 139 +++ .../browser-session-timeout-type.service.ts | 43 + .../src/popup/services/services.module.ts | 14 +- .../cli-session-timeout-type.service.ts | 15 + .../service-container/service-container.ts | 4 + .../src/app/accounts/settings.component.html | 4 +- .../src/app/accounts/settings.component.ts | 4 +- .../src/app/services/services.module.ts | 12 +- ...sion-timeout-settings-component.service.ts | 48 - ...sktop-session-timeout-type.service.spec.ts | 125 +++ .../desktop-session-timeout-type.service.ts | 46 + apps/desktop/src/locales/en/messages.json | 41 + apps/web/src/app/core/core.module.ts | 12 +- ...sion-timeout-settings-component.service.ts | 39 - .../web-session-timeout-type.service.spec.ts | 115 +++ .../web-session-timeout-type.service.ts | 44 + .../app/settings/preferences.component.html | 4 +- .../src/app/settings/preferences.component.ts | 4 +- apps/web/src/locales/en/messages.json | 38 + .../session-timeout.component.spec.ts | 10 +- .../policies/session-timeout.component.ts | 36 +- .../src/services/jslib-services.module.ts | 2 + .../session-timeout-type.service.ts | 15 + .../key-management/session-timeout/index.ts | 3 + .../maximum-session-timeout-policy.type.ts | 7 + .../types/session-timeout.type.ts | 8 + .../src/key-management/vault-timeout/index.ts | 3 +- .../vault-timeout-settings.service.spec.ts | 298 ++++++- .../vault-timeout-settings.service.ts | 127 ++- .../maximum-vault-timeout-policy.type.ts | 6 - .../vault-timeout/types/vault-timeout.type.ts | 14 +- libs/key-management-ui/src/index.ts | 1 + ...ession-timeout-input-legacy.component.html | 47 + .../session-timeout-input-legacy.component.ts | 296 +++++++ .../session-timeout-input.component.html | 86 +- .../session-timeout-input.component.spec.ts | 817 +++++++++++++++++- .../session-timeout-input.component.ts | 373 ++++---- .../session-timeout-settings.component.html | 25 +- ...session-timeout-settings.component.spec.ts | 203 ++++- .../session-timeout-settings.component.ts | 122 +-- ...timeout-settings-component.service.spec.ts | 337 ++++++++ ...sion-timeout-settings-component.service.ts | 159 +++- 48 files changed, 3344 insertions(+), 569 deletions(-) create mode 100644 apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts create mode 100644 apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts create mode 100644 apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts create mode 100644 apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts delete mode 100644 apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts create mode 100644 apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts create mode 100644 apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts delete mode 100644 apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts create mode 100644 apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts create mode 100644 apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts create mode 100644 libs/common/src/key-management/session-timeout/abstractions/session-timeout-type.service.ts create mode 100644 libs/common/src/key-management/session-timeout/index.ts create mode 100644 libs/common/src/key-management/session-timeout/types/maximum-session-timeout-policy.type.ts create mode 100644 libs/common/src/key-management/session-timeout/types/session-timeout.type.ts delete mode 100644 libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts create mode 100644 libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.html create mode 100644 libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.ts create mode 100644 libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.spec.ts diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index bbdea838e62..5c8c351e58b 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5881,5 +5881,49 @@ }, "sessionTimeoutSettingsAction": { "message": "Timeout action" + }, + "sessionTimeoutSettingsManagedByOrganization": { + "message": "This setting is managed by your organization." + }, + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { + "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "placeholders": { + "hours": { + "content": "$1", + "example": "8" + }, + "minutes": { + "content": "$2", + "example": "2" + } + } + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately": { + "message": "Your organization has set the default session timeout to Immediately." + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked": { + "message": "Your organization has set the default session timeout to On system lock." + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart": { + "message": "Your organization has set the default session timeout to On browser restart." + }, + "sessionTimeoutSettingsPolicyMaximumError": { + "message": "Maximum timeout cannot exceed $HOURS$ hour(s) and $MINUTES$ minute(s)", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, + "sessionTimeoutOnRestart": { + "message": "On browser restart" + }, + "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { + "message": "Set an unlock method to change your timeout action" } } diff --git a/apps/browser/src/auth/popup/settings/account-security.component.html b/apps/browser/src/auth/popup/settings/account-security.component.html index 37efcee9012..b5d725b4a82 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.html +++ b/apps/browser/src/auth/popup/settings/account-security.component.html @@ -86,12 +86,12 @@ - - + {{ "vaultTimeoutAction1" | i18n }} diff --git a/apps/browser/src/auth/popup/settings/account-security.component.ts b/apps/browser/src/auth/popup/settings/account-security.component.ts index e6e7be96c08..4ff29c8853e 100644 --- a/apps/browser/src/auth/popup/settings/account-security.component.ts +++ b/apps/browser/src/auth/popup/settings/account-security.component.ts @@ -70,7 +70,7 @@ import { BiometricsStatus, } from "@bitwarden/key-management"; import { - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, SessionTimeoutSettingsComponent, } from "@bitwarden/key-management-ui"; @@ -109,7 +109,7 @@ import { AwaitDesktopDialogComponent } from "./await-desktop-dialog.component"; SessionTimeoutSettingsComponent, SpotlightComponent, TypographyModule, - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, ], }) export class AccountSecurityComponent implements OnInit, OnDestroy { diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 143f5d1f6b3..1bd47186914 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -297,6 +297,7 @@ import { SafariApp } from "../browser/safariApp"; import { PhishingDataService } from "../dirt/phishing-detection/services/phishing-data.service"; import { PhishingDetectionService } from "../dirt/phishing-detection/services/phishing-detection.service"; import { BackgroundBrowserBiometricsService } from "../key-management/biometrics/background-browser-biometrics.service"; +import { BrowserSessionTimeoutTypeService } from "../key-management/session-timeout/services/browser-session-timeout-type.service"; import VaultTimeoutService from "../key-management/vault-timeout/vault-timeout.service"; import { BrowserActionsService } from "../platform/actions/browser-actions.service"; import { DefaultBadgeBrowserApi } from "../platform/badge/badge-browser-api"; @@ -738,6 +739,10 @@ export default class MainBackground { this.accountService, ); + const sessionTimeoutTypeService = new BrowserSessionTimeoutTypeService( + this.platformUtilsService, + ); + this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, pinStateService, @@ -749,6 +754,7 @@ export default class MainBackground { this.stateProvider, this.logService, VaultTimeoutStringType.OnRestart, // default vault timeout + sessionTimeoutTypeService, ); this.apiService = new ApiService( diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts new file mode 100644 index 00000000000..cf5d556a553 --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.spec.ts @@ -0,0 +1,57 @@ +import { mock } from "jest-mock-extended"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; + +import { BrowserSessionTimeoutSettingsComponentService } from "./browser-session-timeout-settings-component.service"; + +describe("BrowserSessionTimeoutSettingsComponentService", () => { + let service: BrowserSessionTimeoutSettingsComponentService; + let mockI18nService: jest.Mocked; + let mockSessionTimeoutTypeService: jest.Mocked; + let mockPolicyService: jest.Mocked; + let mockMessagingService: jest.Mocked; + + beforeEach(() => { + mockI18nService = mock(); + mockSessionTimeoutTypeService = mock(); + mockPolicyService = mock(); + mockMessagingService = mock(); + + service = new BrowserSessionTimeoutSettingsComponentService( + mockI18nService, + mockSessionTimeoutTypeService, + mockPolicyService, + mockMessagingService, + ); + }); + + describe("onTimeoutSave", () => { + it("should call messagingService.send with 'bgReseedStorage' when timeout is Never", () => { + service.onTimeoutSave(VaultTimeoutStringType.Never); + + expect(mockMessagingService.send).toHaveBeenCalledWith("bgReseedStorage"); + }); + + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Custom, + ])("should not call messagingService.send when timeout is %s", (timeoutValue) => { + service.onTimeoutSave(timeoutValue); + + expect(mockMessagingService.send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts index 297718687eb..24925e25e24 100644 --- a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-settings-component.service.ts @@ -1,56 +1,24 @@ -import { defer, Observable, of } from "rxjs"; - +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeout, - VaultTimeoutOption, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; 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 { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; -export class BrowserSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("immediately"), value: 0 }, - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - ]; - - const showOnLocked = - !this.platformUtilsService.isFirefox() && - !this.platformUtilsService.isSafari() && - !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel"); - - if (showOnLocked) { - options.push({ - name: this.i18nService.t("onLocked"), - value: VaultTimeoutStringType.OnLocked, - }); - } - - options.push( - { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, - { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, - ); - - return of(options); - }); - +export class BrowserSessionTimeoutSettingsComponentService extends SessionTimeoutSettingsComponentService { constructor( - private readonly i18nService: I18nService, - private readonly platformUtilsService: PlatformUtilsService, + i18nService: I18nService, + sessionTimeoutTypeService: SessionTimeoutTypeService, + policyService: PolicyService, private readonly messagingService: MessagingService, - ) {} + ) { + super(i18nService, sessionTimeoutTypeService, policyService); + } - onTimeoutSave(timeout: VaultTimeout): void { + override onTimeoutSave(timeout: VaultTimeout): void { if (timeout === VaultTimeoutStringType.Never) { this.messagingService.send("bgReseedStorage"); } diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts new file mode 100644 index 00000000000..83de5c51a4a --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.spec.ts @@ -0,0 +1,139 @@ +import { mock } from "jest-mock-extended"; + +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { BrowserSessionTimeoutTypeService } from "./browser-session-timeout-type.service"; + +describe("BrowserSessionTimeoutTypeService", () => { + let service: BrowserSessionTimeoutTypeService; + let mockPlatformUtilsService: jest.Mocked; + + beforeEach(() => { + mockPlatformUtilsService = mock(); + service = new BrowserSessionTimeoutTypeService(mockPlatformUtilsService); + }); + + describe("isAvailable", () => { + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return true for always available type: %s", async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }); + + it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])( + "should return true for numeric timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + describe("OnLocked availability", () => { + const mockNavigatorPlatform = (platform: string) => { + Object.defineProperty(navigator, "platform", { + value: platform, + writable: true, + configurable: true, + }); + }; + + beforeEach(() => { + mockNavigatorPlatform("Linux x86_64"); + mockPlatformUtilsService.isFirefox.mockReturnValue(false); + mockPlatformUtilsService.isSafari.mockReturnValue(false); + mockPlatformUtilsService.isOpera.mockReturnValue(false); + }); + + it("should return true when not Firefox, Safari, or Opera on Mac", async () => { + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + }); + + it("should return true when Opera on non-Mac platform", async () => { + mockNavigatorPlatform("Win32"); + mockPlatformUtilsService.isOpera.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + }); + + it("should return false when Opera on Mac", async () => { + mockNavigatorPlatform("MacIntel"); + mockPlatformUtilsService.isOpera.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + + it("should return false when Firefox", async () => { + mockPlatformUtilsService.isFirefox.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + + it("should return false when Safari", async () => { + mockPlatformUtilsService.isSafari.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + }); + }); + + it.each([VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnSleep])( + "should return false for unavailable timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(false); + }, + ); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Custom, + ])("should return the original type when it is available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(true); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(timeoutType); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + 5, + ])("should return OnRestart when type is not available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(VaultTimeoutStringType.OnRestart); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + }); +}); diff --git a/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts new file mode 100644 index 00000000000..33ac3e356d4 --- /dev/null +++ b/apps/browser/src/key-management/session-timeout/services/browser-session-timeout-type.service.ts @@ -0,0 +1,43 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +export class BrowserSessionTimeoutTypeService implements SessionTimeoutTypeService { + constructor(private readonly platformUtilsService: PlatformUtilsService) {} + + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Never: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.OnLocked: + return ( + !this.platformUtilsService.isFirefox() && + !this.platformUtilsService.isSafari() && + !(this.platformUtilsService.isOpera() && navigator.platform === "MacIntel") + ); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + return VaultTimeoutStringType.OnRestart; + } + return type; + } +} diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index c462319dc2e..0a82a07b722 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -76,6 +76,7 @@ import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutService, VaultTimeoutStringType, @@ -170,6 +171,7 @@ import { InlineMenuFieldQualificationService } from "../../autofill/services/inl import { ForegroundBrowserBiometricsService } from "../../key-management/biometrics/foreground-browser-biometrics"; import { ExtensionLockComponentService } from "../../key-management/lock/services/extension-lock-component.service"; import { BrowserSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/browser-session-timeout-settings-component.service"; +import { BrowserSessionTimeoutTypeService } from "../../key-management/session-timeout/services/browser-session-timeout-type.service"; import { ForegroundVaultTimeoutService } from "../../key-management/vault-timeout/foreground-vault-timeout.service"; import { BrowserActionsService } from "../../platform/actions/browser-actions.service"; import { BrowserApi } from "../../platform/browser/browser-api"; @@ -723,10 +725,20 @@ const safeProviders: SafeProvider[] = [ useClass: ExtensionNewDeviceVerificationComponentService, deps: [], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: BrowserSessionTimeoutTypeService, + deps: [PlatformUtilsService], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, useClass: BrowserSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction, PlatformUtilsService, MessagingServiceAbstraction], + deps: [ + I18nServiceAbstraction, + SessionTimeoutTypeService, + PolicyService, + MessagingServiceAbstraction, + ], }), ]; diff --git a/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts b/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts new file mode 100644 index 00000000000..8143b37b8a3 --- /dev/null +++ b/apps/cli/src/key-management/session-timeout/services/cli-session-timeout-type.service.ts @@ -0,0 +1,15 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeout, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +export class CliSessionTimeoutTypeService implements SessionTimeoutTypeService { + async isAvailable(timeout: VaultTimeout): Promise { + return timeout === VaultTimeoutStringType.Never; + } + + async getOrPromoteToAvailable(_: VaultTimeout): Promise { + return VaultTimeoutStringType.Never; + } +} diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index e29bc517f24..83c64c61423 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -211,6 +211,7 @@ import { import { CliBiometricsService } from "../key-management/cli-biometrics-service"; import { CliProcessReloadService } from "../key-management/cli-process-reload.service"; +import { CliSessionTimeoutTypeService } from "../key-management/session-timeout/services/cli-session-timeout-type.service"; import { flagEnabled } from "../platform/flags"; import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service"; import { CliSdkLoadService } from "../platform/services/cli-sdk-load.service"; @@ -529,6 +530,8 @@ export class ServiceContainer { this.accountService, ); + const sessionTimeoutTypeService = new CliSessionTimeoutTypeService(); + this.vaultTimeoutSettingsService = new DefaultVaultTimeoutSettingsService( this.accountService, pinStateService, @@ -540,6 +543,7 @@ export class ServiceContainer { this.stateProvider, this.logService, VaultTimeoutStringType.Never, // default vault timeout + sessionTimeoutTypeService, ); const refreshAccessTokenErrorCallback = () => { diff --git a/apps/desktop/src/app/accounts/settings.component.html b/apps/desktop/src/app/accounts/settings.component.html index 8abd84ee39c..d5042918d2f 100644 --- a/apps/desktop/src/app/accounts/settings.component.html +++ b/apps/desktop/src/app/accounts/settings.component.html @@ -44,12 +44,12 @@

{{ "vaultTimeoutHeader" | i18n }}

- - + {{ diff --git a/apps/desktop/src/app/accounts/settings.component.ts b/apps/desktop/src/app/accounts/settings.component.ts index 68863312ffe..e3022428421 100644 --- a/apps/desktop/src/app/accounts/settings.component.ts +++ b/apps/desktop/src/app/accounts/settings.component.ts @@ -55,7 +55,7 @@ import { } from "@bitwarden/components"; import { KeyService, BiometricStateService, BiometricsStatus } from "@bitwarden/key-management"; import { - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, SessionTimeoutSettingsComponent, } from "@bitwarden/key-management-ui"; import { PermitCipherDetailsPopoverComponent } from "@bitwarden/vault"; @@ -97,7 +97,7 @@ import { NativeMessagingManifestService } from "../services/native-messaging-man SectionHeaderComponent, SelectModule, TypographyModule, - SessionTimeoutInputComponent, + SessionTimeoutInputLegacyComponent, SessionTimeoutSettingsComponent, PermitCipherDetailsPopoverComponent, PremiumBadgeComponent, diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 03d6eb5c908..04f5e8026c2 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -62,6 +62,7 @@ import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypt import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { DefaultProcessReloadService } from "@bitwarden/common/key-management/services/default-process-reload.service"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeoutSettingsService, VaultTimeoutStringType, @@ -128,7 +129,7 @@ import { DesktopBiometricsService } from "../../key-management/biometrics/deskto import { RendererBiometricsService } from "../../key-management/biometrics/renderer-biometrics.service"; import { ElectronKeyService } from "../../key-management/electron-key.service"; import { DesktopLockComponentService } from "../../key-management/lock/services/desktop-lock-component.service"; -import { DesktopSessionTimeoutSettingsComponentService } from "../../key-management/session-timeout/services/desktop-session-timeout-settings-component.service"; +import { DesktopSessionTimeoutTypeService } from "../../key-management/session-timeout/services/desktop-session-timeout-type.service"; import { flagEnabled } from "../../platform/flags"; import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; import { ElectronLogRendererService } from "../../platform/services/electron-log.renderer.service"; @@ -484,10 +485,15 @@ const safeProviders: SafeProvider[] = [ useClass: DesktopAutotypeDefaultSettingPolicy, deps: [AccountServiceAbstraction, AuthServiceAbstraction, InternalPolicyService, ConfigService], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: DesktopSessionTimeoutTypeService, + deps: [], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, - useClass: DesktopSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction], + useClass: SessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyServiceAbstraction], }), ]; diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts deleted file mode 100644 index 91c8126cdd7..00000000000 --- a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-settings-component.service.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { defer, from, map, Observable } from "rxjs"; - -import { - VaultTimeout, - VaultTimeoutOption, - VaultTimeoutStringType, -} from "@bitwarden/common/key-management/vault-timeout"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; - -export class DesktopSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => - from(ipc.platform.powermonitor.isLockMonitorAvailable()).pipe( - map((isLockMonitorAvailable) => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - { name: this.i18nService.t("onIdle"), value: VaultTimeoutStringType.OnIdle }, - { name: this.i18nService.t("onSleep"), value: VaultTimeoutStringType.OnSleep }, - ]; - - if (isLockMonitorAvailable) { - options.push({ - name: this.i18nService.t("onLocked"), - value: VaultTimeoutStringType.OnLocked, - }); - } - - options.push( - { name: this.i18nService.t("onRestart"), value: VaultTimeoutStringType.OnRestart }, - { name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }, - ); - - return options; - }), - ), - ); - - constructor(private readonly i18nService: I18nService) {} - - onTimeoutSave(_: VaultTimeout): void {} -} diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts new file mode 100644 index 00000000000..d3ece8842b2 --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.spec.ts @@ -0,0 +1,125 @@ +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +import { DesktopSessionTimeoutTypeService } from "./desktop-session-timeout-type.service"; + +describe("DesktopSessionTimeoutTypeService", () => { + let service: DesktopSessionTimeoutTypeService; + let mockIsLockMonitorAvailable: jest.Mock; + + beforeEach(() => { + mockIsLockMonitorAvailable = jest.fn(); + + (global as any).ipc = { + platform: { + powermonitor: { + isLockMonitorAvailable: mockIsLockMonitorAvailable, + }, + }, + }; + + service = new DesktopSessionTimeoutTypeService(); + }); + + describe("isAvailable", () => { + it("should return false for Immediately", async () => { + const result = await service.isAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(false); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return true for always available type: %s", async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }); + + it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])( + "should return true for numeric timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + describe("OnLocked availability", () => { + it("should return true when lock monitor is available", async () => { + mockIsLockMonitorAvailable.mockResolvedValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(true); + expect(mockIsLockMonitorAvailable).toHaveBeenCalled(); + }); + + it("should return false when lock monitor is not available", async () => { + mockIsLockMonitorAvailable.mockResolvedValue(false); + + const result = await service.isAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(false); + expect(mockIsLockMonitorAvailable).toHaveBeenCalled(); + }); + }); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.OnMinute, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return the original type when it is available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(true); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(timeoutType); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + + it("should return OnMinute when Immediately is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(VaultTimeoutNumberType.OnMinute); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately); + }); + + it("should return OnSleep when OnLocked is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutStringType.OnLocked); + + expect(result).toBe(VaultTimeoutStringType.OnSleep); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutStringType.OnLocked); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutNumberType.OnMinute, + 5, + ])("should return OnRestart when type is not available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(VaultTimeoutStringType.OnRestart); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + }); +}); diff --git a/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts new file mode 100644 index 00000000000..1f09e83b0f1 --- /dev/null +++ b/apps/desktop/src/key-management/session-timeout/services/desktop-session-timeout-type.service.ts @@ -0,0 +1,46 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; + +export class DesktopSessionTimeoutTypeService implements SessionTimeoutTypeService { + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return false; + case VaultTimeoutStringType.OnIdle: + case VaultTimeoutStringType.OnSleep: + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Never: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.OnLocked: + return await ipc.platform.powermonitor.isLockMonitorAvailable(); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return VaultTimeoutNumberType.OnMinute; + case VaultTimeoutStringType.OnLocked: + return VaultTimeoutStringType.OnSleep; + default: + return VaultTimeoutStringType.OnRestart; + } + } + return type; + } +} diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 8da3ba54844..86df61940d1 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -4255,5 +4255,46 @@ }, "sessionTimeoutHeader": { "message": "Session timeout" + }, + "sessionTimeoutSettingsManagedByOrganization": { + "message": "This setting is managed by your organization." + }, + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes": { + "message": "Your organization has set the maximum session timeout to $HOURS$ hour(s) and $MINUTES$ minute(s).", + "placeholders": { + "hours": { + "content": "$1", + "example": "8" + }, + "minutes": { + "content": "$2", + "example": "2" + } + } + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked": { + "message": "Your organization has set the default session timeout to On system lock." + }, + "sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart": { + "message": "Your organization has set the default session timeout to On restart." + }, + "sessionTimeoutSettingsPolicyMaximumError": { + "message": "Maximum timeout cannot exceed $HOURS$ hour(s) and $MINUTES$ minute(s)", + "placeholders": { + "hours": { + "content": "$1", + "example": "5" + }, + "minutes": { + "content": "$2", + "example": "5" + } + } + }, + "sessionTimeoutOnRestart": { + "message": "On restart" + }, + "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { + "message": "Set an unlock method to change your timeout action" } } diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index c0716d99716..ab8f06dcf32 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -69,6 +69,7 @@ import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-managemen import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.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 { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { VaultTimeout, VaultTimeoutStringType, @@ -124,7 +125,6 @@ import { import { SerializedMemoryStorageService } from "@bitwarden/storage-core"; import { DefaultSshImportPromptService, SshImportPromptService } from "@bitwarden/vault"; import { WebOrganizationInviteService } from "@bitwarden/web-vault/app/auth/core/services/organization-invite/web-organization-invite.service"; -import { WebSessionTimeoutSettingsComponentService } from "@bitwarden/web-vault/app/key-management/session-timeout/services/web-session-timeout-settings-component.service"; import { WebVaultPremiumUpgradePromptService } from "@bitwarden/web-vault/app/vault/services/web-premium-upgrade-prompt.service"; import { flagEnabled } from "../../utils/flags"; @@ -149,6 +149,7 @@ import { WebFileDownloadService } from "../core/web-file-download.service"; import { UserKeyRotationService } from "../key-management/key-rotation/user-key-rotation.service"; import { WebLockComponentService } from "../key-management/lock/services/web-lock-component.service"; import { WebProcessReloadService } from "../key-management/services/web-process-reload.service"; +import { WebSessionTimeoutTypeService } from "../key-management/session-timeout/services/web-session-timeout-type.service"; import { WebBiometricsService } from "../key-management/web-biometric.service"; import { WebIpcService } from "../platform/ipc/web-ipc.service"; import { WebEnvironmentService } from "../platform/web-environment.service"; @@ -469,10 +470,15 @@ const safeProviders: SafeProvider[] = [ useClass: WebSystemService, deps: [], }), + safeProvider({ + provide: SessionTimeoutTypeService, + useClass: WebSessionTimeoutTypeService, + deps: [PlatformUtilsService], + }), safeProvider({ provide: SessionTimeoutSettingsComponentService, - useClass: WebSessionTimeoutSettingsComponentService, - deps: [I18nServiceAbstraction, PlatformUtilsService], + useClass: SessionTimeoutSettingsComponentService, + deps: [I18nServiceAbstraction, SessionTimeoutTypeService, PolicyService], }), ]; diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts deleted file mode 100644 index 61836c98252..00000000000 --- a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-settings-component.service.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { defer, Observable, of } from "rxjs"; - -import { - VaultTimeout, - VaultTimeoutOption, - VaultTimeoutStringType, -} from "@bitwarden/common/key-management/vault-timeout"; -import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; -import { SessionTimeoutSettingsComponentService } from "@bitwarden/key-management-ui"; - -export class WebSessionTimeoutSettingsComponentService - implements SessionTimeoutSettingsComponentService -{ - availableTimeoutOptions$: Observable = defer(() => { - const options: VaultTimeoutOption[] = [ - { name: this.i18nService.t("oneMinute"), value: 1 }, - { name: this.i18nService.t("fiveMinutes"), value: 5 }, - { name: this.i18nService.t("fifteenMinutes"), value: 15 }, - { name: this.i18nService.t("thirtyMinutes"), value: 30 }, - { name: this.i18nService.t("oneHour"), value: 60 }, - { name: this.i18nService.t("fourHours"), value: 240 }, - { name: this.i18nService.t("onRefresh"), value: VaultTimeoutStringType.OnRestart }, - ]; - - if (this.platformUtilsService.isDev()) { - options.push({ name: this.i18nService.t("never"), value: VaultTimeoutStringType.Never }); - } - - return of(options); - }); - - constructor( - private readonly i18nService: I18nService, - private readonly platformUtilsService: PlatformUtilsService, - ) {} - - onTimeoutSave(_: VaultTimeout): void {} -} diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts new file mode 100644 index 00000000000..40eb3e77d43 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.spec.ts @@ -0,0 +1,115 @@ +import { mock } from "jest-mock-extended"; + +import { + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +import { WebSessionTimeoutTypeService } from "./web-session-timeout-type.service"; + +describe("WebSessionTimeoutTypeService", () => { + let service: WebSessionTimeoutTypeService; + let mockPlatformUtilsService: jest.Mocked; + + beforeEach(() => { + mockPlatformUtilsService = mock(); + service = new WebSessionTimeoutTypeService(mockPlatformUtilsService); + }); + + describe("isAvailable", () => { + it("should return false for Immediately", async () => { + const result = await service.isAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(false); + }); + + it.each([VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.Custom])( + "should return true for always available type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + it.each([VaultTimeoutNumberType.OnMinute, VaultTimeoutNumberType.EightHours])( + "should return true for numeric timeout type: %s", + async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(true); + }, + ); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + ])("should return false for unavailable timeout type: %s", async (timeoutType) => { + const result = await service.isAvailable(timeoutType); + + expect(result).toBe(false); + }); + + describe("Never availability", () => { + it("should return true when in dev mode", async () => { + mockPlatformUtilsService.isDev.mockReturnValue(true); + + const result = await service.isAvailable(VaultTimeoutStringType.Never); + + expect(result).toBe(true); + expect(mockPlatformUtilsService.isDev).toHaveBeenCalled(); + }); + + it("should return false when not in dev mode", async () => { + mockPlatformUtilsService.isDev.mockReturnValue(false); + + const result = await service.isAvailable(VaultTimeoutStringType.Never); + + expect(result).toBe(false); + expect(mockPlatformUtilsService.isDev).toHaveBeenCalled(); + }); + }); + }); + + describe("getOrPromoteToAvailable", () => { + it.each([ + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.Custom, + ])("should return the original type when it is available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(true); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(timeoutType); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + + it("should return OnMinute when Immediately is not available", async () => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(VaultTimeoutNumberType.Immediately); + + expect(result).toBe(VaultTimeoutNumberType.OnMinute); + expect(service.isAvailable).toHaveBeenCalledWith(VaultTimeoutNumberType.Immediately); + }); + + it.each([ + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + ])("should return OnRestart when type is not available: %s", async (timeoutType) => { + jest.spyOn(service, "isAvailable").mockResolvedValue(false); + + const result = await service.getOrPromoteToAvailable(timeoutType); + + expect(result).toBe(VaultTimeoutStringType.OnRestart); + expect(service.isAvailable).toHaveBeenCalledWith(timeoutType); + }); + }); +}); diff --git a/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts new file mode 100644 index 00000000000..458befc29a7 --- /dev/null +++ b/apps/web/src/app/key-management/session-timeout/services/web-session-timeout-type.service.ts @@ -0,0 +1,44 @@ +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; + +export class WebSessionTimeoutTypeService implements SessionTimeoutTypeService { + constructor(private readonly platformUtilsService: PlatformUtilsService) {} + + async isAvailable(type: VaultTimeout): Promise { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return false; + case VaultTimeoutStringType.OnRestart: + case VaultTimeoutStringType.Custom: + return true; + case VaultTimeoutStringType.Never: + return this.platformUtilsService.isDev(); + default: + if (isVaultTimeoutTypeNumeric(type)) { + return true; + } + break; + } + + return false; + } + + async getOrPromoteToAvailable(type: VaultTimeout): Promise { + const available = await this.isAvailable(type); + if (!available) { + switch (type) { + case VaultTimeoutNumberType.Immediately: + return VaultTimeoutNumberType.OnMinute; + default: + return VaultTimeoutStringType.OnRestart; + } + } + return type; + } +} diff --git a/apps/web/src/app/settings/preferences.component.html b/apps/web/src/app/settings/preferences.component.html index a2e90dd5889..cdcb8973602 100644 --- a/apps/web/src/app/settings/preferences.component.html +++ b/apps/web/src/app/settings/preferences.component.html @@ -17,12 +17,12 @@ {{ "vaultTimeoutActionPolicyInEffect" | i18n: (policy.action | i18n) }} - - + (); - private lastConfirmedType$ = new BehaviorSubject(null); - actionOptions: { name: string; value: SessionTimeoutAction }[]; typeOptions: { name: string; value: SessionTimeoutType }[]; data = this.formBuilder.group({ @@ -74,6 +67,9 @@ export class SessionTimeoutPolicyComponent action: new FormControl(null), }); + private destroy$ = new Subject(); + private lastConfirmedType$ = new BehaviorSubject(null); + constructor( private formBuilder: FormBuilder, private i18nService: I18nService, @@ -123,12 +119,10 @@ export class SessionTimeoutPolicyComponent } protected override loadData() { - const minutes: number | null = this.policyResponse?.data?.minutes ?? null; - const action: SessionTimeoutAction = - this.policyResponse?.data?.action ?? (null satisfies SessionTimeoutAction); + const minutes: number | null = this.policyData?.minutes ?? null; + const action: SessionTimeoutAction = this.policyData?.action ?? null; // For backward compatibility, the "type" field might not exist, hence we initialize it based on the presence of "minutes" - const type: SessionTimeoutType = - this.policyResponse?.data?.type ?? ((minutes ? "custom" : null) satisfies SessionTimeoutType); + const type: SessionTimeoutType = this.policyData?.type ?? (minutes ? "custom" : null); this.updateFormControls(type); this.data.patchValue({ @@ -165,7 +159,11 @@ export class SessionTimeoutPolicyComponent type, minutes, action: this.data.value.action, - }; + } satisfies MaximumSessionTimeoutPolicyData; + } + + private get policyData(): MaximumSessionTimeoutPolicyData | null { + return this.policyResponse?.data ?? null; } private async confirmTypeChange(newType: SessionTimeoutType): Promise { diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 13935beab19..c2d4440b449 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -207,6 +207,7 @@ import { SendPasswordService, DefaultSendPasswordService, } from "@bitwarden/common/key-management/sends"; +import { SessionTimeoutTypeService } from "@bitwarden/common/key-management/session-timeout"; import { DefaultVaultTimeoutService, DefaultVaultTimeoutSettingsService, @@ -912,6 +913,7 @@ const safeProviders: SafeProvider[] = [ StateProvider, LogService, DEFAULT_VAULT_TIMEOUT, + SessionTimeoutTypeService, ], }), safeProvider({ diff --git a/libs/common/src/key-management/session-timeout/abstractions/session-timeout-type.service.ts b/libs/common/src/key-management/session-timeout/abstractions/session-timeout-type.service.ts new file mode 100644 index 00000000000..6ed230d9b78 --- /dev/null +++ b/libs/common/src/key-management/session-timeout/abstractions/session-timeout-type.service.ts @@ -0,0 +1,15 @@ +import { VaultTimeout } from "../../vault-timeout"; + +export abstract class SessionTimeoutTypeService { + /** + * Is provided timeout type available on this client type, OS ? + * @param timeout the timeout type + */ + abstract isAvailable(timeout: VaultTimeout): Promise; + + /** + * Returns the highest available and permissive timeout type, that is higher than or equals the provided timeout type. + * @param timeout the provided timeout type + */ + abstract getOrPromoteToAvailable(timeout: VaultTimeout): Promise; +} diff --git a/libs/common/src/key-management/session-timeout/index.ts b/libs/common/src/key-management/session-timeout/index.ts new file mode 100644 index 00000000000..4768534e7cb --- /dev/null +++ b/libs/common/src/key-management/session-timeout/index.ts @@ -0,0 +1,3 @@ +export { SessionTimeoutTypeService } from "./abstractions/session-timeout-type.service"; +export { MaximumSessionTimeoutPolicyData } from "./types/maximum-session-timeout-policy.type"; +export { SessionTimeoutAction, SessionTimeoutType } from "./types/session-timeout.type"; diff --git a/libs/common/src/key-management/session-timeout/types/maximum-session-timeout-policy.type.ts b/libs/common/src/key-management/session-timeout/types/maximum-session-timeout-policy.type.ts new file mode 100644 index 00000000000..de416be1518 --- /dev/null +++ b/libs/common/src/key-management/session-timeout/types/maximum-session-timeout-policy.type.ts @@ -0,0 +1,7 @@ +import { SessionTimeoutAction, SessionTimeoutType } from "./session-timeout.type"; + +export interface MaximumSessionTimeoutPolicyData { + type?: SessionTimeoutType; + minutes: number; + action?: SessionTimeoutAction; +} diff --git a/libs/common/src/key-management/session-timeout/types/session-timeout.type.ts b/libs/common/src/key-management/session-timeout/types/session-timeout.type.ts new file mode 100644 index 00000000000..3dd1a546aab --- /dev/null +++ b/libs/common/src/key-management/session-timeout/types/session-timeout.type.ts @@ -0,0 +1,8 @@ +export type SessionTimeoutAction = null | "lock" | "logOut"; +export type SessionTimeoutType = + | null + | "never" + | "onAppRestart" + | "onSystemLock" + | "immediately" + | "custom"; diff --git a/libs/common/src/key-management/vault-timeout/index.ts b/libs/common/src/key-management/vault-timeout/index.ts index 02e49dceaab..ba32c12c9fb 100644 --- a/libs/common/src/key-management/vault-timeout/index.ts +++ b/libs/common/src/key-management/vault-timeout/index.ts @@ -4,8 +4,9 @@ export { VaultTimeoutService } from "./abstractions/vault-timeout.service"; export { VaultTimeoutService as DefaultVaultTimeoutService } from "./services/vault-timeout.service"; export { VaultTimeoutAction } from "./enums/vault-timeout-action.enum"; export { + isVaultTimeoutTypeNumeric, VaultTimeout, VaultTimeoutOption, + VaultTimeoutNumberType, VaultTimeoutStringType, } from "./types/vault-timeout.type"; -export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type"; diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts index ba58fa80922..ccb66a4dff4 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.spec.ts @@ -21,9 +21,14 @@ import { LogService } from "../../../platform/abstractions/log.service"; import { Utils } from "../../../platform/misc/utils"; import { UserId } from "../../../types/guid"; import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction"; +import { SessionTimeoutTypeService } from "../../session-timeout"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service"; import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; -import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type"; +import { + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "../types/vault-timeout.type"; import { VaultTimeoutSettingsService } from "./vault-timeout-settings.service"; import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state"; @@ -40,9 +45,11 @@ describe("VaultTimeoutSettingsService", () => { let userDecryptionOptionsSubject: BehaviorSubject; + const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout const mockUserId = Utils.newGuid() as UserId; let stateProvider: FakeStateProvider; let logService: MockProxy; + let sessionTimeoutTypeService: MockProxy; beforeEach(() => { accountService = mockAccountServiceWith(mockUserId); @@ -67,8 +74,8 @@ describe("VaultTimeoutSettingsService", () => { stateProvider = new FakeStateProvider(accountService); logService = mock(); + sessionTimeoutTypeService = mock(); - const defaultVaultTimeout: VaultTimeout = 15; // default web vault timeout vaultTimeoutSettingsService = createVaultTimeoutSettingsService(defaultVaultTimeout); biometricStateService.biometricUnlockEnabled$ = of(false); @@ -259,40 +266,276 @@ describe("VaultTimeoutSettingsService", () => { ); }); - it.each([ - // policy, vaultTimeout, expected - [null, null, 15], // no policy, no vault timeout, falls back to default - [30, 90, 30], // policy overrides vault timeout - [30, 15, 15], // policy doesn't override vault timeout when it's within acceptable range - [90, VaultTimeoutStringType.Never, 90], // policy overrides vault timeout when it's "never" - [null, VaultTimeoutStringType.Never, VaultTimeoutStringType.Never], // no policy, persist "never" vault timeout - [90, 0, 0], // policy doesn't override vault timeout when it's 0 (immediate) - [null, 0, 0], // no policy, persist 0 (immediate) vault timeout - [90, VaultTimeoutStringType.OnRestart, 90], // policy overrides vault timeout when it's "onRestart" - [null, VaultTimeoutStringType.OnRestart, VaultTimeoutStringType.OnRestart], // no policy, persist "onRestart" vault timeout - [90, VaultTimeoutStringType.OnLocked, 90], // policy overrides vault timeout when it's "onLocked" - [null, VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnLocked], // no policy, persist "onLocked" vault timeout - [90, VaultTimeoutStringType.OnSleep, 90], // policy overrides vault timeout when it's "onSleep" - [null, VaultTimeoutStringType.OnSleep, VaultTimeoutStringType.OnSleep], // no policy, persist "onSleep" vault timeout - [90, VaultTimeoutStringType.OnIdle, 90], // policy overrides vault timeout when it's "onIdle" - [null, VaultTimeoutStringType.OnIdle, VaultTimeoutStringType.OnIdle], // no policy, persist "onIdle" vault timeout - ])( - "when policy is %s, and vault timeout is %s, returns %s", - async (policy, vaultTimeout, expected) => { + describe("no policy", () => { + it("when vault timeout is null, returns default", async () => { userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); - policyService.policiesByType$.mockReturnValue( - of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])), + policyService.policiesByType$.mockReturnValue(of([])); + + await stateProvider.setUserState(VAULT_TIMEOUT, null, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); + expect(result).toBe(defaultVaultTimeout); + }); + + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnIdle, + ])("when vault timeout is %s, returns unchanged", async (vaultTimeout) => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue(of([])); + await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId); const result = await firstValueFrom( vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), ); - expect(result).toBe(expected); - }, - ); + expect(result).toBe(vaultTimeout); + }); + }); + + describe("policy type: custom", () => { + const policyMinutes = 30; + + it.each([ + VaultTimeoutNumberType.EightHours, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnIdle, + ])( + "when vault timeout is %s and exceeds policy max, returns policy minutes", + async (vaultTimeout) => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(result).toBe(policyMinutes); + }, + ); + + it.each([VaultTimeoutNumberType.OnMinute, policyMinutes])( + "when vault timeout is %s and within policy max, returns unchanged", + async (vaultTimeout) => { + userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true })); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "custom", minutes: policyMinutes } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(result).toBe(vaultTimeout); + }, + ); + + it("when vault timeout is Immediately, returns Immediately", async () => { + 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.Immediately, + mockUserId, + ); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(result).toBe(VaultTimeoutNumberType.Immediately); + }); + }); + + describe("policy type: immediately", () => { + it.each([ + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + ])( + "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[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutNumberType.Immediately, + ); + expect(result).toBe(expectedTimeout); + }, + ); + }); + + describe("policy type: onSystemLock", () => { + it.each([ + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + ])( + "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[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.OnLocked, + ); + expect(result).toBe(expectedTimeout); + }, + ); + + it.each([ + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + ])("when current timeout is numeric %s, returns unchanged", async (currentTimeout) => { + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onSystemLock" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(result).toBe(currentTimeout); + }); + }); + + describe("policy type: onAppRestart", () => { + it.each([ + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + ])("when current timeout is %s, returns onRestart", async (currentTimeout) => { + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(result).toBe(VaultTimeoutStringType.OnRestart); + }); + + it.each([ + VaultTimeoutStringType.OnRestart, + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + ])("when current timeout is %s, returns unchanged", async (currentTimeout) => { + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "onAppRestart" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + 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); + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "never" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, VaultTimeoutStringType.Never, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).toHaveBeenCalledWith( + VaultTimeoutStringType.Never, + ); + expect(result).toBe(expectedTimeout); + }); + + it.each([ + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.OnIdle, + VaultTimeoutStringType.OnSleep, + VaultTimeoutNumberType.Immediately, + VaultTimeoutNumberType.OnMinute, + VaultTimeoutNumberType.EightHours, + ])("when current timeout is %s, returns unchanged", async (currentTimeout) => { + policyService.policiesByType$.mockReturnValue( + of([{ data: { type: "never" } }] as unknown as Policy[]), + ); + + await stateProvider.setUserState(VAULT_TIMEOUT, currentTimeout, mockUserId); + + const result = await firstValueFrom( + vaultTimeoutSettingsService.getVaultTimeoutByUserId$(mockUserId), + ); + + expect(sessionTimeoutTypeService.getOrPromoteToAvailable).not.toHaveBeenCalled(); + expect(result).toBe(currentTimeout); + }); + }); }); describe("setVaultTimeoutOptions", () => { @@ -405,6 +648,7 @@ describe("VaultTimeoutSettingsService", () => { stateProvider, logService, defaultVaultTimeout, + sessionTimeoutTypeService, ); } }); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts index 00e53596de4..dc0c5620518 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.service.ts @@ -1,14 +1,15 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { - EMPTY, - Observable, catchError, combineLatest, defer, distinctUntilChanged, + EMPTY, firstValueFrom, from, + map, + Observable, shareReplay, switchMap, tap, @@ -23,7 +24,6 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management"; import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "../../../admin-console/enums"; -import { Policy } from "../../../admin-console/models/domain/policy"; import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service"; import { AccountService } from "../../../auth/abstractions/account.service"; import { TokenService } from "../../../auth/abstractions/token.service"; @@ -31,9 +31,15 @@ import { LogService } from "../../../platform/abstractions/log.service"; import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { PinStateServiceAbstraction } from "../../pin/pin-state.service.abstraction"; +import { MaximumSessionTimeoutPolicyData, SessionTimeoutTypeService } from "../../session-timeout"; import { VaultTimeoutSettingsService as VaultTimeoutSettingsServiceAbstraction } from "../abstractions/vault-timeout-settings.service"; import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; -import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "../types/vault-timeout.type"; import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state"; @@ -49,6 +55,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private stateProvider: StateProvider, private logService: LogService, private defaultVaultTimeout: VaultTimeout, + private sessionTimeoutTypeService: SessionTimeoutTypeService, ) {} async setVaultTimeoutOptions( @@ -131,11 +138,25 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return combineLatest([ this.stateProvider.getUserState$(VAULT_TIMEOUT, userId), - this.getMaxVaultTimeoutPolicyByUserId$(userId), + this.getMaxSessionTimeoutPolicyDataByUserId$(userId), ]).pipe( - switchMap(([currentVaultTimeout, maxVaultTimeoutPolicy]) => { - return from(this.determineVaultTimeout(currentVaultTimeout, maxVaultTimeoutPolicy)).pipe( + switchMap(([currentVaultTimeout, maxSessionTimeoutPolicyData]) => { + this.logService.debug( + "[VaultTimeoutSettingsService] Current vault timeout is %o for user id %s, max session policy %o", + currentVaultTimeout, + userId, + maxSessionTimeoutPolicyData, + ); + return from( + this.determineVaultTimeout(currentVaultTimeout, maxSessionTimeoutPolicyData), + ).pipe( tap((vaultTimeout: VaultTimeout) => { + this.logService.debug( + "[VaultTimeoutSettingsService] Determined vault timeout is %o for user id %s", + vaultTimeout, + userId, + ); + // As a side effect, set the new value determined by determineVaultTimeout into state if it's different from the current if (vaultTimeout !== currentVaultTimeout) { return this.stateProvider.setUserState(VAULT_TIMEOUT, vaultTimeout, userId); @@ -155,28 +176,63 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private async determineVaultTimeout( currentVaultTimeout: VaultTimeout | null, - maxVaultTimeoutPolicy: Policy | null, + maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, ): Promise { // if current vault timeout is null, apply the client specific default currentVaultTimeout = currentVaultTimeout ?? this.defaultVaultTimeout; // If no policy applies, return the current vault timeout - if (!maxVaultTimeoutPolicy) { + if (maxSessionTimeoutPolicyData == null) { return currentVaultTimeout; } - // User is subject to a max vault timeout policy - const maxVaultTimeoutPolicyData = maxVaultTimeoutPolicy.data; - - // If the current vault timeout is not numeric, change it to the policy compliant value - if (typeof currentVaultTimeout === "string") { - return maxVaultTimeoutPolicyData.minutes; + switch (maxSessionTimeoutPolicyData.type) { + case "immediately": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutNumberType.Immediately, + ); + case "custom": + case null: + case undefined: + if (currentVaultTimeout === VaultTimeoutNumberType.Immediately) { + return currentVaultTimeout; + } + if (isVaultTimeoutTypeNumeric(currentVaultTimeout)) { + return Math.min(currentVaultTimeout as number, maxSessionTimeoutPolicyData.minutes); + } + return maxSessionTimeoutPolicyData.minutes; + case "onSystemLock": + if ( + currentVaultTimeout === VaultTimeoutStringType.Never || + currentVaultTimeout === VaultTimeoutStringType.OnRestart || + currentVaultTimeout === VaultTimeoutStringType.OnLocked || + currentVaultTimeout === VaultTimeoutStringType.OnIdle || + currentVaultTimeout === VaultTimeoutStringType.OnSleep + ) { + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.OnLocked, + ); + } + break; + case "onAppRestart": + if ( + currentVaultTimeout === VaultTimeoutStringType.Never || + currentVaultTimeout === VaultTimeoutStringType.OnLocked || + currentVaultTimeout === VaultTimeoutStringType.OnIdle || + currentVaultTimeout === VaultTimeoutStringType.OnSleep + ) { + return VaultTimeoutStringType.OnRestart; + } + break; + case "never": + if (currentVaultTimeout === VaultTimeoutStringType.Never) { + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.Never, + ); + } + break; } - - // For numeric vault timeouts, ensure they are smaller than maximum allowed value according to policy - const policyCompliantTimeout = Math.min(currentVaultTimeout, maxVaultTimeoutPolicyData.minutes); - - return policyCompliantTimeout; + return currentVaultTimeout; } private async setVaultTimeoutAction(userId: UserId, action: VaultTimeoutAction): Promise { @@ -198,14 +254,14 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return combineLatest([ this.stateProvider.getUserState$(VAULT_TIMEOUT_ACTION, userId), - this.getMaxVaultTimeoutPolicyByUserId$(userId), + this.getMaxSessionTimeoutPolicyDataByUserId$(userId), ]).pipe( - switchMap(([currentVaultTimeoutAction, maxVaultTimeoutPolicy]) => { + switchMap(([currentVaultTimeoutAction, maxSessionTimeoutPolicyData]) => { return from( this.determineVaultTimeoutAction( userId, currentVaultTimeoutAction, - maxVaultTimeoutPolicy, + maxSessionTimeoutPolicyData, ), ).pipe( tap((vaultTimeoutAction: VaultTimeoutAction) => { @@ -235,7 +291,7 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA private async determineVaultTimeoutAction( userId: string, currentVaultTimeoutAction: VaultTimeoutAction | null, - maxVaultTimeoutPolicy: Policy | null, + maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null, ): Promise { const availableVaultTimeoutActions = await this.getAvailableVaultTimeoutActions(userId); if (availableVaultTimeoutActions.length === 1) { @@ -243,11 +299,13 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA } if ( - maxVaultTimeoutPolicy?.data?.action && - availableVaultTimeoutActions.includes(maxVaultTimeoutPolicy.data.action) + maxSessionTimeoutPolicyData?.action && + availableVaultTimeoutActions.includes( + maxSessionTimeoutPolicyData.action as VaultTimeoutAction, + ) ) { - // return policy defined vault timeout action - return maxVaultTimeoutPolicy.data.action; + // return policy defined session timeout action + return maxSessionTimeoutPolicyData.action as VaultTimeoutAction; } // No policy applies from here on @@ -262,14 +320,17 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA return currentVaultTimeoutAction; } - private getMaxVaultTimeoutPolicyByUserId$(userId: UserId): Observable { + private getMaxSessionTimeoutPolicyDataByUserId$( + userId: UserId, + ): Observable { if (!userId) { - throw new Error("User id required. Cannot get max vault timeout policy."); + throw new Error("User id required. Cannot get max session timeout policy."); } - return this.policyService - .policiesByType$(PolicyType.MaximumVaultTimeout, userId) - .pipe(getFirstPolicy); + return this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId).pipe( + getFirstPolicy, + map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null), + ); } private async getAvailableVaultTimeoutActions(userId?: string): Promise { diff --git a/libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts b/libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts deleted file mode 100644 index c254e823fee..00000000000 --- a/libs/common/src/key-management/vault-timeout/types/maximum-vault-timeout-policy.type.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; - -export interface MaximumVaultTimeoutPolicyData { - minutes: number; - action?: VaultTimeoutAction; -} diff --git a/libs/common/src/key-management/vault-timeout/types/vault-timeout.type.ts b/libs/common/src/key-management/vault-timeout/types/vault-timeout.type.ts index e5a2e7f1820..a8757f6100e 100644 --- a/libs/common/src/key-management/vault-timeout/types/vault-timeout.type.ts +++ b/libs/common/src/key-management/vault-timeout/types/vault-timeout.type.ts @@ -5,13 +5,25 @@ export const VaultTimeoutStringType = { OnLocked: "onLocked", // -2 OnSleep: "onSleep", // -3 OnIdle: "onIdle", // -4 + Custom: "custom", // -100 +} as const; + +export const VaultTimeoutNumberType = { + Immediately: 0, + OnMinute: 1, + EightHours: 480, } as const; export type VaultTimeout = - | number // 0 or positive numbers only + | (typeof VaultTimeoutNumberType)[keyof typeof VaultTimeoutNumberType] + | number // 0 or positive numbers (in minutes). See VaultTimeoutNumberType for common numeric presets | (typeof VaultTimeoutStringType)[keyof typeof VaultTimeoutStringType]; export interface VaultTimeoutOption { name: string; value: VaultTimeout; } + +export function isVaultTimeoutTypeNumeric(timeout: VaultTimeout): boolean { + return typeof timeout === "number"; +} diff --git a/libs/key-management-ui/src/index.ts b/libs/key-management-ui/src/index.ts index d681d469123..b273b49cb73 100644 --- a/libs/key-management-ui/src/index.ts +++ b/libs/key-management-ui/src/index.ts @@ -12,3 +12,4 @@ export { ConfirmKeyConnectorDomainComponent } from "./key-connector/confirm-key- export { SessionTimeoutSettingsComponent } from "./session-timeout/components/session-timeout-settings.component"; export { SessionTimeoutSettingsComponentService } from "./session-timeout/services/session-timeout-settings-component.service"; export { SessionTimeoutInputComponent } from "./session-timeout/components/session-timeout-input.component"; +export { SessionTimeoutInputLegacyComponent } from "./session-timeout/components/session-timeout-input-legacy.component"; diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.html b/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.html new file mode 100644 index 00000000000..4f1b27a8127 --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.html @@ -0,0 +1,47 @@ +
+ + {{ "vaultTimeout1" | i18n }} + + + + +
+ + + {{ "hours" | i18n }} + + + + {{ "minutes" | i18n }} + +
+ + {{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }} + + + {{ "vaultCustomTimeoutMinimum" | i18n }} + + + + {{ + "vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes + }} + +
diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.ts new file mode 100644 index 00000000000..22a53f6a53e --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input-legacy.component.ts @@ -0,0 +1,296 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { CommonModule } from "@angular/common"; +import { Component, Input, OnChanges, OnDestroy, OnInit } from "@angular/core"; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + ValidationErrors, + Validator, +} from "@angular/forms"; +import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { + VaultTimeout, + VaultTimeoutAction, + VaultTimeoutOption, + VaultTimeoutSettingsService, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { FormFieldModule, SelectModule } from "@bitwarden/components"; + +type VaultTimeoutForm = FormGroup<{ + vaultTimeout: FormControl; + custom: FormGroup<{ + hours: FormControl; + minutes: FormControl; + }>; +}>; + +type VaultTimeoutFormValue = VaultTimeoutForm["value"]; + +/** + * @deprecated Use {@link SessionTimeoutInputComponent} instead. + * + * TODO Cleanup once feature flag enabled: https://bitwarden.atlassian.net/browse/PM-27297 + */ +// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush +// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection +@Component({ + selector: "bit-session-timeout-input-legacy", + templateUrl: "session-timeout-input-legacy.component.html", + imports: [CommonModule, JslibModule, ReactiveFormsModule, FormFieldModule, SelectModule], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: SessionTimeoutInputLegacyComponent, + }, + { + provide: NG_VALIDATORS, + multi: true, + useExisting: SessionTimeoutInputLegacyComponent, + }, + ], +}) +export class SessionTimeoutInputLegacyComponent + implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges +{ + static CUSTOM_VALUE = -100; + static MIN_CUSTOM_MINUTES = 0; + form: VaultTimeoutForm = this.formBuilder.group({ + vaultTimeout: [null], + custom: this.formBuilder.group({ + hours: [null], + minutes: [null], + }), + }); + + // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals + // eslint-disable-next-line @angular-eslint/prefer-signals + @Input() vaultTimeoutOptions: VaultTimeoutOption[]; + + vaultTimeoutPolicy: Policy; + vaultTimeoutPolicyHours: number; + vaultTimeoutPolicyMinutes: number; + + protected readonly VaultTimeoutAction = VaultTimeoutAction; + + protected canLockVault$: Observable; + private onChange: (vaultTimeout: VaultTimeout) => void; + private validatorChange: () => void; + private destroy$ = new Subject(); + + constructor( + private formBuilder: FormBuilder, + private policyService: PolicyService, + private vaultTimeoutSettingsService: VaultTimeoutSettingsService, + private i18nService: I18nService, + private accountService: AccountService, + ) {} + + get showCustom() { + return this.form.get("vaultTimeout").value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE; + } + + get exceedsMinimumTimeout(): boolean { + return ( + !this.showCustom || + this.customTimeInMinutes() > SessionTimeoutInputLegacyComponent.MIN_CUSTOM_MINUTES + ); + } + + get exceedsMaximumTimeout(): boolean { + return ( + this.showCustom && + this.customTimeInMinutes() > + this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours + ); + } + + get filteredVaultTimeoutOptions(): VaultTimeoutOption[] { + // by policy max value + if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) { + return this.vaultTimeoutOptions; + } + + return this.vaultTimeoutOptions.filter((option) => { + if (typeof option.value === "number") { + return option.value <= this.vaultTimeoutPolicy.data.minutes; + } + + return false; + }); + } + + async ngOnInit() { + this.accountService.activeAccount$ + .pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, + filter((policy) => policy != null), + takeUntil(this.destroy$), + ) + .subscribe((policy) => { + this.vaultTimeoutPolicy = policy; + this.applyVaultTimeoutPolicy(); + }); + this.form.valueChanges + .pipe(takeUntil(this.destroy$)) + .subscribe((value: VaultTimeoutFormValue) => { + if (this.onChange) { + this.onChange(this.getVaultTimeout(value)); + } + }); + + // Assign the current value to the custom fields + // so that if the user goes from a numeric value to custom + // we can initialize the custom fields with the current value + // ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields + this.form.controls.vaultTimeout.valueChanges + .pipe( + filter((value) => value !== SessionTimeoutInputLegacyComponent.CUSTOM_VALUE), + takeUntil(this.destroy$), + ) + .subscribe((value) => { + const current = typeof value === "string" ? 0 : Math.max(value, 0); + + // This cannot emit an event b/c it would cause form.valueChanges to fire again + // and we are already handling that above so just silently update + // custom fields when vaultTimeout changes to a non-custom value + this.form.patchValue( + { + custom: { + hours: Math.floor(current / 60), + minutes: current % 60, + }, + }, + { emitEvent: false }, + ); + }); + + this.canLockVault$ = this.vaultTimeoutSettingsService + .availableVaultTimeoutActions$() + .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngOnChanges() { + if ( + !this.vaultTimeoutOptions.find( + (p) => p.value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE, + ) + ) { + this.vaultTimeoutOptions.push({ + name: this.i18nService.t("custom"), + value: SessionTimeoutInputLegacyComponent.CUSTOM_VALUE, + }); + } + } + + getVaultTimeout(value: VaultTimeoutFormValue) { + if (value.vaultTimeout !== SessionTimeoutInputLegacyComponent.CUSTOM_VALUE) { + return value.vaultTimeout; + } + + return value.custom.hours * 60 + value.custom.minutes; + } + + writeValue(value: number): void { + if (value == null) { + return; + } + + if (this.vaultTimeoutOptions.every((p) => p.value !== value)) { + this.form.setValue({ + vaultTimeout: SessionTimeoutInputLegacyComponent.CUSTOM_VALUE, + custom: { + hours: Math.floor(value / 60), + minutes: value % 60, + }, + }); + return; + } + + this.form.patchValue({ + vaultTimeout: value, + }); + } + + registerOnChange(onChange: any): void { + this.onChange = onChange; + } + + registerOnTouched(onTouched: any): void { + // Empty + } + + setDisabledState?(isDisabled: boolean): void { + // Empty + } + + validate(control: AbstractControl): ValidationErrors { + if (this.vaultTimeoutPolicy && this.vaultTimeoutPolicy?.data?.minutes < control.value) { + return { policyError: true }; + } + + if (!this.exceedsMinimumTimeout) { + return { minTimeoutError: true }; + } + + return null; + } + + registerOnValidatorChange(fn: () => void): void { + this.validatorChange = fn; + } + + private customTimeInMinutes() { + return this.form.value.custom.hours * 60 + this.form.value.custom.minutes; + } + + private applyVaultTimeoutPolicy() { + this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60); + this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60; + + this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => { + // Always include the custom option + if (vaultTimeoutOption.value === SessionTimeoutInputLegacyComponent.CUSTOM_VALUE) { + return true; + } + + if (typeof vaultTimeoutOption.value === "number") { + // Include numeric values that are less than or equal to the policy minutes + return vaultTimeoutOption.value <= this.vaultTimeoutPolicy.data.minutes; + } + + // Exclude all string cases when there's a numeric policy defined + return false; + }); + + // Only call validator change if it's been set + if (this.validatorChange) { + this.validatorChange(); + } + } +} diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.html b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.html index 4f1b27a8127..9a8de253d5f 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.html +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.html @@ -1,47 +1,51 @@
- + {{ "vaultTimeout1" | i18n }} - + @for (option of availableTimeoutOptions(); track option.value) { + + } -
- - - {{ "hours" | i18n }} - - - - {{ "minutes" | i18n }} - -
- - {{ "vaultTimeoutPolicyInEffect1" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes }} - - - {{ "vaultCustomTimeoutMinimum" | i18n }} - - - - {{ - "vaultTimeoutPolicyMaximumError" | i18n: vaultTimeoutPolicyHours : vaultTimeoutPolicyMinutes - }} - + @if (isCustomTimeoutType) { +
+ + + {{ "hours" | i18n }} + + + + {{ "minutes" | i18n }} + +
+ } + @if (form.hasError("maxTimeoutError")) { +
+ + {{ + "sessionTimeoutSettingsPolicyMaximumError" + | i18n: maxSessionTimeoutPolicyHours : maxSessionTimeoutPolicyMinutes + }} +
+ } @else if (maxSessionTimeoutPolicyData != null) { + @let policyTimeoutMessage = policyTimeoutMessage$ | async; + @if (policyTimeoutMessage != null) { + + {{ policyTimeoutMessage }} + + } + }
diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.spec.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.spec.ts index ca9b1230beb..cbe532de5b2 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.spec.ts +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.spec.ts @@ -1,90 +1,819 @@ -import { ComponentFixture, TestBed } from "@angular/core/testing"; -import { BehaviorSubject } from "rxjs"; +import { ComponentFixture, fakeAsync, flush, TestBed } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { - VaultTimeoutSettingsService, + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeout, + VaultTimeoutOption, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; -import { Utils } from "@bitwarden/common/platform/misc/utils"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; +import { LogService } from "@bitwarden/logging"; + +import { SessionTimeoutSettingsComponentService } from "../services/session-timeout-settings-component.service"; import { SessionTimeoutInputComponent } from "./session-timeout-input.component"; describe("SessionTimeoutInputComponent", () => { let component: SessionTimeoutInputComponent; let fixture: ComponentFixture; - const policiesByType$ = jest.fn().mockReturnValue(new BehaviorSubject({})); - const availableVaultTimeoutActions$ = jest.fn().mockReturnValue(new BehaviorSubject([])); - const mockUserId = Utils.newGuid() as UserId; - const accountService = mockAccountServiceWith(mockUserId); + + // Test constants + const MOCK_USER_ID = "user-id" as UserId; + const ONE_MINUTE = 1; + const FIVE_MINUTES = 5; + const FIFTEEN_MINUTES = 15; + const THIRTY_MINUTES = 30; + const ONE_HOUR = 60; + const FOUR_HOURS = 240; + const NINETY_MINUTES = 90; + + // Mock services + let mockPolicyService: MockProxy; + let mockSessionTimeoutSettingsComponentService: MockProxy; + let mockSessionTimeoutTypeService: MockProxy; + let mockI18nService: MockProxy; + let mockLogService: MockProxy; + let accountService: AccountService; + + // BehaviorSubjects for reactive testing + let policies$: BehaviorSubject; + let availableTimeoutOptions: VaultTimeoutOption[]; beforeEach(async () => { + // Initialize BehaviorSubjects + policies$ = new BehaviorSubject([]); + + // Initialize available timeout options + availableTimeoutOptions = [ + { name: "oneMinute-used-i18n", value: ONE_MINUTE }, + { name: "fiveMinutes-used-i18n", value: FIVE_MINUTES }, + { name: "fifteenMinutes-used-i18n", value: FIFTEEN_MINUTES }, + { name: "thirtyMinutes-used-i18n", value: THIRTY_MINUTES }, + { name: "oneHour-used-i18n", value: ONE_HOUR }, + { name: "fourHours-used-i18n", value: FOUR_HOURS }, + { name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart }, + { name: "onLocked-used-i18n", value: VaultTimeoutStringType.OnLocked }, + { name: "never-used-i18n", value: VaultTimeoutStringType.Never }, + ]; + + // Initialize mocks + mockPolicyService = mock(); + mockPolicyService.policiesByType$.mockReturnValue(policies$.asObservable()); + + accountService = mockAccountServiceWith(MOCK_USER_ID); + + mockI18nService = mock(); + mockI18nService.t.mockImplementation((key, ...args) => { + if (args.length > 0) { + return `${key}-used-i18n-${args.join("-")}`; + } + return `${key}-used-i18n`; + }); + + mockLogService = mock(); + + mockSessionTimeoutSettingsComponentService = mock(); + mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockReturnValue( + of(availableTimeoutOptions), + ); + + mockSessionTimeoutTypeService = mock(); + mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true); + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockImplementation( + async (timeout: VaultTimeout) => timeout, + ); + await TestBed.configureTestingModule({ imports: [SessionTimeoutInputComponent], providers: [ - { provide: PolicyService, useValue: { policiesByType$ } }, + { provide: PolicyService, useValue: mockPolicyService }, { provide: AccountService, useValue: accountService }, - { provide: VaultTimeoutSettingsService, useValue: { availableVaultTimeoutActions$ } }, - { provide: I18nService, useValue: { t: (key: string) => key } }, + { provide: I18nService, useValue: mockI18nService }, + { provide: LogService, useValue: mockLogService }, + { + provide: SessionTimeoutSettingsComponentService, + useValue: mockSessionTimeoutSettingsComponentService, + }, + { provide: SessionTimeoutTypeService, useValue: mockSessionTimeoutTypeService }, ], }).compileComponents(); fixture = TestBed.createComponent(SessionTimeoutInputComponent); component = fixture.componentInstance; - component.vaultTimeoutOptions = [ - { name: "oneMinute", value: 1 }, - { name: "fiveMinutes", value: 5 }, - { name: "fifteenMinutes", value: 15 }, - { name: "thirtyMinutes", value: 30 }, - { name: "oneHour", value: 60 }, - { name: "fourHours", value: 240 }, - { name: "onRefresh", value: VaultTimeoutStringType.OnRestart }, - ]; - fixture.detectChanges(); + fixture.componentRef.setInput("availableTimeoutOptions", availableTimeoutOptions); }); - describe("form", () => { - beforeEach(async () => { - await component.ngOnInit(); + it("should create", () => { + expect(component).toBeTruthy(); + }); + + describe("ngOnInit", () => { + describe("policy data subscription and initialization", () => { + it("should initialize maxSessionTimeoutPolicyData to null when no policy exists", fakeAsync(() => { + fixture.detectChanges(); + flush(); + + expect(component["maxSessionTimeoutPolicyData"]).toBeNull(); + })); + + it("should set maxSessionTimeoutPolicyData when policy exists", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: NINETY_MINUTES, + }; + + fixture.detectChanges(); + flush(); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component["maxSessionTimeoutPolicyData"]).toEqual(policyData); + })); + + it("should trigger validatorChange callback when policy data changes", fakeAsync(() => { + const validatorChangeFn = jest.fn(); + component.registerOnValidatorChange(validatorChangeFn); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: NINETY_MINUTES, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(validatorChangeFn).toHaveBeenCalled(); + })); + + it("should update form validation when policy data changes", fakeAsync(() => { + const updateSpy = jest.spyOn(component.form.controls.custom, "updateValueAndValidity"); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: FIFTEEN_MINUTES, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(updateSpy).toHaveBeenCalled(); + })); }); - it("invokes the onChange associated with `ControlValueAccessor`", () => { - const onChange = jest.fn(); - component.registerOnChange(onChange); + describe("policyTimeoutMessage$ observable", () => { + it("should emit custom timeout message when policy has custom type", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: NINETY_MINUTES, + }; - component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnRestart); + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); - expect(onChange).toHaveBeenCalledWith(VaultTimeoutStringType.OnRestart); + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe( + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes-used-i18n-1-30", + ); + })); + + it("should emit immediately message when policy has immediately type", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "immediately", + minutes: 0, + }; + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe( + "sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately-used-i18n", + ); + })); + + it("should emit onLocked message when policy has onSystemLock type", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onSystemLock", + minutes: 0, + }; + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked-used-i18n"); + })); + + it("should emit onRestart message when policy has onAppRestart type", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onAppRestart", + minutes: 0, + }; + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n"); + })); + + it("should emit null when policy has never type and promoted value is Never", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "never", + minutes: 0, + }; + + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + VaultTimeoutStringType.Never, + ); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = "initial"; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBeNull(); + })); + + it("should emit numeric timeout message when immediately is promoted to 1 minute", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "immediately", + minutes: 0, + }; + + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue(ONE_MINUTE); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe( + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes-used-i18n-0-1", + ); + })); + + it("should emit onRestart message when onSystemLock is promoted to OnRestart", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onSystemLock", + minutes: 0, + }; + + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + VaultTimeoutStringType.OnRestart, + ); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n"); + })); + + it("should emit onRestart message when never is promoted to OnRestart", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "never", + minutes: 0, + }; + + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + VaultTimeoutStringType.OnRestart, + ); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + + fixture.detectChanges(); + flush(); + + let message: string | null = null; + component["policyTimeoutMessage$"].subscribe((msg) => (message = msg)); + flush(); + + expect(message).toBe("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart-used-i18n"); + })); }); - it("updates custom value to match preset option", () => { - // 1 hour - component.form.controls.vaultTimeout.setValue(60); + describe("form value changes subscription", () => { + it("should call onChange with vault timeout when form is valid and in custom mode", fakeAsync(() => { + const onChange = jest.fn(); + component.registerOnChange(onChange); - expect(component.form.value.custom).toEqual({ hours: 1, minutes: 0 }); + fixture.detectChanges(); + flush(); - // 17 minutes - component.form.controls.vaultTimeout.setValue(17); + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 1, minutes: 30 }); + flush(); - expect(component.form.value.custom).toEqual({ hours: 0, minutes: 17 }); + expect(onChange).toHaveBeenCalledWith(NINETY_MINUTES); + })); - // 2.25 hours - component.form.controls.vaultTimeout.setValue(135); + it("should call onChange when form changes to non-custom mode", fakeAsync(() => { + const onChange = jest.fn(); + component.registerOnChange(onChange); - expect(component.form.value.custom).toEqual({ hours: 2, minutes: 15 }); + fixture.detectChanges(); + flush(); + + onChange.mockClear(); + + component.form.controls.vaultTimeout.setValue(FIFTEEN_MINUTES); + flush(); + + expect(onChange).toHaveBeenCalledWith(FIFTEEN_MINUTES); + })); + + it("should not call onChange when custom controls are invalid", fakeAsync(() => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + fixture.detectChanges(); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + flush(); + + onChange.mockClear(); + + component.form.controls.custom.controls.hours.setValue(null); + flush(); + + expect(onChange).not.toHaveBeenCalled(); + })); + + it("should not call onChange when vaultTimeout is null", fakeAsync(() => { + const onChange = jest.fn(); + component.registerOnChange(onChange); + + fixture.detectChanges(); + flush(); + + onChange.mockClear(); + + component.form.controls.vaultTimeout.setValue(null); + flush(); + + expect(onChange).not.toHaveBeenCalled(); + })); }); - it("sets custom timeout to 0 when a preset string option is selected", () => { - // Set custom value to random values - component.form.controls.custom.setValue({ hours: 1, minutes: 1 }); + describe("custom fields initialization from vaultTimeout changes", () => { + it("should update custom fields when vaultTimeout changes to numeric value", fakeAsync(() => { + fixture.detectChanges(); + flush(); - component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.OnLocked); + component.form.controls.vaultTimeout.setValue(NINETY_MINUTES); + flush(); - expect(component.form.value.custom).toEqual({ hours: 0, minutes: 0 }); + expect(component.form.value.custom).toEqual({ hours: 1, minutes: 30 }); + })); + + it.each([ + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + VaultTimeoutStringType.OnSleep, + VaultTimeoutStringType.OnIdle, + ])( + "should set custom fields to 8 hours when vaultTimeout changes to %s", + fakeAsync((timeoutType: VaultTimeout) => { + fixture.detectChanges(); + flush(); + + component.form.controls.custom.setValue({ hours: 1, minutes: 30 }); + component.form.controls.vaultTimeout.setValue(timeoutType); + flush(); + + expect(component.form.value.custom).toEqual({ hours: 8, minutes: 0 }); + }), + ); + + it("should mark custom fields as touched after update", fakeAsync(() => { + fixture.detectChanges(); + flush(); + + component.form.controls.vaultTimeout.setValue(ONE_HOUR); + flush(); + + expect(component.form.controls.custom.controls.hours.touched).toBe(true); + expect(component.form.controls.custom.controls.minutes.touched).toBe(true); + })); + + it("should not update custom fields when vaultTimeout changes to Custom", fakeAsync(() => { + fixture.detectChanges(); + flush(); + + component.form.controls.custom.setValue({ hours: 5, minutes: 15 }); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + flush(); + + expect(component.form.value.custom).toEqual({ hours: 5, minutes: 15 }); + })); }); }); + + describe("isCustomTimeoutType", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return true when vaultTimeout is Custom", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + flush(); + + expect(component.isCustomTimeoutType).toBe(true); + })); + + it.each([ + ONE_MINUTE, + FIFTEEN_MINUTES, + ONE_HOUR, + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + ])( + "should return false when vaultTimeout is %s", + fakeAsync((timeout: VaultTimeout) => { + component.form.controls.vaultTimeout.setValue(timeout); + flush(); + + expect(component.isCustomTimeoutType).toBe(false); + }), + ); + }); + + describe("customMinutesMin", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return 1 when hours is 0", fakeAsync(() => { + component.form.controls.custom.controls.hours.setValue(0); + flush(); + + expect(component.customMinutesMin).toBe(1); + })); + + it.each([1, 2, 5, 10])( + "should return 0 when hours is %s", + fakeAsync((hours: number) => { + component.form.controls.custom.controls.hours.setValue(hours); + flush(); + + expect(component.customMinutesMin).toBe(0); + }), + ); + }); + + describe("maxSessionTimeoutPolicyHours", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return 0 when no policy exists", fakeAsync(() => { + expect(component.maxSessionTimeoutPolicyHours).toBe(0); + })); + + it.each([ + { minutes: ONE_HOUR, expectedHours: 1 }, + { minutes: NINETY_MINUTES, expectedHours: 1 }, + { minutes: FOUR_HOURS, expectedHours: 4 }, + { minutes: 300, expectedHours: 5 }, + ])( + "should return $expectedHours when policy minutes is $minutes", + fakeAsync(({ minutes, expectedHours }: { minutes: number; expectedHours: number }) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.maxSessionTimeoutPolicyHours).toBe(expectedHours); + }), + ); + }); + + describe("maxSessionTimeoutPolicyMinutes", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return 0 when no policy exists", fakeAsync(() => { + expect(component.maxSessionTimeoutPolicyMinutes).toBe(0); + })); + + it.each([ + { minutes: ONE_HOUR, expectedMinutes: 0 }, + { minutes: NINETY_MINUTES, expectedMinutes: 30 }, + { minutes: 65, expectedMinutes: 5 }, + { minutes: 137, expectedMinutes: 17 }, + ])( + "should return $expectedMinutes when policy minutes is $minutes", + fakeAsync(({ minutes, expectedMinutes }: { minutes: number; expectedMinutes: number }) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.maxSessionTimeoutPolicyMinutes).toBe(expectedMinutes); + }), + ); + }); + + describe("exceedsPolicyMaximumTimeout", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return true when custom timeout exceeds policy maximum", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: ONE_HOUR, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 2, minutes: 0 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(true); + })); + + it("should return false when no policy exists", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 100, minutes: 0 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + + it("should return false when policy type is not custom", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "immediately", + minutes: 0, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 10, minutes: 0 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + + it("should return false when policy type is custom and form timeout is not custom", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: FIFTEEN_MINUTES, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(ONE_HOUR); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + + it("should return false when custom timeout equals policy maximum", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: ONE_HOUR, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 1, minutes: 0 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + + it("should return false when custom timeout is below policy maximum", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: FOUR_HOURS, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 2, minutes: 30 }); + flush(); + + expect(component.exceedsPolicyMaximumTimeout).toBe(false); + })); + }); + + describe("writeValue", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should do nothing when value is null", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(ONE_HOUR); + flush(); + + component.writeValue(null); + flush(); + + expect(component.form.controls.vaultTimeout.value).toBe(ONE_HOUR); + })); + + it("should set form to custom mode when value doesn't match any available option", fakeAsync(() => { + component.writeValue(NINETY_MINUTES); + flush(); + + expect(component.form.controls.vaultTimeout.value).toBe(VaultTimeoutStringType.Custom); + expect(component.form.controls.custom.value).toEqual({ hours: 1, minutes: 30 }); + })); + + it.each([ONE_MINUTE, FIVE_MINUTES, FIFTEEN_MINUTES, THIRTY_MINUTES, ONE_HOUR, FOUR_HOURS])( + "should set vaultTimeout directly when numeric value %s matches preset option", + fakeAsync((timeout: number) => { + component.writeValue(timeout); + flush(); + + expect(component.form.controls.vaultTimeout.value).toBe(timeout); + }), + ); + + it.each([ + VaultTimeoutStringType.OnRestart, + VaultTimeoutStringType.OnLocked, + VaultTimeoutStringType.Never, + ])( + "should set vaultTimeout directly when string value %s matches preset option", + fakeAsync((timeout: VaultTimeout) => { + component.writeValue(timeout); + flush(); + + expect(component.form.controls.vaultTimeout.value).toBe(timeout); + }), + ); + }); + + describe("validate", () => { + beforeEach(fakeAsync(() => { + fixture.detectChanges(); + flush(); + })); + + it("should return null when vaultTimeout is not custom", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(ONE_HOUR); + flush(); + + const result = component.validate(component.form); + + expect(result).toBeNull(); + })); + + it("should return required error when vaultTimeout is custom and hours is null", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.controls.hours.setValue(null); + component.form.controls.custom.controls.minutes.setValue(30); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ required: true }); + })); + + it("should return required error when vaultTimeout is custom and minutes is null", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.controls.hours.setValue(1); + component.form.controls.custom.controls.minutes.setValue(null); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ required: true }); + })); + + it("should return required error when vaultTimeout is custom and both hours and minutes are null", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.controls.hours.setValue(null); + component.form.controls.custom.controls.minutes.setValue(null); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ required: true }); + })); + + it("should return minTimeoutError when total minutes is 0", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 0, minutes: 0 }); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ minTimeoutError: true }); + })); + + it("should return maxTimeoutError when exceeds policy maximum", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: ONE_HOUR, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 2, minutes: 0 }); + flush(); + + const result = component.validate(component.form); + + expect(result).toEqual({ maxTimeoutError: true }); + })); + + it("should return null when custom values are valid and within policy limit", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "custom", + minutes: FOUR_HOURS, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 2, minutes: 30 }); + flush(); + + const result = component.validate(component.form); + + expect(result).toBeNull(); + })); + + it("should return null when custom values are valid and no policy exists", fakeAsync(() => { + component.form.controls.vaultTimeout.setValue(VaultTimeoutStringType.Custom); + component.form.controls.custom.setValue({ hours: 5, minutes: 15 }); + flush(); + + const result = component.validate(component.form); + + expect(result).toBeNull(); + })); + }); }); diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.ts index e9a5722ff4e..f8bbf9c215e 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.ts +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-input.component.ts @@ -1,9 +1,16 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, Input, OnChanges, OnDestroy, OnInit } from "@angular/core"; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + inject, + input, + OnInit, +} from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { AbstractControl, + AbstractControlOptions, ControlValueAccessor, FormBuilder, FormControl, @@ -13,26 +20,32 @@ import { ReactiveFormsModule, ValidationErrors, Validator, + Validators, } from "@angular/forms"; -import { filter, map, Observable, Subject, switchMap, takeUntil } from "rxjs"; +import { filter, map, Observable, switchMap } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; -import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, VaultTimeout, - VaultTimeoutAction, VaultTimeoutOption, - VaultTimeoutSettingsService, + VaultTimeoutNumberType, + VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { FormFieldModule, SelectModule } from "@bitwarden/components"; +import { LogService } from "@bitwarden/logging"; -type VaultTimeoutForm = FormGroup<{ +type SessionTimeoutForm = FormGroup<{ vaultTimeout: FormControl; custom: FormGroup<{ hours: FormControl; @@ -40,10 +53,8 @@ type VaultTimeoutForm = FormGroup<{ }>; }>; -type VaultTimeoutFormValue = VaultTimeoutForm["value"]; +type SessionTimeoutFormValue = SessionTimeoutForm["value"]; -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection @Component({ selector: "bit-session-timeout-input", templateUrl: "session-timeout-input.component.html", @@ -60,111 +71,110 @@ type VaultTimeoutFormValue = VaultTimeoutForm["value"]; useExisting: SessionTimeoutInputComponent, }, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class SessionTimeoutInputComponent - implements ControlValueAccessor, Validator, OnInit, OnDestroy, OnChanges -{ - static CUSTOM_VALUE = -100; - static MIN_CUSTOM_MINUTES = 0; - form: VaultTimeoutForm = this.formBuilder.group({ - vaultTimeout: [null], - custom: this.formBuilder.group({ - hours: [null], - minutes: [null], - }), - }); +export class SessionTimeoutInputComponent implements ControlValueAccessor, Validator, OnInit { + static readonly MIN_CUSTOM_MINUTES = 0; - // FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals - // eslint-disable-next-line @angular-eslint/prefer-signals - @Input() vaultTimeoutOptions: VaultTimeoutOption[]; + private readonly formBuilder = inject(FormBuilder); + private readonly policyService = inject(PolicyService); + private readonly i18nService = inject(I18nService); + private readonly accountService = inject(AccountService); + private readonly destroyRef = inject(DestroyRef); + private readonly sessionTimeoutTypeService = inject(SessionTimeoutTypeService); + private readonly logService = inject(LogService); - vaultTimeoutPolicy: Policy; - vaultTimeoutPolicyHours: number; - vaultTimeoutPolicyMinutes: number; + readonly availableTimeoutOptions = input.required(); - protected readonly VaultTimeoutAction = VaultTimeoutAction; + protected maxSessionTimeoutPolicyData: MaximumSessionTimeoutPolicyData | null = null; + protected policyTimeoutMessage$!: Observable; - protected canLockVault$: Observable; - private onChange: (vaultTimeout: VaultTimeout) => void; - private validatorChange: () => void; - private destroy$ = new Subject(); + readonly form: SessionTimeoutForm = this.formBuilder.group( + { + vaultTimeout: [null as VaultTimeout | null], + custom: this.formBuilder.group({ + hours: [0, [Validators.required, Validators.min(0)]], + minutes: [0, [Validators.required, Validators.min(0), Validators.max(59)]], + }), + }, + { validators: [this.formValidator.bind(this)] } as AbstractControlOptions, + ); - constructor( - private formBuilder: FormBuilder, - private policyService: PolicyService, - private vaultTimeoutSettingsService: VaultTimeoutSettingsService, - private i18nService: I18nService, - private accountService: AccountService, - ) {} + private onChange: ((vaultTimeout: VaultTimeout) => void) | null = null; + private validatorChange: (() => void) | null = null; - get showCustom() { - return this.form.get("vaultTimeout").value === SessionTimeoutInputComponent.CUSTOM_VALUE; + get isCustomTimeoutType(): boolean { + return this.form.controls.vaultTimeout.value === VaultTimeoutStringType.Custom; } - get exceedsMinimumTimeout(): boolean { + get customMinutesMin(): number { + return this.form.controls.custom.controls.hours.value === 0 ? 1 : 0; + } + + get exceedsPolicyMaximumTimeout(): boolean { return ( - !this.showCustom || - this.customTimeInMinutes() > SessionTimeoutInputComponent.MIN_CUSTOM_MINUTES + this.maxSessionTimeoutPolicyData?.type === VaultTimeoutStringType.Custom && + this.isCustomTimeoutType && + this.getTotalMinutesFromCustomValue(this.form.value.custom) > + this.maxSessionTimeoutPolicyMinutes + 60 * this.maxSessionTimeoutPolicyHours ); } - get exceedsMaximumTimeout(): boolean { - return ( - this.showCustom && - this.customTimeInMinutes() > - this.vaultTimeoutPolicyMinutes + 60 * this.vaultTimeoutPolicyHours + ngOnInit(): void { + const maximumSessionTimeoutPolicyData$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, + filter((policy) => policy != null), + map((policy) => policy.data as MaximumSessionTimeoutPolicyData), ); - } - get filteredVaultTimeoutOptions(): VaultTimeoutOption[] { - // by policy max value - if (this.vaultTimeoutPolicy == null || this.vaultTimeoutPolicy.data == null) { - return this.vaultTimeoutOptions; - } + this.policyTimeoutMessage$ = maximumSessionTimeoutPolicyData$.pipe( + switchMap((policyData) => this.getPolicyTimeoutMessage(policyData)), + ); - return this.vaultTimeoutOptions.filter((option) => { - if (typeof option.value === "number") { - return option.value <= this.vaultTimeoutPolicy.data.minutes; - } - - return false; - }); - } - - async ngOnInit() { - this.accountService.activeAccount$ - .pipe( - getUserId, - switchMap((userId) => - this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), - ), - getFirstPolicy, - filter((policy) => policy != null), - takeUntil(this.destroy$), - ) - .subscribe((policy) => { - this.vaultTimeoutPolicy = policy; - this.applyVaultTimeoutPolicy(); - }); - this.form.valueChanges - .pipe(takeUntil(this.destroy$)) - .subscribe((value: VaultTimeoutFormValue) => { - if (this.onChange) { - this.onChange(this.getVaultTimeout(value)); + maximumSessionTimeoutPolicyData$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((policyData) => { + this.maxSessionTimeoutPolicyData = policyData; + // Re-validate custom form group with new policy data + this.form.controls.custom.updateValueAndValidity(); + // Trigger validator change when policy data changes + if (this.validatorChange) { + this.validatorChange(); } }); + // Subscribe to form value changes + this.form.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((value) => { + if (this.onChange) { + const vaultTimeout = this.getVaultTimeout(value); + if (vaultTimeout != null) { + // Only call onChange if the form is valid + // For non-numeric values, we don't need to validate custom fields + const isValid = !this.isCustomTimeoutType || this.form.controls.custom.valid; + if (isValid) { + this.onChange(vaultTimeout); + } + } + } + }); + // Assign the current value to the custom fields // so that if the user goes from a numeric value to custom // we can initialize the custom fields with the current value // ex: user picks 5 min, goes to custom, we want to show 0 hr, 5 min in the custom fields this.form.controls.vaultTimeout.valueChanges .pipe( - filter((value) => value !== SessionTimeoutInputComponent.CUSTOM_VALUE), - takeUntil(this.destroy$), + filter((value) => value != null && value !== VaultTimeoutStringType.Custom), + takeUntilDestroyed(this.destroyRef), ) .subscribe((value) => { - const current = typeof value === "string" ? 0 : Math.max(value, 0); + const current = isVaultTimeoutTypeNumeric(value) + ? (value as number) + : VaultTimeoutNumberType.EightHours; // This cannot emit an event b/c it would cause form.valueChanges to fire again // and we are already handling that above so just silently update @@ -178,112 +188,169 @@ export class SessionTimeoutInputComponent }, { emitEvent: false }, ); + + this.form.controls.custom.markAllAsTouched(); }); - - this.canLockVault$ = this.vaultTimeoutSettingsService - .availableVaultTimeoutActions$() - .pipe(map((actions) => actions.includes(VaultTimeoutAction.Lock))); } - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); + get maxSessionTimeoutPolicyHours(): number { + return Math.floor((this.maxSessionTimeoutPolicyData?.minutes ?? 0) / 60); } - ngOnChanges() { - if ( - !this.vaultTimeoutOptions.find((p) => p.value === SessionTimeoutInputComponent.CUSTOM_VALUE) - ) { - this.vaultTimeoutOptions.push({ - name: this.i18nService.t("custom"), - value: SessionTimeoutInputComponent.CUSTOM_VALUE, - }); - } + get maxSessionTimeoutPolicyMinutes(): number { + return (this.maxSessionTimeoutPolicyData?.minutes ?? 0) % 60; } - getVaultTimeout(value: VaultTimeoutFormValue) { - if (value.vaultTimeout !== SessionTimeoutInputComponent.CUSTOM_VALUE) { - return value.vaultTimeout; - } - - return value.custom.hours * 60 + value.custom.minutes; - } - - writeValue(value: number): void { + writeValue(value: VaultTimeout | null): void { if (value == null) { return; } - if (this.vaultTimeoutOptions.every((p) => p.value !== value)) { + // Normalize the custom numeric value to preset (i.e. 1 minute), otherwise set as custom + const options = this.availableTimeoutOptions(); + const matchingOption = options.some((opt) => opt.value === value); + if (!matchingOption) { + this.logService.debug( + `[SessionTimeoutInputComponent] form control write value as custom ${value}`, + ); this.form.setValue({ - vaultTimeout: SessionTimeoutInputComponent.CUSTOM_VALUE, + vaultTimeout: VaultTimeoutStringType.Custom, custom: { - hours: Math.floor(value / 60), - minutes: value % 60, + hours: Math.floor((value as number) / 60), + minutes: (value as number) % 60, }, }); return; } + this.logService.debug( + `[SessionTimeoutInputComponent] form control write value as preset ${value}`, + ); + + // For string values (e.g., "onLocked", "never"), set directly this.form.patchValue({ vaultTimeout: value, }); } - registerOnChange(onChange: any): void { + registerOnChange(onChange: (vaultTimeout: VaultTimeout) => void): void { this.onChange = onChange; } - registerOnTouched(onTouched: any): void { + registerOnTouched(_onTouched: () => void): void { // Empty } - setDisabledState?(isDisabled: boolean): void { + setDisabledState?(_isDisabled: boolean): void { // Empty } - validate(control: AbstractControl): ValidationErrors { - if (this.vaultTimeoutPolicy && this.vaultTimeoutPolicy?.data?.minutes < control.value) { - return { policyError: true }; - } - - if (!this.exceedsMinimumTimeout) { - return { minTimeoutError: true }; - } - - return null; + validate(_: AbstractControl): ValidationErrors | null { + return this.form.errors; } registerOnValidatorChange(fn: () => void): void { this.validatorChange = fn; } - private customTimeInMinutes() { - return this.form.value.custom.hours * 60 + this.form.value.custom.minutes; + private getTotalMinutesFromCustomValue(customValue: SessionTimeoutFormValue["custom"]): number { + const hours = customValue?.hours ?? 0; + const minutes = customValue?.minutes ?? 0; + return hours * 60 + minutes; } - private applyVaultTimeoutPolicy() { - this.vaultTimeoutPolicyHours = Math.floor(this.vaultTimeoutPolicy.data.minutes / 60); - this.vaultTimeoutPolicyMinutes = this.vaultTimeoutPolicy.data.minutes % 60; + private formValidator(control: AbstractControl): ValidationErrors | null { + const formValue = control.value as SessionTimeoutFormValue; + const isCustomMode = formValue.vaultTimeout === VaultTimeoutStringType.Custom; - this.vaultTimeoutOptions = this.vaultTimeoutOptions.filter((vaultTimeoutOption) => { - // Always include the custom option - if (vaultTimeoutOption.value === SessionTimeoutInputComponent.CUSTOM_VALUE) { - return true; + // Only validate when in custom mode + if (!isCustomMode) { + return null; + } + + const hours = formValue.custom?.hours; + const minutes = formValue.custom?.minutes; + + if (hours == null || minutes == null) { + return { required: true }; + } + + const totalMinutes = this.getTotalMinutesFromCustomValue(formValue.custom); + if (totalMinutes === 0) { + return { minTimeoutError: true }; + } + + if (this.exceedsPolicyMaximumTimeout) { + return { maxTimeoutError: true }; + } + + return null; + } + + private getVaultTimeout(value: SessionTimeoutFormValue): VaultTimeout | null { + if (value.vaultTimeout !== VaultTimeoutStringType.Custom) { + return value.vaultTimeout ?? null; + } + + return this.getTotalMinutesFromCustomValue(value.custom); + } + + private async getPolicyTimeoutMessage( + policyData: MaximumSessionTimeoutPolicyData, + ): Promise { + const timeout = await this.getPolicyAppliedTimeout(policyData); + + switch (timeout) { + case null: + // Don't display the policy message + return null; + case VaultTimeoutNumberType.Immediately: + return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToImmediately"); + case VaultTimeoutStringType.OnLocked: + return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnLocked"); + case VaultTimeoutStringType.OnRestart: + return this.i18nService.t("sessionTimeoutSettingsPolicySetDefaultTimeoutToOnRestart"); + default: + if (isVaultTimeoutTypeNumeric(timeout)) { + const hours = Math.floor((timeout as number) / 60); + const minutes = (timeout as number) % 60; + return this.i18nService.t( + "sessionTimeoutSettingsPolicySetMaximumTimeoutToHoursMinutes", + hours, + minutes, + ); + } + throw new Error("Invalid timeout parameter"); + } + } + + private async getPolicyAppliedTimeout( + policyData: MaximumSessionTimeoutPolicyData, + ): Promise { + switch (policyData.type) { + case "immediately": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutNumberType.Immediately, + ); + case "onSystemLock": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.OnLocked, + ); + case "onAppRestart": + return VaultTimeoutStringType.OnRestart; + case "never": { + const timeout = await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.Never, + ); + if (timeout == VaultTimeoutStringType.Never) { + // Don't display policy message, when the policy doesn't change the available timeout options + return null; + } + return timeout; } - - if (typeof vaultTimeoutOption.value === "number") { - // Include numeric values that are less than or equal to the policy minutes - return vaultTimeoutOption.value <= this.vaultTimeoutPolicy.data.minutes; - } - - // Exclude all string cases when there's a numeric policy defined - return false; - }); - - // Only call validator change if it's been set - if (this.validatorChange) { - this.validatorChange(); + case "custom": + default: + return policyData.minutes; } } } diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html index 4d607ce5d7b..e6de54dc38e 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.html @@ -1,10 +1,11 @@
- - + @if (availableTimeoutOptions$ | async; as options) { + + + } {{ "sessionTimeoutSettingsAction" | i18n }} @@ -18,14 +19,10 @@ } - @if (!canLock) { - {{ "unlockMethodNeededToChangeTimeoutActionDesc" | i18n }}
+ @if (!canLock && supportsLock) { + {{ "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction" | i18n }} + } @else if ((sessionTimeoutActionFromPolicy$ | async) != null) { + {{ "sessionTimeoutSettingsManagedByOrganization" | i18n }} }
- - @if (hasVaultTimeoutPolicy$ | async) { - - {{ "vaultTimeoutPolicyAffectingOptions" | i18n }} - - }
diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts index 4c3f41d738c..7b69ee13dc3 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.spec.ts @@ -4,11 +4,15 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { mock, MockProxy } from "jest-mock-extended"; import { BehaviorSubject, filter, firstValueFrom, of } from "rxjs"; +import { ClientType } from "@bitwarden/client-type"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { - MaximumVaultTimeoutPolicyData, + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { VaultTimeout, VaultTimeoutAction, VaultTimeoutOption, @@ -16,6 +20,7 @@ import { VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec"; import { UserId } from "@bitwarden/common/types/guid"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -39,6 +44,8 @@ describe("SessionTimeoutSettingsComponent", () => { let accountService: FakeAccountService; let mockDialogService: MockProxy; let mockLogService: MockProxy; + const mockPlatformUtilsService = mock(); + const mockSessionTimeoutTypeService = mock(); const mockUserId = "user-id" as UserId; const mockEmail = "test@example.com"; @@ -46,6 +53,7 @@ describe("SessionTimeoutSettingsComponent", () => { const mockInitialTimeoutAction = VaultTimeoutAction.Lock; let refreshTimeoutActionSettings$: BehaviorSubject; let availableTimeoutOptions$: BehaviorSubject; + let policies$: BehaviorSubject; beforeEach(async () => { refreshTimeoutActionSettings$ = new BehaviorSubject(undefined); @@ -58,6 +66,7 @@ describe("SessionTimeoutSettingsComponent", () => { { name: "onIdle-used-i18n", value: VaultTimeoutStringType.OnIdle }, { name: "never-used-i18n", value: VaultTimeoutStringType.Never }, ]); + policies$ = new BehaviorSubject([]); mockVaultTimeoutSettingsService = mock(); mockSessionTimeoutSettingsComponentService = mock(); @@ -79,9 +88,10 @@ describe("SessionTimeoutSettingsComponent", () => { mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), ); - mockSessionTimeoutSettingsComponentService.availableTimeoutOptions$ = - availableTimeoutOptions$.asObservable(); - mockPolicyService.policiesByType$.mockImplementation(() => of([])); + mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockImplementation( + (userId) => availableTimeoutOptions$.asObservable(), + ); + mockPolicyService.policiesByType$.mockReturnValue(policies$.asObservable()); await TestBed.configureTestingModule({ imports: [ @@ -102,6 +112,8 @@ describe("SessionTimeoutSettingsComponent", () => { { provide: AccountService, useValue: accountService }, { provide: LogService, useValue: mockLogService }, { provide: DialogService, useValue: mockDialogService }, + { provide: PlatformUtilsService, useValue: mockPlatformUtilsService }, + { provide: SessionTimeoutTypeService, useValue: mockSessionTimeoutTypeService }, ], }) .overrideComponent(SessionTimeoutSettingsComponent, { @@ -145,6 +157,83 @@ describe("SessionTimeoutSettingsComponent", () => { })); }); + describe("supportsLock", () => { + it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli])( + "should return true when client is %s and policy action is null", + fakeAsync((clientType: ClientType) => { + mockPlatformUtilsService.getClientType.mockReturnValue(clientType); + + fixture.detectChanges(); + flush(); + + expect(component.supportsLock).toBe(true); + }), + ); + + it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli])( + "should return true when client is %s and policy action is lock", + fakeAsync((clientType: ClientType) => { + mockPlatformUtilsService.getClientType.mockReturnValue(clientType); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.Lock, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.supportsLock).toBe(true); + }), + ); + + it.each([ClientType.Desktop, ClientType.Browser, ClientType.Cli, ClientType.Web])( + "should return false when client is %s and policy action is logOut", + fakeAsync((clientType: ClientType) => { + mockPlatformUtilsService.getClientType.mockReturnValue(clientType); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.LogOut, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.supportsLock).toBe(false); + }), + ); + + it("should return false when client is Web and policy action is null", fakeAsync(() => { + mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); + + fixture.detectChanges(); + flush(); + + expect(component.supportsLock).toBe(false); + })); + + it("should return false when client is Web and policy action is lock", fakeAsync(() => { + mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); + + fixture.detectChanges(); + flush(); + + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: VaultTimeoutAction.Lock, + }; + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component.supportsLock).toBe(false); + })); + }); + describe("ngOnInit", () => { it("should initialize available timeout options", fakeAsync(async () => { fixture.detectChanges(); @@ -178,7 +267,7 @@ describe("SessionTimeoutSettingsComponent", () => { }); })); - it("should initialize available timeout actions", fakeAsync(() => { + it("should initialize available timeout actions signal", fakeAsync(() => { const expectedActions = [VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]; mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => @@ -209,27 +298,55 @@ describe("SessionTimeoutSettingsComponent", () => { expect(component.formGroup.value.timeoutAction).toBe(expectedAction); })); - it("should fall back to OnRestart when current option is not available", fakeAsync(() => { - availableTimeoutOptions$.next([ - { name: "oneMinute-used-i18n", value: 1 }, - { name: "fiveMinutes-used-i18n", value: 5 }, - { name: "onRestart-used-i18n", value: VaultTimeoutStringType.OnRestart }, - ]); + it("should initialize userId from active account", fakeAsync(() => { + fixture.detectChanges(); + flush(); - const unavailableTimeout = VaultTimeoutStringType.Never; + expect(component["userId"]).toBe(mockUserId); + })); - mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation(() => - of(unavailableTimeout), - ); + it("should initialize sessionTimeoutActionFromPolicy signal with null when no policy exists", fakeAsync(() => { + fixture.detectChanges(); + flush(); + + expect(component["sessionTimeoutActionFromPolicy"]()).toBeNull(); + })); + + it.each([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut])( + "should initialize sessionTimeoutActionFromPolicy signal with policy action %s when policy exists", + fakeAsync((timeoutAction: VaultTimeoutAction) => { + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: timeoutAction, + }; + + fixture.detectChanges(); + flush(); + + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component["sessionTimeoutActionFromPolicy"]()).toBe(timeoutAction); + }), + ); + + it("should initialize sessionTimeoutActionFromPolicy signal with null when policy exists and action is user preference", fakeAsync(() => { + const policyData: MaximumSessionTimeoutPolicyData = { + minutes: 15, + action: null, + }; fixture.detectChanges(); flush(); - expect(component.formGroup.value.timeout).toBe(VaultTimeoutStringType.OnRestart); + policies$.next([{ id: "1", enabled: true, data: policyData } as Policy]); + flush(); + + expect(component["sessionTimeoutActionFromPolicy"]()).toBeNull(); })); it("should disable timeout action control when policy enforces action", fakeAsync(() => { - const policyData: MaximumVaultTimeoutPolicyData = { + const policyData: MaximumSessionTimeoutPolicyData = { minutes: 15, action: VaultTimeoutAction.LogOut, }; @@ -273,7 +390,7 @@ describe("SessionTimeoutSettingsComponent", () => { expect(component.formGroup.controls.timeoutAction.enabled).toBe(true); - const policyData: MaximumVaultTimeoutPolicyData = { + const policyData: MaximumSessionTimeoutPolicyData = { minutes: 15, action: VaultTimeoutAction.LogOut, }; @@ -355,6 +472,56 @@ describe("SessionTimeoutSettingsComponent", () => { expect(saveSpy).toHaveBeenCalledWith(VaultTimeoutAction.LogOut); })); + + it("should sync form timeout when service emits new timeout value", fakeAsync(() => { + const timeout$ = new BehaviorSubject(mockInitialTimeout); + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(timeout$); + + fixture.detectChanges(); + flush(); + + expect(component.formGroup.controls.timeout.value).toBe(mockInitialTimeout); + + const newTimeout = 30; + timeout$.next(newTimeout); + flush(); + + expect(component.formGroup.controls.timeout.value).toBe(newTimeout); + })); + + it("should not sync form timeout when service emits same timeout value", fakeAsync(() => { + const timeout$ = new BehaviorSubject(mockInitialTimeout); + mockVaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockReturnValue(timeout$); + + fixture.detectChanges(); + flush(); + + const setValueSpy = jest.spyOn(component.formGroup.controls.timeout, "setValue"); + + timeout$.next(mockInitialTimeout); + flush(); + + expect(setValueSpy).not.toHaveBeenCalled(); + })); + + it("should update availableTimeoutActions signal when service emits new actions", fakeAsync(() => { + const actions$ = new BehaviorSubject([VaultTimeoutAction.Lock]); + mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockReturnValue(actions$); + + fixture.detectChanges(); + flush(); + + expect(component["availableTimeoutActions"]()).toEqual([VaultTimeoutAction.Lock]); + + actions$.next([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]); + refreshTimeoutActionSettings$.next(undefined); + flush(); + + expect(component["availableTimeoutActions"]()).toEqual([ + VaultTimeoutAction.Lock, + VaultTimeoutAction.LogOut, + ]); + })); }); describe("saveTimeout", () => { diff --git a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts index ecdd0ee89ad..89f085e5127 100644 --- a/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts +++ b/libs/key-management-ui/src/session-timeout/components/session-timeout-settings.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from "@angular/common"; -import { Component, DestroyRef, input, OnInit, signal } from "@angular/core"; -import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Component, DestroyRef, inject, input, OnInit, signal } from "@angular/core"; +import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop"; import { FormControl, FormGroup, @@ -18,27 +18,28 @@ import { firstValueFrom, map, Observable, - of, pairwise, startWith, switchMap, + tap, } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { ClientType } from "@bitwarden/client-type"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { MaximumSessionTimeoutPolicyData } from "@bitwarden/common/key-management/session-timeout"; import { - MaximumVaultTimeoutPolicyData, VaultTimeout, VaultTimeoutAction, - VaultTimeoutOption, VaultTimeoutSettingsService, VaultTimeoutStringType, } from "@bitwarden/common/key-management/vault-timeout"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { UserId } from "@bitwarden/common/types/guid"; import { CheckboxModule, @@ -86,6 +87,19 @@ export class SessionTimeoutSettingsComponent implements OnInit { new BehaviorSubject(undefined), ); + private readonly vaultTimeoutSettingsService = inject(VaultTimeoutSettingsService); + private readonly sessionTimeoutSettingsComponentService = inject( + SessionTimeoutSettingsComponentService, + ); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + private readonly policyService = inject(PolicyService); + private readonly accountService = inject(AccountService); + private readonly dialogService = inject(DialogService); + private readonly logService = inject(LogService); + private readonly destroyRef = inject(DestroyRef); + private readonly platformUtilsService = inject(PlatformUtilsService); + formGroup = new FormGroup({ timeout: new FormControl(null, [Validators.required]), timeoutAction: new FormControl(VaultTimeoutAction.Lock, [ @@ -93,63 +107,48 @@ export class SessionTimeoutSettingsComponent implements OnInit { ]), }); protected readonly availableTimeoutActions = signal([]); - protected readonly availableTimeoutOptions$ = - this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$.pipe( - startWith([] as VaultTimeoutOption[]), - ); - protected hasVaultTimeoutPolicy$: Observable = of(false); + protected readonly availableTimeoutOptions$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.sessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$(userId), + ), + tap((options) => { + this.logService.debug("[SessionTimeoutSettings] Available timeout options", options); + }), + ); + protected readonly sessionTimeoutActionFromPolicy$ = this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + this.policyService.policiesByType$(PolicyType.MaximumVaultTimeout, userId), + ), + getFirstPolicy, + map((policy) => policy?.data as MaximumSessionTimeoutPolicyData | undefined), + map((data) => data?.action ?? null), + ); + protected readonly sessionTimeoutActionFromPolicy = toSignal( + this.sessionTimeoutActionFromPolicy$, + ); private userId!: UserId; - constructor( - private readonly vaultTimeoutSettingsService: VaultTimeoutSettingsService, - private readonly sessionTimeoutSettingsComponentService: SessionTimeoutSettingsComponentService, - private readonly i18nService: I18nService, - private readonly toastService: ToastService, - private readonly policyService: PolicyService, - private readonly accountService: AccountService, - private readonly dialogService: DialogService, - private readonly logService: LogService, - private readonly destroyRef: DestroyRef, - ) {} - get canLock() { return this.availableTimeoutActions().includes(VaultTimeoutAction.Lock); } + get supportsLock() { + return ( + this.platformUtilsService.getClientType() !== ClientType.Web && + this.sessionTimeoutActionFromPolicy() !== "logOut" + ); + } + async ngOnInit(): Promise { - const availableTimeoutOptions = await firstValueFrom( - this.sessionTimeoutSettingsComponentService.availableTimeoutOptions$, - ); - - this.logService.debug( - "[SessionTimeoutSettings] Available timeout options", - availableTimeoutOptions, - ); - this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); - const maximumVaultTimeoutPolicy$ = this.policyService - .policiesByType$(PolicyType.MaximumVaultTimeout, this.userId) - .pipe(getFirstPolicy); - - this.hasVaultTimeoutPolicy$ = maximumVaultTimeoutPolicy$.pipe(map((policy) => policy != null)); - - let timeout = await firstValueFrom( + const timeout = await firstValueFrom( this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(this.userId), ); - // Fallback if current timeout option is not available on this platform - // Only applies to string-based timeout types, not numeric values - const hasCurrentOption = availableTimeoutOptions.some((opt) => opt.value === timeout); - if (!hasCurrentOption && typeof timeout !== "number") { - this.logService.debug( - "[SessionTimeoutSettings] Current timeout option not available, falling back from", - { timeout }, - ); - timeout = VaultTimeoutStringType.OnRestart; - } - this.formGroup.patchValue( { timeout: timeout, @@ -160,6 +159,23 @@ export class SessionTimeoutSettingsComponent implements OnInit { { emitEvent: false }, ); + // Sync form with reactive timeout updates to handle race condition where policies + // load asynchronously and may override the initially set timeout value + this.vaultTimeoutSettingsService + .getVaultTimeoutByUserId$(this.userId) + .pipe( + filter((timeout) => this.formGroup.controls.timeout.value !== timeout), + tap((timeout) => + this.logService.debug( + `[SessionTimeoutSettings] Updating initial form timeout from ${this.formGroup.controls.timeout.value} to ${timeout}`, + ), + ), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((timeout) => { + this.formGroup.controls.timeout.setValue(timeout, { emitEvent: false }); + }); + this.refreshTimeoutActionSettings() .pipe( startWith(undefined), @@ -167,19 +183,17 @@ export class SessionTimeoutSettingsComponent implements OnInit { combineLatest([ this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId), this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), - maximumVaultTimeoutPolicy$, + this.sessionTimeoutActionFromPolicy$, ]), ), takeUntilDestroyed(this.destroyRef), ) - .subscribe(([availableActions, action, policy]) => { + .subscribe(([availableActions, action, sessionTimeoutActionFromPolicy]) => { this.availableTimeoutActions.set(availableActions); this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false }); - const policyData = policy?.data as MaximumVaultTimeoutPolicyData | undefined; - // Enable/disable the action control based on policy or available actions - if (policyData?.action != null || availableActions.length <= 1) { + if (sessionTimeoutActionFromPolicy != null || availableActions.length <= 1) { this.formGroup.controls.timeoutAction.disable({ emitEvent: false }); } else { this.formGroup.controls.timeoutAction.enable({ emitEvent: false }); diff --git a/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.spec.ts b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.spec.ts new file mode 100644 index 00000000000..9fe8ba6747b --- /dev/null +++ b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.spec.ts @@ -0,0 +1,337 @@ +import { fakeAsync, flush } from "@angular/core/testing"; +import { mock, MockProxy } from "jest-mock-extended"; +import { firstValueFrom, of } from "rxjs"; + +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { SessionTimeoutSettingsComponentService } from "./session-timeout-settings-component.service"; + +describe("SessionTimeoutSettingsComponentService", () => { + let service: SessionTimeoutSettingsComponentService; + let mockI18nService: MockProxy; + let mockSessionTimeoutTypeService: MockProxy; + let mockPolicyService: MockProxy; + + const mockUserId = "test-user-id" as UserId; + + beforeEach(() => { + mockI18nService = mock(); + mockSessionTimeoutTypeService = mock(); + mockPolicyService = mock(); + + mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`); + mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true); + mockPolicyService.policiesByType$.mockReturnValue(of([])); + + service = new SessionTimeoutSettingsComponentService( + mockI18nService, + mockSessionTimeoutTypeService, + mockPolicyService, + ); + }); + + it("should create", () => { + expect(service).toBeTruthy(); + }); + + describe("availableTimeoutOptions$", () => { + it("should return all options when isAvailable returns true for all", fakeAsync(async () => { + mockSessionTimeoutTypeService.isAvailable.mockResolvedValue(true); + flush(); + + const options = await firstValueFrom(service["availableTimeoutOptions$"]); + + assertAllTimeoutTypes(options); + })); + + it("should filter options based on isAvailable() results", fakeAsync(async () => { + mockSessionTimeoutTypeService.isAvailable.mockImplementation(async (value: VaultTimeout) => { + return ( + value === VaultTimeoutNumberType.OnMinute || + value === 5 || + value === VaultTimeoutStringType.OnLocked + ); + }); + flush(); + + const options = await firstValueFrom(service["availableTimeoutOptions$"]); + + expect(options).toHaveLength(3); + expect(options).toContainEqual({ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute }); + expect(options).toContainEqual({ name: "fiveMinutes", value: 5 }); + expect(options).toContainEqual({ name: "onLocked", value: VaultTimeoutStringType.OnLocked }); + expect(options).not.toContainEqual({ + name: "immediately", + value: VaultTimeoutNumberType.Immediately, + }); + })); + }); + + describe("policyFilteredTimeoutOptions$", () => { + it("should return all available options when no policy for user", fakeAsync(async () => { + mockPolicyService.policiesByType$.mockReturnValue(of([])); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + assertAllTimeoutTypes(options); + })); + + describe('policy type "immediately"', () => { + it.each([VaultTimeoutNumberType.Immediately, VaultTimeoutNumberType.OnMinute])( + "should only return immediately option or fallback", + fakeAsync(async (availableTimeoutOrPromoted: VaultTimeout) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "immediately", + minutes: 0, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + availableTimeoutOrPromoted, + ); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + expect(options).toHaveLength(1); + if (availableTimeoutOrPromoted === VaultTimeoutNumberType.Immediately) { + expect(options[0]).toEqual({ + name: "immediately", + value: VaultTimeoutNumberType.Immediately, + }); + } else { + expect(options[0]).toEqual({ + name: "oneMinute", + value: VaultTimeoutNumberType.OnMinute, + }); + } + }), + ); + }); + + describe('policy type "onSystemLock"', () => { + it.each([VaultTimeoutStringType.OnLocked, VaultTimeoutStringType.OnRestart])( + "should allow immediately, numeric, custom, onLocked, onIdle, onSleep or fallback", + fakeAsync(async (availableTimeoutOrPromoted: VaultTimeout) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onSystemLock", + minutes: 0, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + mockSessionTimeoutTypeService.getOrPromoteToAvailable.mockResolvedValue( + availableTimeoutOrPromoted, + ); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + assertNumericTimeoutTypes(options); + expect(options).toContainEqual({ + name: "onLocked", + value: VaultTimeoutStringType.OnLocked, + }); + expect(options).toContainEqual({ name: "onIdle", value: VaultTimeoutStringType.OnIdle }); + expect(options).toContainEqual({ + name: "onSleep", + value: VaultTimeoutStringType.OnSleep, + }); + expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom }); + expect(options).not.toContainEqual({ + name: "never", + value: VaultTimeoutStringType.Never, + }); + if (availableTimeoutOrPromoted === VaultTimeoutStringType.OnLocked) { + expect(options).not.toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + } else { + expect(options).toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + } + }), + ); + }); + + describe('policy type "onAppRestart"', () => { + it("should allow immediately, numeric, custom, and onRestart", fakeAsync(async () => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "onAppRestart", + minutes: 0, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + assertNumericTimeoutTypes(options); + expect(options).toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom }); + expect(options).not.toContainEqual({ + name: "onLocked", + value: VaultTimeoutStringType.OnLocked, + }); + expect(options).not.toContainEqual({ + name: "onIdle", + value: VaultTimeoutStringType.OnIdle, + }); + expect(options).not.toContainEqual({ + name: "onSleep", + value: VaultTimeoutStringType.OnSleep, + }); + expect(options).not.toContainEqual({ name: "never", value: VaultTimeoutStringType.Never }); + })); + }); + + describe('policy type "custom", null, or undefined', () => { + it.each(["custom", null, undefined])( + "should allow immediately, custom, and numeric values within policy limit when type is %s", + fakeAsync(async (policyType: "custom" | null | undefined) => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: policyType as "custom" | null | undefined, + minutes: 15, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + expect(options).toContainEqual({ + name: "immediately", + value: VaultTimeoutNumberType.Immediately, + }); + expect(options).toContainEqual({ + name: "oneMinute", + value: VaultTimeoutNumberType.OnMinute, + }); + expect(options).toContainEqual({ name: "fiveMinutes", value: 5 }); + expect(options).toContainEqual({ name: "fifteenMinutes", value: 15 }); + expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom }); + expect(options).not.toContainEqual({ name: "thirtyMinutes", value: 30 }); + expect(options).not.toContainEqual({ name: "oneHour", value: 60 }); + expect(options).not.toContainEqual({ name: "fourHours", value: 240 }); + expect(options).not.toContainEqual({ + name: "onLocked", + value: VaultTimeoutStringType.OnLocked, + }); + expect(options).not.toContainEqual({ + name: "onIdle", + value: VaultTimeoutStringType.OnIdle, + }); + expect(options).not.toContainEqual({ + name: "onSleep", + value: VaultTimeoutStringType.OnSleep, + }); + expect(options).not.toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + expect(options).not.toContainEqual({ + name: "never", + value: VaultTimeoutStringType.Never, + }); + }), + ); + }); + + describe('policy type "never"', () => { + it("should return all available options", fakeAsync(async () => { + const policyData: MaximumSessionTimeoutPolicyData = { + type: "never", + minutes: 0, + }; + const policy = { + id: "policy-id", + organizationId: "org-id", + type: PolicyType.MaximumVaultTimeout, + data: policyData, + enabled: true, + } as Policy; + + mockPolicyService.policiesByType$.mockReturnValue(of([policy])); + flush(); + + const options = await firstValueFrom(service.policyFilteredTimeoutOptions$(mockUserId)); + + assertAllTimeoutTypes(options); + })); + }); + }); + + function assertAllTimeoutTypes(options: VaultTimeoutOption[]) { + assertNumericTimeoutTypes(options); + expect(options).toContainEqual({ name: "onIdle", value: VaultTimeoutStringType.OnIdle }); + expect(options).toContainEqual({ name: "onSleep", value: VaultTimeoutStringType.OnSleep }); + expect(options).toContainEqual({ name: "onLocked", value: VaultTimeoutStringType.OnLocked }); + expect(options).toContainEqual({ + name: "sessionTimeoutOnRestart", + value: VaultTimeoutStringType.OnRestart, + }); + expect(options).toContainEqual({ name: "never", value: VaultTimeoutStringType.Never }); + expect(options).toContainEqual({ name: "custom", value: VaultTimeoutStringType.Custom }); + } + + function assertNumericTimeoutTypes(options: VaultTimeoutOption[]) { + expect(options).toContainEqual({ + name: "immediately", + value: VaultTimeoutNumberType.Immediately, + }); + expect(options).toContainEqual({ name: "oneMinute", value: VaultTimeoutNumberType.OnMinute }); + expect(options).toContainEqual({ name: "fiveMinutes", value: 5 }); + expect(options).toContainEqual({ name: "fifteenMinutes", value: 15 }); + expect(options).toContainEqual({ name: "thirtyMinutes", value: 30 }); + expect(options).toContainEqual({ name: "oneHour", value: 60 }); + expect(options).toContainEqual({ name: "fourHours", value: 240 }); + } +}); diff --git a/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts index 7b9efeac9cb..d974eb25468 100644 --- a/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts +++ b/libs/key-management-ui/src/session-timeout/services/session-timeout-settings-component.service.ts @@ -1,9 +1,158 @@ -import { Observable } from "rxjs"; +import { combineLatest, concatMap, defer, map, Observable } from "rxjs"; -import { VaultTimeout, VaultTimeoutOption } from "@bitwarden/common/key-management/vault-timeout"; +import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; +import { PolicyType } from "@bitwarden/common/admin-console/enums"; +import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service"; +import { + MaximumSessionTimeoutPolicyData, + SessionTimeoutTypeService, +} from "@bitwarden/common/key-management/session-timeout"; +import { + isVaultTimeoutTypeNumeric, + VaultTimeout, + VaultTimeoutOption, + VaultTimeoutNumberType, + VaultTimeoutStringType, +} from "@bitwarden/common/key-management/vault-timeout"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserId } from "@bitwarden/user-core"; -export abstract class SessionTimeoutSettingsComponentService { - abstract availableTimeoutOptions$: Observable; +export class SessionTimeoutSettingsComponentService { + private readonly availableTimeoutOptions$: Observable; - abstract onTimeoutSave(timeout: VaultTimeout): void; + constructor( + protected readonly i18nService: I18nService, + protected readonly sessionTimeoutTypeService: SessionTimeoutTypeService, + protected readonly policyService: PolicyService, + ) { + this.availableTimeoutOptions$ = defer(async () => { + const allOptions = this.getAllTimeoutOptions(); + const availabilityResults = await Promise.all( + allOptions.map(async (option) => ({ + option, + available: await this.sessionTimeoutTypeService.isAvailable(option.value), + })), + ); + + return availabilityResults + .filter((result) => result.available) + .map((result) => result.option); + }); + } + + onTimeoutSave(_timeout: VaultTimeout): void { + // Default implementation does nothing, but other clients might want to override this + } + + policyFilteredTimeoutOptions$(userId: UserId): Observable { + const policyData$ = this.policyService + .policiesByType$(PolicyType.MaximumVaultTimeout, userId) + .pipe( + getFirstPolicy, + map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null), + ); + + return combineLatest([ + this.availableTimeoutOptions$, + policyData$, + policyData$.pipe( + concatMap(async (policyData) => { + if (policyData == null) { + return null; + } + switch (policyData.type) { + case "immediately": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutNumberType.Immediately, + ); + case "onSystemLock": + return await this.sessionTimeoutTypeService.getOrPromoteToAvailable( + VaultTimeoutStringType.OnLocked, + ); + } + + return null; + }), + ), + ]).pipe( + concatMap( + async ([availableOptions, policyData, highestAvailableEnforcedByPolicyTimeoutType]) => { + if (policyData == null) { + return availableOptions; + } + + return availableOptions.filter((option) => { + switch (policyData.type) { + case "immediately": { + // Policy requires immediate lock. + return option.value === highestAvailableEnforcedByPolicyTimeoutType; + } + + case "onSystemLock": { + // Allow immediately, numeric values, custom, and any system lock-related options. + if ( + option.value === VaultTimeoutNumberType.Immediately || + isVaultTimeoutTypeNumeric(option.value) || + option.value === VaultTimeoutStringType.Custom || + option.value === VaultTimeoutStringType.OnLocked || + option.value === VaultTimeoutStringType.OnIdle || + option.value === VaultTimeoutStringType.OnSleep + ) { + return true; + } + + // When on locked is not supported, fallback. + return option.value === highestAvailableEnforcedByPolicyTimeoutType; + } + + case "onAppRestart": + // Allow immediately, numeric values, custom, and on app restart + return ( + option.value === VaultTimeoutNumberType.Immediately || + isVaultTimeoutTypeNumeric(option.value) || + option.value === VaultTimeoutStringType.Custom || + option.value === VaultTimeoutStringType.OnRestart + ); + + case "custom": + case null: + case undefined: + // Allow immediately, custom, and numeric values within policy limit + return ( + option.value === VaultTimeoutNumberType.Immediately || + option.value === VaultTimeoutStringType.Custom || + (isVaultTimeoutTypeNumeric(option.value) && + (option.value as number) <= policyData.minutes) + ); + + case "never": + // No policy restriction + return true; + + default: + throw Error(`Unsupported policy type: ${policyData.type}`); + } + }); + }, + ), + ); + } + + private getAllTimeoutOptions(): VaultTimeoutOption[] { + return [ + { name: "immediately", value: VaultTimeoutNumberType.Immediately }, + { name: "oneMinute", value: VaultTimeoutNumberType.OnMinute }, + { name: "fiveMinutes", value: 5 }, + { name: "fifteenMinutes", value: 15 }, + { name: "thirtyMinutes", value: 30 }, + { name: "oneHour", value: 60 }, + { name: "fourHours", value: 240 }, + { name: "onIdle", value: VaultTimeoutStringType.OnIdle }, + { name: "onSleep", value: VaultTimeoutStringType.OnSleep }, + { name: "onLocked", value: VaultTimeoutStringType.OnLocked }, + { name: "sessionTimeoutOnRestart", value: VaultTimeoutStringType.OnRestart }, + { name: "never", value: VaultTimeoutStringType.Never }, + { name: "custom", value: VaultTimeoutStringType.Custom }, + ]; + } } From b49a9d4b0cac7ca73037d27e4217636748f0c42a Mon Sep 17 00:00:00 2001 From: MarsCandyBars <63179967+MarsCandyBars@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:10:05 -0600 Subject: [PATCH 002/136] [AC-1159] Fix disabledSso duplicate event log (#17596) * fixed disabledSso duplicate message * Add ssoTurnedOff entry * Revise to only add new key --- apps/web/src/app/core/event.service.ts | 2 +- apps/web/src/locales/en/messages.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/core/event.service.ts b/apps/web/src/app/core/event.service.ts index 05a7f5aa64c..55d5524c2fa 100644 --- a/apps/web/src/app/core/event.service.ts +++ b/apps/web/src/app/core/event.service.ts @@ -372,7 +372,7 @@ export class EventService { msg = humanReadableMsg = this.i18nService.t("enabledSso"); break; case EventType.Organization_DisabledSso: - msg = humanReadableMsg = this.i18nService.t("disabledSso"); + msg = humanReadableMsg = this.i18nService.t("ssoTurnedOff"); break; case EventType.Organization_EnabledKeyConnector: msg = humanReadableMsg = this.i18nService.t("enabledKeyConnector"); diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 5f404463d66..b925f3a4198 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7158,8 +7158,8 @@ "enabledSso": { "message": "SSO turned on" }, - "disabledSso": { - "message": "SSO turned on" + "ssoTurnedOff": { + "message": "SSO turned off" }, "emailMustLoginWithSso": { "message": "$EMAIL$ must login with Single Sign-on", From 3efa7b1f4092b916efbf043ad20621c00933dd05 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Fri, 5 Dec 2025 11:00:07 -0500 Subject: [PATCH 003/136] Export spinner component (#17836) --- libs/components/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 410a96f1cb3..23fb5beb456 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -39,6 +39,9 @@ export * from "./section"; export * from "./select"; export * from "./shared/compact-mode.service"; export * from "./skeleton"; +export * from "./spinner"; +export * from "./stepper"; +export * from "./switch"; export * from "./table"; export * from "./tabs"; export * from "./toast"; @@ -46,5 +49,3 @@ export * from "./toggle-group"; export * from "./tooltip"; export * from "./typography"; export * from "./utils"; -export * from "./stepper"; -export * from "./switch"; From 64fb817e99d89864bdbe8cf9ad7e2f49b2892b3c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 11:43:52 -0600 Subject: [PATCH 004/136] [deps] Platform: Update @ngtools/webpack to v20.3.12 (#17838) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 25 ++++--------------------- package.json | 2 +- 2 files changed, 5 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88754d75ad0..b03c0c7b20c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -87,7 +87,7 @@ "@electron/rebuild": "4.0.1", "@eslint/compat": "2.0.0", "@lit-labs/signals": "0.1.2", - "@ngtools/webpack": "20.3.11", + "@ngtools/webpack": "20.3.12", "@storybook/addon-a11y": "9.1.16", "@storybook/addon-designs": "9.0.0-next.3", "@storybook/addon-docs": "9.1.16", @@ -1029,23 +1029,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@angular-devkit/build-angular/node_modules/@ngtools/webpack": { - "version": "20.3.12", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.12.tgz", - "integrity": "sha512-ePuofHOtbgvEq2t+hcmL30s4q9HQ/nv9ABwpLiELdVIObcWUnrnizAvM7hujve/9CQL6gRCeEkxPLPS4ZrK9AQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "@angular/compiler-cli": "^20.0.0", - "typescript": ">=5.8 <6.0", - "webpack": "^5.54.0" - } - }, "node_modules/@angular-devkit/build-angular/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -9002,9 +8985,9 @@ } }, "node_modules/@ngtools/webpack": { - "version": "20.3.11", - "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.11.tgz", - "integrity": "sha512-c2/66tObP9YevCt7jyhwiGifS8ldfce6vYQ63Wwj8tlXSSutHk8+3VEQmbW3wW1JH7+0aNf3kF+pA97EbGj6QA==", + "version": "20.3.12", + "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-20.3.12.tgz", + "integrity": "sha512-ePuofHOtbgvEq2t+hcmL30s4q9HQ/nv9ABwpLiELdVIObcWUnrnizAvM7hujve/9CQL6gRCeEkxPLPS4ZrK9AQ==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index b07d9f2a9e6..fd56093210f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "@electron/rebuild": "4.0.1", "@eslint/compat": "2.0.0", "@lit-labs/signals": "0.1.2", - "@ngtools/webpack": "20.3.11", + "@ngtools/webpack": "20.3.12", "@storybook/addon-a11y": "9.1.16", "@storybook/addon-designs": "9.0.0-next.3", "@storybook/addon-docs": "9.1.16", From 7cba6f417007be85d9a4645808b1fbbc78be86c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Anders=20=C3=85berg?= Date: Fri, 5 Dec 2025 18:58:20 +0100 Subject: [PATCH 005/136] PM-8353 MacOS passkey provider (#13963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Turn on passkeys and dev mode * PM-19138: Add try-catch to desktop-autofill (#13964) * PM-19424: React to IPC disconnect (#14123) * React to IPC disconnects * Minor cleanup * Update apps/desktop/package.json Co-authored-by: Daniel García * Relaxed ordering --------- Co-authored-by: Daniel García * Autofill/pm 9034 implement passkey for unlocked accounts (#13826) * Passkey stuff Co-authored-by: Anders Åberg * Ugly hacks * Work On Modal State Management * Applying modalStyles * modal * Improved hide/show * fixed promise * File name * fix prettier * Protecting against null API's and undefined data * Only show fake popup to devs * cleanup mock code * rename minmimal-app to modal-app * Added comment * Added comment * removed old comment * Avoided changing minimum size * Add small comment * Rename component * adress feedback * Fixed uppercase file * Fixed build * Added codeowners * added void * commentary * feat: reset setting on app start * Moved reset to be in main / process launch * Add comment to create window * Added a little bit of styling * Use Messaging service to loadUrl * Enable passkeysautofill * Add logging * halfbaked * Integration working * And now it works without extra delay * Clean up * add note about messaging * lb * removed console.logs * Cleanup and adress review feedback * This hides the swift UI * add modal components * update modal with correct ciphers and functionality * add create screen * pick credential, draft * Remove logger * a whole lot of wiring * not working * Improved wiring * Cancel after 90s * Introduced observable * update cipher handling * update to use matchesUri * Launching bitwarden if its not running * Passing position from native to electron * Rename inModalMode to modalMode * remove tap * revert spaces * added back isDev * cleaned up a bit * Cleanup swift file * tweaked logging * clean up * Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift Co-authored-by: Andreas Coroiu * Update apps/desktop/src/platform/main/autofill/native-autofill.main.ts Co-authored-by: Andreas Coroiu * Update apps/desktop/src/platform/services/desktop-settings.service.ts Co-authored-by: Andreas Coroiu * adress position feedback * Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift Co-authored-by: Andreas Coroiu * Removed extra logging * Adjusted error logging * Use .error to log errors * remove dead code * Update desktop-autofill.service.ts * use parseCredentialId instead of guidToRawFormat * Update apps/desktop/src/autofill/services/desktop-autofill.service.ts Co-authored-by: Andreas Coroiu * Change windowXy to a Record instead of [number,number] * Update apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts Co-authored-by: Andreas Coroiu * Remove unsued dep and comment * changed timeout to be spec recommended maxium, 10 minutes, for now. * Correctly assume UP * Removed extra cancelRequest in deinint * Add timeout and UV to confirmChoseCipher UV is performed by UI, not the service * Improved docs regarding undefined cipherId * cleanup: UP is no longer undefined * Run completeError if ipc messages conversion failed * don't throw, instead return undefined * Disabled passkey provider * Throw error if no activeUserId was found * removed comment * Fixed lint * removed unsued service * reset entitlement formatting * Update entitlements.mas.plist * Fix build issues * Fix import issues * Update route names to use `fido2` * Fix being unable to select a passkey * Fix linting issues * Followup to fix merge issues and other comments * Update `userHandle` value * Add error handling for missing session or other errors * Remove unused route * Fix linting issues * Simplify updateCredential method * Followup to remove comments and timeouts and handle errors * Address lint issue by using `takeUntilDestroyed` * PR Followup for typescript and vault concerns * Add try block for cipher creation * Make userId manditory for cipher service --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: Anders Åberg Co-authored-by: Anders Åberg Co-authored-by: Colton Hurst Co-authored-by: Andreas Coroiu Co-authored-by: Evan Bassler Co-authored-by: Andreas Coroiu * PM-11455: Trigger sync when user enables OS setting (#14127) * Implemented a SendNativeStatus command This allows reporting status or asking the electron app to do something. * fmt * Update apps/desktop/src/autofill/services/desktop-autofill.service.ts Co-authored-by: Daniel García * clean up * Don't add empty callbacks * Removed comment --------- Co-authored-by: Daniel García * Added support for handling a locked vault Handle unlocktimeout * PM-19511: Add support for ExcludedCredentials (#14128) * works * Add mapping * remove the build script * cleanup * simplify updatedCipher (#14179) * Fix base64url decode on MacOS passkeys (#14227) * Add support for padding in base64url decode * whitespace * whitespace * Autofill/pm 17444 use reprompt (#14004) * Passkey stuff Co-authored-by: Anders Åberg * Ugly hacks * Work On Modal State Management * Applying modalStyles * modal * Improved hide/show * fixed promise * File name * fix prettier * Protecting against null API's and undefined data * Only show fake popup to devs * cleanup mock code * rename minmimal-app to modal-app * Added comment * Added comment * removed old comment * Avoided changing minimum size * Add small comment * Rename component * adress feedback * Fixed uppercase file * Fixed build * Added codeowners * added void * commentary * feat: reset setting on app start * Moved reset to be in main / process launch * Add comment to create window * Added a little bit of styling * Use Messaging service to loadUrl * Enable passkeysautofill * Add logging * halfbaked * Integration working * And now it works without extra delay * Clean up * add note about messaging * lb * removed console.logs * Cleanup and adress review feedback * This hides the swift UI * add modal components * update modal with correct ciphers and functionality * add create screen * pick credential, draft * Remove logger * a whole lot of wiring * not working * Improved wiring * Cancel after 90s * Introduced observable * update cipher handling * update to use matchesUri * Launching bitwarden if its not running * Passing position from native to electron * Rename inModalMode to modalMode * remove tap * revert spaces * added back isDev * cleaned up a bit * Cleanup swift file * tweaked logging * clean up * Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift Co-authored-by: Andreas Coroiu * Update apps/desktop/src/platform/main/autofill/native-autofill.main.ts Co-authored-by: Andreas Coroiu * Update apps/desktop/src/platform/services/desktop-settings.service.ts Co-authored-by: Andreas Coroiu * adress position feedback * Update apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift Co-authored-by: Andreas Coroiu * Removed extra logging * Adjusted error logging * Use .error to log errors * remove dead code * Update desktop-autofill.service.ts * use parseCredentialId instead of guidToRawFormat * Update apps/desktop/src/autofill/services/desktop-autofill.service.ts Co-authored-by: Andreas Coroiu * Change windowXy to a Record instead of [number,number] * Update apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts Co-authored-by: Andreas Coroiu * Remove unsued dep and comment * changed timeout to be spec recommended maxium, 10 minutes, for now. * Correctly assume UP * Removed extra cancelRequest in deinint * Add timeout and UV to confirmChoseCipher UV is performed by UI, not the service * Improved docs regarding undefined cipherId * cleanup: UP is no longer undefined * Run completeError if ipc messages conversion failed * don't throw, instead return undefined * Disabled passkey provider * Throw error if no activeUserId was found * removed comment * Fixed lint * removed unsued service * reset entitlement formatting * Update entitlements.mas.plist * Fix build issues * Fix import issues * Update route names to use `fido2` * Fix being unable to select a passkey * Fix linting issues * Added support for handling a locked vault * Followup to fix merge issues and other comments * Update `userHandle` value * Add error handling for missing session or other errors * Remove unused route * Fix linting issues * Simplify updateCredential method * Add master password reprompt on passkey create * Followup to remove comments and timeouts and handle errors * Address lint issue by using `takeUntilDestroyed` * Add MP prompt to cipher selection * Change how timeout is handled * Include `of` from rxjs * Hide blue header for passkey popouts (#14095) * Hide blue header for passkey popouts * Fix issue with test * Fix ngOnDestroy complaint * Import OnDestroy correctly * Only require master password if item requires it --------- Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: Anders Åberg Co-authored-by: Anders Åberg Co-authored-by: Colton Hurst Co-authored-by: Andreas Coroiu Co-authored-by: Evan Bassler Co-authored-by: Andreas Coroiu * Change modal size to 600x600 * Improve MacOS Syncing This changes the behaviour to react to logoff, but not to account locks. It also adds better error handling on the native side. * Improved modalPosition by allowing multiple calls to applyModalStyles * moved imports to please lint * Make passkey header stick for select and create (#14357) * Added local build command * Exclude credentials using kvc to avoid comilation error in cicd (#14568) * Fix syntax error * Don't use kvc * Enables the autofill extension in mac and mas builds (#14373) * Enables autofill extension building * Try use macos-14 * add --break-system-packages for macos14 * revert using build-native * try add rustup target add x86_64-apple-darwin * add more rustup target add x86_64-apple-darwin * try to force sdk version * Show SDK versions * USE KVC for excludedCredentials * added xcodebuild deugging * Revert "try to force sdk version" This reverts commit d94f2550adf69d944b49273221dbcdcf1a53e416. * Use macos-15 * undo merge * remove macos-15 from cli * remove macos-15 from browser --------- Co-authored-by: Anders Åberg * Improve Autofill IPC reliability (#14358) * Delay IPC server start * Better ipc handling * Rename ready() to listenerReady() --------- Co-authored-by: Daniel García * feat: add test and check for too long buffers (#14775) * Autofill/PM-19511: Overwrite and reprompt (#14288) * Show items for url that don't have passkey * Show existing login items in the UI * Filter available cipher results (#14399) * Filter available cipher results * Fix linting issues * Update logic for eligible ciphers * Remove unused method to check matching username * PM-20608 update styling for excludedCredentials (#14444) * PM-20608 update styling for excludedCredentials * Have flow correctly move to creation for excluded cipher * Remove duplicate confirmNeCredential call * Revert fido2-authenticator changes and move the excluded check * Create a separate component for excluded cipher view * Display traffic light MacOS buttons when the vault is locked (#14673) * Remove unneccessary filter for excludedCiphers * Remove dead code from the excluded ciphers work * Remove excludedCipher checks from fido2 create and vault * Remove excludedCipher remnants from vault and simplify create cipher logic * Move cipherHasNoOtherPasskeys to shared fido2-utils * Remove all containsExcludedCipher references * Use `bufferToString` to convert `userHandle` --------- Co-authored-by: Jeffrey Holland Co-authored-by: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com> * Move modal files to `autofill` and rename dir to `credentials` (#14757) * Show existing login items in the UI * Filter available cipher results (#14399) * Filter available cipher results * Fix linting issues * Update logic for eligible ciphers * Remove unused method to check matching username * PM-20608 update styling for excludedCredentials (#14444) * PM-20608 update styling for excludedCredentials * Have flow correctly move to creation for excluded cipher * Remove duplicate confirmNeCredential call * Revert fido2-authenticator changes and move the excluded check * Create a separate component for excluded cipher view * Display traffic light MacOS buttons when the vault is locked (#14673) * Remove unneccessary filter for excludedCiphers * Remove dead code from the excluded ciphers work * Remove excludedCipher checks from fido2 create and vault * Move modal files to `autofill` and rename dir to `credentials` * Update merge issues * Add tests for `cipherHasNoOtherPasskeys` (#14829) * Adjust spacing to place new login button below other items (#14877) * Adjust spacing to place new login button below other items * Add correct design when no credentials available (#14879) * Autofill/pm 21903 use translations everywhere for passkeys (#14908) * Adjust spacing to place new login button below other items * Add correct design when no credentials available * Add correct design when no credentials available (#14879) * Remove hardcoded strings and use translations in passkey flow * Remove duplicate `select` translation * Autofill/pm 21864 center unlock vault modal (#14867) * Center the Locked Vault modal when using passkeys * Revert swift changes and handle offscreen modals * Remove comments * Add rustup for cicd to work (#15055) * Hide credentials that are in the bin (#15034) * Add tests for passkey components (#15185) * Add tests for passkey components * Reuse cipher in chooseCipher tests and simplify mock creation * Autofill/pm 22821 center vault modal (#15243) * Center the vault modal for passkeys * Add comments and fix electron-builder.json * Set values to Int32 in the ternaries * Refactor Fido2 Components (#15105) * Refactor Fido2 Components * Address error message and missing session * Address remaining missing session * Reset modals so subsequent creates work (#15145) * Fix broken test * Rename relevantCiphers to displayedCiphers * Clean up heading settings, errors, and other concerns * Address missing comments and throw error in try block * fix type issue for SimpleDialogType * fix type issue for SimpleDialogType * Revert new type * try using as null to satisfy type issue * Remove use of firstValueFrom in create component * PM-22476: Show config UI while enabling Bitwarden (#15149) * Show config ui while enabling Bitwarden * locals * Added Localizable strings * Changed the linebreakmode * Removed swedish locals * Add provisioning profile values to electron build (#15412) * Address BitwardenShield icon issue * Fix fido2-vault component * Display the vault modal when selecting Bitwarden... (#15257) * Passkeys filtering breaks on SSH keys (#15448) * Display the blue header on the locked vault passkey flow (#15655) * PM-23848: Use the MacOS UI-friendly API instead (#15650) * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Fix action text and close vault modal (#15634) * Fix action text and close vault modal * Fix broken tests * Update SVG to support dark mode (#15805) * When a locked vault is unlocked displays correctly (#15612) * When a locked vault is unlocked displays correctly * Keep old behavior while checking for recently unlocked vault * Revert the electron-builder * Simplify by using a simple redirect when vault unlocked * Remove single use of `userSelectedCipher` * Add a guard clause to unlock * Revert to original spacing * Add reactive guard to unlock vault * Fix for passkey picker closing prematurely * Remove unneeded root navigation in ensureUnlockedVault * Fix vault not unlocking * Update broken tests for lock component * Add missing brace to preload.ts * Run lint * Added explainer * Moved the explainer * Tidying up readme * Add feature flag to short-circuit the passkey provider (#16003) * Add feature flag to short-circuit the passkey provider * Check FF in renderer instead * Lint fixes * PM-22175: Improve launch of app + window positioning (#15658) * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Implement prepareInterfaceToProvideCredential * Fix launch of app + window pos * Wait for animation to complete and use proper position * Wait for animation to complete and use proper position * Added commentary * Remove console.log * Remove call to removed function --------- Co-authored-by: Jeffrey Holland Co-authored-by: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com> * Update fido2-vault and fido2-service implementations * Use tailwind-alike classes for new styles * Add label to biticons in passkey modals * Fix broken vault test * Revert to original `isDev` function * Add comment to lock component describing `disable-redirect` param * Use tailwind classes instead of custom sticky header class * Use standard `tw-z-10` for z-index * Change log service levels * Mock svg icons for CI * Add back provisioning profiles * Remove `--break-system-packages` and simplify commands * Revert `cipherId` param for `confirmNewCredential` * Remove placeholder UI * Small improvements to the readme * Remove optional userId and deprecated method * Autofill should own the macos_provider (#16271) * Autofill should own the macos_provider * Autofill should own the macos_provider * Remove unnecessary logs, no magic numbers, revert `cipherId?` * Fixes for broken build * Update test issues * [BEEEP] Use tracing in macOS provider * Update comments and add null check for ciphers * Update status comments and readme * Remove electron modal mode link * Clarify modal mode use * Add comment about usernames * Add comment that we don't support extensions yet * Added comment about base64 format * Use NO_CALLBACK_INDICATOR * cb -> callback * Update apps/desktop/desktop_native/napi/src/lib.rs Co-authored-by: neuronull <9162534+neuronull@users.noreply.github.com> * Clean up Fido2Create subscriptions and update comments * added comment to clarify silent exception * Add comments * clean up unwrap() * set log level filter to INFO * Address modal popup issue * plutil on Info.plist * Adhere to style guides * Fix broken lock ui component tests * Fix broken lock ui component tests * Added codeowners entry * logservice.warning -> debug * Uint8Array -> ArrayBuffer * Remove autofill entitlement * Fix linting issues * Fix arm build issue * Adjust build command * Add missing entitlement * revert missing entitlement change * Add proper autofill entitlements * Remove autofill extension from mas builds * Run rust formatter --------- Co-authored-by: Daniel García Co-authored-by: Jeffrey Holland <124393578+jholland-livefront@users.noreply.github.com> Co-authored-by: Justin Baur <19896123+justindbaur@users.noreply.github.com> Co-authored-by: Colton Hurst Co-authored-by: Andreas Coroiu Co-authored-by: Evan Bassler Co-authored-by: Andreas Coroiu Co-authored-by: Nathan Ansel Co-authored-by: Jeffrey Holland Co-authored-by: Robyn MacCallum Co-authored-by: neuronull <9162534+neuronull@users.noreply.github.com> --- .github/CODEOWNERS | 2 + .github/workflows/build-desktop.yml | 9 +- .../autofill/popup/fido2/fido2.component.ts | 15 +- .../desktop_native/macos_provider/README.md | 35 ++ .../desktop_native/macos_provider/build.sh | 3 + .../desktop_native/macos_provider/src/lib.rs | 85 +++- .../macos_provider/src/registration.rs | 1 + apps/desktop/desktop_native/napi/index.d.ts | 7 +- apps/desktop/desktop_native/napi/src/lib.rs | 31 ++ .../objc/src/native/autofill/commands/sync.m | 82 ++-- .../desktop_native/objc/src/native/utils.m | 19 +- .../CredentialProviderViewController.xib | 87 ++-- .../CredentialProviderViewController.swift | 376 ++++++++++++------ .../macos/autofill-extension/Info.plist | 4 +- .../autofill_extension.entitlements | 8 +- .../autofill-extension/bitwarden-icon.png | Bin 0 -> 3291 bytes .../en.lproj/Localizable.strings | 2 + .../macos/desktop.xcodeproj/project.pbxproj | 25 ++ apps/desktop/package.json | 13 +- apps/desktop/resources/entitlements.mac.plist | 2 - .../resources/entitlements.mas.inherit.plist | 4 +- apps/desktop/resources/entitlements.mas.plist | 10 +- apps/desktop/scripts/after-sign.js | 2 +- apps/desktop/src/app/app-routing.module.ts | 19 +- apps/desktop/src/app/app.component.ts | 3 +- .../components/fido2placeholder.component.ts | 122 ------ .../src/app/services/services.module.ts | 1 + .../autofill/guards/reactive-vault-guard.ts | 42 ++ .../credentials/fido2-create.component.html | 66 +++ .../fido2-create.component.spec.ts | 238 +++++++++++ .../credentials/fido2-create.component.ts | 219 ++++++++++ .../fido2-excluded-ciphers.component.html | 44 ++ .../fido2-excluded-ciphers.component.spec.ts | 78 ++++ .../fido2-excluded-ciphers.component.ts | 78 ++++ .../credentials/fido2-vault.component.html | 37 ++ .../credentials/fido2-vault.component.spec.ts | 196 +++++++++ .../credentials/fido2-vault.component.ts | 161 ++++++++ apps/desktop/src/autofill/preload.ts | 21 + .../services/desktop-autofill.service.ts | 281 ++++++++----- .../desktop-fido2-user-interface.service.ts | 159 ++++++-- apps/desktop/src/locales/en/messages.json | 77 +++- apps/desktop/src/main/tray.main.ts | 11 +- apps/desktop/src/main/window.main.ts | 6 +- .../main/autofill/native-autofill.main.ts | 69 +++- .../platform/models/domain/window-state.ts | 1 + .../src/platform/popup-modal-styles.ts | 14 +- .../services/desktop-settings.service.ts | 7 +- eslint.config.mjs | 3 +- libs/common/spec/fake-account-service.ts | 7 + .../src/auth/abstractions/account.service.ts | 7 + .../src/auth/services/account.service.spec.ts | 10 + .../src/auth/services/account.service.ts | 7 + ...fido2-authenticator.service.abstraction.ts | 2 +- ...ido2-user-interface.service.abstraction.ts | 2 +- .../services/fido2/fido2-utils.spec.ts | 64 +++ .../platform/services/fido2/fido2-utils.ts | 14 + .../services/fido2/guid-utils.spec.ts | 73 +++- .../src/platform/services/fido2/guid-utils.ts | 4 + .../src/vault/abstractions/cipher.service.ts | 1 + .../src/vault/services/cipher.service.ts | 9 + libs/components/src/icon-button/index.ts | 2 +- libs/components/src/tw-theme.css | 12 + .../lock/components/lock.component.spec.ts | 72 +++- .../src/lock/components/lock.component.ts | 11 +- 64 files changed, 2519 insertions(+), 553 deletions(-) create mode 100644 apps/desktop/desktop_native/macos_provider/README.md create mode 100644 apps/desktop/macos/autofill-extension/bitwarden-icon.png create mode 100644 apps/desktop/macos/autofill-extension/en.lproj/Localizable.strings delete mode 100644 apps/desktop/src/app/components/fido2placeholder.component.ts create mode 100644 apps/desktop/src/autofill/guards/reactive-vault-guard.ts create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-create.component.html create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts create mode 100644 apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ed7fcac96e6..89fff27b217 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -8,7 +8,9 @@ apps/desktop/desktop_native @bitwarden/team-platform-dev apps/desktop/desktop_native/objc/src/native/autofill @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/autofill @bitwarden/team-autofill-desktop-dev +apps/desktop/desktop_native/macos_provider @bitwarden/team-autofill-desktop-dev apps/desktop/desktop_native/core/src/secure_memory @bitwarden/team-key-management-dev + ## No ownership for Cargo.lock and Cargo.toml to allow dependency updates apps/desktop/desktop_native/Cargo.lock apps/desktop/desktop_native/Cargo.toml diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index ab5a1a50c17..5086cd75ab7 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1022,7 +1022,7 @@ jobs: python-version: '3.14' - name: Set up Node-gyp - run: python3 -m pip install setuptools + run: python -m pip install setuptools - name: Cache Rust dependencies uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 @@ -1038,6 +1038,7 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - name: Cache Build id: build-cache @@ -1259,7 +1260,7 @@ jobs: python-version: '3.14' - name: Set up Node-gyp - run: python3 -m pip install setuptools + run: python -m pip install setuptools - name: Cache Rust dependencies uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 @@ -1275,6 +1276,7 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - name: Get Build Cache id: build-cache @@ -1531,7 +1533,7 @@ jobs: python-version: '3.14' - name: Set up Node-gyp - run: python3 -m pip install setuptools + run: python -m pip install setuptools - name: Cache Rust dependencies uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2 @@ -1547,6 +1549,7 @@ jobs: rustup show echo "GitHub ref: $GITHUB_REF" echo "GitHub event: $GITHUB_EVENT" + xcodebuild -showsdks - name: Get Build Cache id: build-cache diff --git a/apps/browser/src/autofill/popup/fido2/fido2.component.ts b/apps/browser/src/autofill/popup/fido2/fido2.component.ts index c6799f93a5e..c1982d27d24 100644 --- a/apps/browser/src/autofill/popup/fido2/fido2.component.ts +++ b/apps/browser/src/autofill/popup/fido2/fido2.component.ts @@ -24,6 +24,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; import { SecureNoteType, CipherType } from "@bitwarden/common/vault/enums"; @@ -198,7 +199,7 @@ export class Fido2Component implements OnInit, OnDestroy { this.displayedCiphers = this.ciphers.filter( (cipher) => cipher.login.matchesUri(this.url, equivalentDomains) && - this.cipherHasNoOtherPasskeys(cipher, message.userHandle), + Fido2Utils.cipherHasNoOtherPasskeys(cipher, message.userHandle), ); this.passkeyAction = PasskeyActions.Register; @@ -472,16 +473,4 @@ export class Fido2Component implements OnInit, OnDestroy { ...msg, }); } - - /** - * This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle - * @param userHandle - */ - private cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean { - if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) { - return true; - } - - return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle); - } } diff --git a/apps/desktop/desktop_native/macos_provider/README.md b/apps/desktop/desktop_native/macos_provider/README.md new file mode 100644 index 00000000000..1d4c1902465 --- /dev/null +++ b/apps/desktop/desktop_native/macos_provider/README.md @@ -0,0 +1,35 @@ +# Explainer: Mac OS Native Passkey Provider + +This document describes the changes introduced in https://github.com/bitwarden/clients/pull/13963, where we introduce the MacOS Native Passkey Provider. It gives the high level explanation of the architecture and some of the quirks and additional good to know context. + +## The high level +MacOS has native APIs (similar to iOS) to allow Credential Managers to provide credentials to the MacOS autofill system (in the PR referenced above, we only provide passkeys). + +We’ve written a Swift-based native autofill-extension. It’s bundled in the app-bundle in PlugIns, similar to the safari-extension. + +This swift extension currently communicates with our Electron app through IPC based on a unix socket. The IPC implementation is done in Rust and utilized through UniFFI + NAPI bindings. + +Footnotes: + +* We're not using the IPC framework as the implementation pre-dates the IPC framework. +* Alternatives like XPC or CFMessagePort may have better support for when the app is sandboxed. + +Electron receives the messages and passes it to Angular (through the electron-renderer event system). + +Our existing fido2 services in the renderer respond to events, displaying UI as necessary, and returns the signature back through the same mechanism, allowing people to authenticate with passkeys through the native system + UI. See [Mac OS Native Passkey Workflows](https://bitwarden.atlassian.net/wiki/spaces/EN/pages/1828356098/Mac+OS+Native+Passkey+Workflows) for demo videos. + +## Typescript + UI implementations + +We utilize the same FIDO2 implementation and interface that is already present for our browser authentication. It was designed by @coroiu with multiple ‘ui environments' in mind. + +Therefore, a lot of the plumbing is implemented in /autofill/services/desktop-fido2-user-interface.service.ts, which implements the interface that our fido2 authenticator/client expects to drive UI related behaviors. + +We’ve also implemented a couple FIDO2 UI components to handle registration/sign in flows, but also improved the “modal mode” of the desktop app. + +## Modal mode + +When modal mode is activated, the desktop app turns into a smaller modal that is always on top and cannot be resized. This is done to improve the UX of performing a passkey operation (or SSH operation). Once the operation is completed, the app returns to normal mode and its previous position. + +We are not using electron modal windows, for a couple reason. It would require us to send data in yet another layer of IPC, but also because we'd need to bootstrap entire renderer/app instead of reusing the existing window. + +Some modal modes may hide the 'traffic buttons' (window controls) due to design requirements. diff --git a/apps/desktop/desktop_native/macos_provider/build.sh b/apps/desktop/desktop_native/macos_provider/build.sh index 21e2e045af4..2f7a2d03541 100755 --- a/apps/desktop/desktop_native/macos_provider/build.sh +++ b/apps/desktop/desktop_native/macos_provider/build.sh @@ -8,6 +8,9 @@ rm -r tmp mkdir -p ./tmp/target/universal-darwin/release/ +rustup target add aarch64-apple-darwin +rustup target add x86_64-apple-darwin + cargo build --package macos_provider --target aarch64-apple-darwin --release cargo build --package macos_provider --target x86_64-apple-darwin --release diff --git a/apps/desktop/desktop_native/macos_provider/src/lib.rs b/apps/desktop/desktop_native/macos_provider/src/lib.rs index a5a134b0bfe..8619a77a0f2 100644 --- a/apps/desktop/desktop_native/macos_provider/src/lib.rs +++ b/apps/desktop/desktop_native/macos_provider/src/lib.rs @@ -57,6 +57,14 @@ trait Callback: Send + Sync { fn error(&self, error: BitwardenError); } +#[derive(uniffi::Enum, Debug)] +/// Store the connection status between the macOS credential provider extension +/// and the desktop application's IPC server. +pub enum ConnectionStatus { + Connected, + Disconnected, +} + #[derive(uniffi::Object)] pub struct MacOSProviderClient { to_server_send: tokio::sync::mpsc::Sender, @@ -65,8 +73,24 @@ pub struct MacOSProviderClient { response_callbacks_counter: AtomicU32, #[allow(clippy::type_complexity)] response_callbacks_queue: Arc, Instant)>>>, + + // Flag to track connection status - atomic for thread safety without locks + connection_status: Arc, } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +/// Store native desktop status information to use for IPC communication +/// between the application and the macOS credential provider. +pub struct NativeStatus { + key: String, + value: String, +} + +// In our callback management, 0 is a reserved sequence number indicating that a message does not +// have a callback. +const NO_CALLBACK_INDICATOR: u32 = 0; + #[uniffi::export] impl MacOSProviderClient { // FIXME: Remove unwraps! They panic and terminate the whole application. @@ -93,13 +117,16 @@ impl MacOSProviderClient { let client = MacOSProviderClient { to_server_send, - response_callbacks_counter: AtomicU32::new(0), + response_callbacks_counter: AtomicU32::new(1), /* Start at 1 since 0 is reserved for + * "no callback" scenarios */ response_callbacks_queue: Arc::new(Mutex::new(HashMap::new())), + connection_status: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; let path = desktop_core::ipc::path("af"); let queue = client.response_callbacks_queue.clone(); + let connection_status = client.connection_status.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Builder::new_current_thread() @@ -117,9 +144,11 @@ impl MacOSProviderClient { match serde_json::from_str::(&message) { Ok(SerializedMessage::Command(CommandMessage::Connected)) => { info!("Connected to server"); + connection_status.store(true, std::sync::atomic::Ordering::Relaxed); } Ok(SerializedMessage::Command(CommandMessage::Disconnected)) => { info!("Disconnected from server"); + connection_status.store(false, std::sync::atomic::Ordering::Relaxed); } Ok(SerializedMessage::Message { sequence_number, @@ -157,12 +186,17 @@ impl MacOSProviderClient { client } + pub fn send_native_status(&self, key: String, value: String) { + let status = NativeStatus { key, value }; + self.send_message(status, None); + } + pub fn prepare_passkey_registration( &self, request: PasskeyRegistrationRequest, callback: Arc, ) { - self.send_message(request, Box::new(callback)); + self.send_message(request, Some(Box::new(callback))); } pub fn prepare_passkey_assertion( @@ -170,7 +204,7 @@ impl MacOSProviderClient { request: PasskeyAssertionRequest, callback: Arc, ) { - self.send_message(request, Box::new(callback)); + self.send_message(request, Some(Box::new(callback))); } pub fn prepare_passkey_assertion_without_user_interface( @@ -178,7 +212,18 @@ impl MacOSProviderClient { request: PasskeyAssertionWithoutUserInterfaceRequest, callback: Arc, ) { - self.send_message(request, Box::new(callback)); + self.send_message(request, Some(Box::new(callback))); + } + + pub fn get_connection_status(&self) -> ConnectionStatus { + let is_connected = self + .connection_status + .load(std::sync::atomic::Ordering::Relaxed); + if is_connected { + ConnectionStatus::Connected + } else { + ConnectionStatus::Disconnected + } } } @@ -200,7 +245,6 @@ enum SerializedMessage { } impl MacOSProviderClient { - // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] fn add_callback(&self, callback: Box) -> u32 { let sequence_number = self @@ -209,20 +253,23 @@ impl MacOSProviderClient { self.response_callbacks_queue .lock() - .unwrap() + .expect("response callbacks queue mutex should not be poisoned") .insert(sequence_number, (callback, Instant::now())); sequence_number } - // FIXME: Remove unwraps! They panic and terminate the whole application. #[allow(clippy::unwrap_used)] fn send_message( &self, message: impl Serialize + DeserializeOwned, - callback: Box, + callback: Option>, ) { - let sequence_number = self.add_callback(callback); + let sequence_number = if let Some(callback) = callback { + self.add_callback(callback) + } else { + NO_CALLBACK_INDICATOR + }; let message = serde_json::to_string(&SerializedMessage::Message { sequence_number, @@ -232,15 +279,17 @@ impl MacOSProviderClient { if let Err(e) = self.to_server_send.blocking_send(message) { // Make sure we remove the callback from the queue if we can't send the message - if let Some((cb, _)) = self - .response_callbacks_queue - .lock() - .unwrap() - .remove(&sequence_number) - { - cb.error(BitwardenError::Internal(format!( - "Error sending message: {e}" - ))); + if sequence_number != NO_CALLBACK_INDICATOR { + if let Some((callback, _)) = self + .response_callbacks_queue + .lock() + .expect("response callbacks queue mutex should not be poisoned") + .remove(&sequence_number) + { + callback.error(BitwardenError::Internal(format!( + "Error sending message: {e}" + ))); + } } } } diff --git a/apps/desktop/desktop_native/macos_provider/src/registration.rs b/apps/desktop/desktop_native/macos_provider/src/registration.rs index 9e697b75c16..c961566a86c 100644 --- a/apps/desktop/desktop_native/macos_provider/src/registration.rs +++ b/apps/desktop/desktop_native/macos_provider/src/registration.rs @@ -14,6 +14,7 @@ pub struct PasskeyRegistrationRequest { user_verification: UserVerification, supported_algorithms: Vec, window_xy: Position, + excluded_credentials: Vec>, } #[derive(uniffi::Record, Serialize, Deserialize)] diff --git a/apps/desktop/desktop_native/napi/index.d.ts b/apps/desktop/desktop_native/napi/index.d.ts index 01bfa65d571..0db29c9a05d 100644 --- a/apps/desktop/desktop_native/napi/index.d.ts +++ b/apps/desktop/desktop_native/napi/index.d.ts @@ -164,6 +164,7 @@ export declare namespace autofill { userVerification: UserVerification supportedAlgorithms: Array windowXy: Position + excludedCredentials: Array> } export interface PasskeyRegistrationResponse { rpId: string @@ -188,6 +189,10 @@ export declare namespace autofill { userVerification: UserVerification windowXy: Position } + export interface NativeStatus { + key: string + value: string + } export interface PasskeyAssertionResponse { rpId: string userHandle: Array @@ -204,7 +209,7 @@ export declare namespace autofill { * connection and must be the same for both the server and client. @param callback * This function will be called whenever a message is received from a client. */ - static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void): Promise + static listen(name: string, registrationCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyRegistrationRequest) => void, assertionCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionRequest) => void, assertionWithoutUserInterfaceCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: PasskeyAssertionWithoutUserInterfaceRequest) => void, nativeStatusCallback: (error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void): Promise /** Return the path to the IPC server. */ getPath(): string /** Stop the IPC server. */ diff --git a/apps/desktop/desktop_native/napi/src/lib.rs b/apps/desktop/desktop_native/napi/src/lib.rs index b5dcb277a75..7f63001c221 100644 --- a/apps/desktop/desktop_native/napi/src/lib.rs +++ b/apps/desktop/desktop_native/napi/src/lib.rs @@ -686,6 +686,7 @@ pub mod autofill { pub user_verification: UserVerification, pub supported_algorithms: Vec, pub window_xy: Position, + pub excluded_credentials: Vec>, } #[napi(object)] @@ -724,6 +725,14 @@ pub mod autofill { pub window_xy: Position, } + #[napi(object)] + #[derive(Debug, Serialize, Deserialize)] + #[serde(rename_all = "camelCase")] + pub struct NativeStatus { + pub key: String, + pub value: String, + } + #[napi(object)] #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -777,6 +786,13 @@ pub mod autofill { (u32, u32, PasskeyAssertionWithoutUserInterfaceRequest), ErrorStrategy::CalleeHandled, >, + #[napi( + ts_arg_type = "(error: null | Error, clientId: number, sequenceNumber: number, message: NativeStatus) => void" + )] + native_status_callback: ThreadsafeFunction< + (u32, u32, NativeStatus), + ErrorStrategy::CalleeHandled, + >, ) -> napi::Result { let (send, mut recv) = tokio::sync::mpsc::channel::(32); tokio::spawn(async move { @@ -849,6 +865,21 @@ pub mod autofill { } } + match serde_json::from_str::>(&message) { + Ok(msg) => { + let value = msg + .value + .map(|value| (client_id, msg.sequence_number, value)) + .map_err(|e| napi::Error::from_reason(format!("{e:?}"))); + native_status_callback + .call(value, ThreadsafeFunctionCallMode::NonBlocking); + continue; + } + Err(error) => { + error!(%error, "Unable to deserialze native status."); + } + } + error!(message, "Received an unknown message2"); } } diff --git a/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m b/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m index fc13c04591a..037a97c7590 100644 --- a/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m +++ b/apps/desktop/desktop_native/objc/src/native/autofill/commands/sync.m @@ -14,40 +14,64 @@ void runSync(void* context, NSDictionary *params) { // Map credentials to ASPasswordCredential objects NSMutableArray *mappedCredentials = [NSMutableArray arrayWithCapacity:credentials.count]; + for (NSDictionary *credential in credentials) { - NSString *type = credential[@"type"]; - - if ([type isEqualToString:@"password"]) { - NSString *cipherId = credential[@"cipherId"]; - NSString *uri = credential[@"uri"]; - NSString *username = credential[@"username"]; - - ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc] - initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL]; - ASPasswordCredentialIdentity *credential = [[ASPasswordCredentialIdentity alloc] - initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId]; - - [mappedCredentials addObject:credential]; - } - - if (@available(macos 14, *)) { - if ([type isEqualToString:@"fido2"]) { + @try { + NSString *type = credential[@"type"]; + + if ([type isEqualToString:@"password"]) { NSString *cipherId = credential[@"cipherId"]; - NSString *rpId = credential[@"rpId"]; - NSString *userName = credential[@"userName"]; - NSData *credentialId = decodeBase64URL(credential[@"credentialId"]); - NSData *userHandle = decodeBase64URL(credential[@"userHandle"]); + NSString *uri = credential[@"uri"]; + NSString *username = credential[@"username"]; + + // Skip credentials with null username since MacOS crashes if we send credentials with empty usernames + if ([username isKindOfClass:[NSNull class]] || username.length == 0) { + NSLog(@"Skipping credential, username is empty: %@", credential); + continue; + } - Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity"); - id credential = [[passkeyCredentialIdentityClass alloc] - initWithRelyingPartyIdentifier:rpId - userName:userName - credentialID:credentialId - userHandle:userHandle - recordIdentifier:cipherId]; + ASCredentialServiceIdentifier *serviceId = [[ASCredentialServiceIdentifier alloc] + initWithIdentifier:uri type:ASCredentialServiceIdentifierTypeURL]; + ASPasswordCredentialIdentity *passwordIdentity = [[ASPasswordCredentialIdentity alloc] + initWithServiceIdentifier:serviceId user:username recordIdentifier:cipherId]; - [mappedCredentials addObject:credential]; + [mappedCredentials addObject:passwordIdentity]; + } + else if (@available(macos 14, *)) { + // Fido2CredentialView uses `userName` (camelCase) while Login uses `username`. + // This is intentional. Fido2 fields are flattened from the FIDO2 spec's nested structure + // (user.name -> userName, rp.id -> rpId) to maintain a clear distinction between these fields. + if ([type isEqualToString:@"fido2"]) { + NSString *cipherId = credential[@"cipherId"]; + NSString *rpId = credential[@"rpId"]; + NSString *userName = credential[@"userName"]; + + // Skip credentials with null username since MacOS crashes if we send credentials with empty usernames + if ([userName isKindOfClass:[NSNull class]] || userName.length == 0) { + NSLog(@"Skipping credential, username is empty: %@", credential); + continue; + } + + NSData *credentialId = decodeBase64URL(credential[@"credentialId"]); + NSData *userHandle = decodeBase64URL(credential[@"userHandle"]); + + Class passkeyCredentialIdentityClass = NSClassFromString(@"ASPasskeyCredentialIdentity"); + id passkeyIdentity = [[passkeyCredentialIdentityClass alloc] + initWithRelyingPartyIdentifier:rpId + userName:userName + credentialID:credentialId + userHandle:userHandle + recordIdentifier:cipherId]; + + [mappedCredentials addObject:passkeyIdentity]; + } } + } @catch (NSException *exception) { + // Silently skip any credential that causes an exception + // to make sure we don't fail the entire sync + // There is likely some invalid data in the credential, and not something the user should/could be asked to correct. + NSLog(@"ERROR: Exception processing credential: %@ - %@", exception.name, exception.reason); + continue; } } diff --git a/apps/desktop/desktop_native/objc/src/native/utils.m b/apps/desktop/desktop_native/objc/src/native/utils.m index 040c723a8ac..8f9493a7afb 100644 --- a/apps/desktop/desktop_native/objc/src/native/utils.m +++ b/apps/desktop/desktop_native/objc/src/native/utils.m @@ -18,9 +18,26 @@ NSString *serializeJson(NSDictionary *dictionary, NSError *error) { } NSData *decodeBase64URL(NSString *base64URLString) { + if (base64URLString.length == 0) { + return nil; + } + + // Replace URL-safe characters with standard base64 characters NSString *base64String = [base64URLString stringByReplacingOccurrencesOfString:@"-" withString:@"+"]; base64String = [base64String stringByReplacingOccurrencesOfString:@"_" withString:@"/"]; - + + // Add padding if needed + // Base 64 strings should be a multiple of 4 in length + NSUInteger paddingLength = 4 - (base64String.length % 4); + if (paddingLength < 4) { + NSMutableString *paddedString = [NSMutableString stringWithString:base64String]; + for (NSUInteger i = 0; i < paddingLength; i++) { + [paddedString appendString:@"="]; + } + base64String = paddedString; + } + + // Decode the string NSData *nsdataFromBase64String = [[NSData alloc] initWithBase64EncodedString:base64String options:0]; diff --git a/apps/desktop/macos/autofill-extension/Base.lproj/CredentialProviderViewController.xib b/apps/desktop/macos/autofill-extension/Base.lproj/CredentialProviderViewController.xib index 1e47cc54de2..132882c6477 100644 --- a/apps/desktop/macos/autofill-extension/Base.lproj/CredentialProviderViewController.xib +++ b/apps/desktop/macos/autofill-extension/Base.lproj/CredentialProviderViewController.xib @@ -8,63 +8,56 @@ + + + + + diff --git a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift index 5568b2e75db..3de9468c8ab 100644 --- a/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift +++ b/apps/desktop/macos/autofill-extension/CredentialProviderViewController.swift @@ -11,63 +11,138 @@ import os class CredentialProviderViewController: ASCredentialProviderViewController { let logger: Logger - // There is something a bit strange about the initialization/deinitialization in this class. - // Sometimes deinit won't be called after a request has successfully finished, - // which would leave this class hanging in memory and the IPC connection open. - // - // If instead I make this a static, the deinit gets called correctly after each request. - // I think we still might want a static regardless, to be able to reuse the connection if possible. - let client: MacOsProviderClient = { - let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") + @IBOutlet weak var statusLabel: NSTextField! + @IBOutlet weak var logoImageView: NSImageView! + + // The IPC client to communicate with the Bitwarden desktop app + private var client: MacOsProviderClient? + + // Timer for checking connection status + private var connectionMonitorTimer: Timer? + private var lastConnectionStatus: ConnectionStatus = .disconnected + + // We changed the getClient method to be async, here's why: + // This is so that we can check if the app is running, and launch it, without blocking the main thread + // Blocking the main thread caused MacOS layouting to 'fail' or at least be very delayed, which caused our getWindowPositioning code to sent 0,0. + // We also properly retry the IPC connection which sometimes would take some time to be up and running, depending on CPU load, phase of jupiters moon, etc. + private func getClient() async -> MacOsProviderClient { + if let client = self.client { + return client + } + let logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") + // Check if the Electron app is running let workspace = NSWorkspace.shared let isRunning = workspace.runningApplications.contains { app in app.bundleIdentifier == "com.bitwarden.desktop" } - + if !isRunning { - logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch") - - // Try to launch the app + logger.log("[autofill-extension] Bitwarden Desktop not running, attempting to launch") + + // Launch the app and wait for it to be ready if let appURL = workspace.urlForApplication(withBundleIdentifier: "com.bitwarden.desktop") { - let semaphore = DispatchSemaphore(value: 0) - - workspace.openApplication(at: appURL, - configuration: NSWorkspace.OpenConfiguration()) { app, error in - if let error = error { - logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)") - } else if let app = app { - logger.log("[autofill-extension] Successfully launched Bitwarden Desktop") - } else { - logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: unknown error") + await withCheckedContinuation { continuation in + workspace.openApplication(at: appURL, configuration: NSWorkspace.OpenConfiguration()) { app, error in + if let error = error { + logger.error("[autofill-extension] Failed to launch Bitwarden Desktop: \(error.localizedDescription)") + } else { + logger.log("[autofill-extension] Successfully launched Bitwarden Desktop") + } + continuation.resume() } - semaphore.signal() } - - // Wait for launch completion with timeout - _ = semaphore.wait(timeout: .now() + 5.0) - - // Add a small delay to allow for initialization - Thread.sleep(forTimeInterval: 1.0) - } else { - logger.error("[autofill-extension] Could not find Bitwarden Desktop app") } - } else { - logger.log("[autofill-extension] Bitwarden Desktop is running") + } + + logger.log("[autofill-extension] Connecting to Bitwarden over IPC") + + // Retry connecting to the Bitwarden IPC with an increasing delay + let maxRetries = 20 + let delayMs = 500 + var newClient: MacOsProviderClient? + + for attempt in 1...maxRetries { + logger.log("[autofill-extension] Connection attempt \(attempt)") + + // Create a new client instance for each retry + newClient = MacOsProviderClient.connect() + try? await Task.sleep(nanoseconds: UInt64(100 * attempt + (delayMs * 1_000_000))) // Convert ms to nanoseconds + let connectionStatus = newClient!.getConnectionStatus() + + logger.log("[autofill-extension] Connection attempt \(attempt), status: \(connectionStatus == .connected ? "connected" : "disconnected")") + + if connectionStatus == .connected { + logger.log("[autofill-extension] Successfully connected to Bitwarden (attempt \(attempt))") + break + } else { + if attempt < maxRetries { + logger.log("[autofill-extension] Retrying connection") + } else { + logger.error("[autofill-extension] Failed to connect after \(maxRetries) attempts, final status: \(connectionStatus == .connected ? "connected" : "disconnected")") + } + } } - logger.log("[autofill-extension] Connecting to Bitwarden over IPC") - - return MacOsProviderClient.connect() - }() + self.client = newClient + return newClient! + } + + // Setup the connection monitoring timer + private func setupConnectionMonitoring() { + // Check connection status every 1 second + connectionMonitorTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.checkConnectionStatus() + } + + // Make sure timer runs even when UI is busy + RunLoop.current.add(connectionMonitorTimer!, forMode: .common) + + // Initial check + checkConnectionStatus() + } + + // Check the connection status by calling into Rust + // If the connection is has changed and is now disconnected, cancel the request + private func checkConnectionStatus() { + // Only check connection status if the client has been initialized. + // Initialization is done asynchronously, so we might be called before it's ready + // In that case we just skip this check and wait for the next timer tick and re-check + guard let client = self.client else { + return + } + + // Get the current connection status from Rust + let currentStatus = client.getConnectionStatus() + + // Only post notification if state changed + if currentStatus != lastConnectionStatus { + if(currentStatus == .connected) { + logger.log("[autofill-extension] Connection status changed: Connected") + } else { + logger.log("[autofill-extension] Connection status changed: Disconnected") + } + + // Save the new status + lastConnectionStatus = currentStatus + + // If we just disconnected, try to cancel the request + if currentStatus == .disconnected { + self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Bitwarden desktop app disconnected")) + } + } + } init() { logger = Logger(subsystem: "com.bitwarden.desktop.autofill-extension", category: "credential-provider") logger.log("[autofill-extension] initializing extension") - super.init(nibName: nil, bundle: nil) + super.init(nibName: "CredentialProviderViewController", bundle: nil) + + // Setup connection monitoring now that self is available + setupConnectionMonitoring() } required init?(coder: NSCoder) { @@ -76,45 +151,109 @@ class CredentialProviderViewController: ASCredentialProviderViewController { deinit { logger.log("[autofill-extension] deinitializing extension") - } - - - @IBAction func cancel(_ sender: AnyObject?) { - self.extensionContext.cancelRequest(withError: NSError(domain: ASExtensionErrorDomain, code: ASExtensionError.userCanceled.rawValue)) - } - - @IBAction func passwordSelected(_ sender: AnyObject?) { - let passwordCredential = ASPasswordCredential(user: "j_appleseed", password: "apple1234") - self.extensionContext.completeRequest(withSelectedCredential: passwordCredential, completionHandler: nil) - } - - private func getWindowPosition() -> Position { - let frame = self.view.window?.frame ?? .zero - let screenHeight = NSScreen.main?.frame.height ?? 0 - // frame.width and frame.height is always 0. Estimating works OK for now. - let estimatedWidth:CGFloat = 400; - let estimatedHeight:CGFloat = 200; - let centerX = Int32(round(frame.origin.x + estimatedWidth/2)) - let centerY = Int32(round(screenHeight - (frame.origin.y + estimatedHeight/2))) - - return Position(x: centerX, y:centerY) + // Stop the connection monitor timer + connectionMonitorTimer?.invalidate() + connectionMonitorTimer = nil } - override func loadView() { - let view = NSView() - // Hide the native window since we only need the IPC connection - view.isHidden = true - self.view = view + private func getWindowPosition() async -> Position { + let screenHeight = NSScreen.main?.frame.height ?? 1440 + + logger.log("[autofill-extension] position: Getting window position") + + // To whomever is reading this. Sorry. But MacOS couldn't give us an accurate window positioning, possibly due to animations + // So I added some retry logic, as well as a fall back to the mouse position which is likely at the sort of the right place. + // In my testing we often succed after 4-7 attempts. + // Wait for window frame to stabilize (animation to complete) + var lastFrame: CGRect = .zero + var stableCount = 0 + let requiredStableChecks = 3 + let maxAttempts = 20 + var attempts = 0 + + while stableCount < requiredStableChecks && attempts < maxAttempts { + let currentFrame: CGRect = self.view.window?.frame ?? .zero + + if currentFrame.equalTo(lastFrame) && !currentFrame.equalTo(.zero) { + stableCount += 1 + } else { + stableCount = 0 + lastFrame = currentFrame + } + + try? await Task.sleep(nanoseconds: 16_666_666) // ~60fps (16.67ms) + attempts += 1 + } + + let finalWindowFrame = self.view.window?.frame ?? .zero + logger.log("[autofill-extension] position: Final window frame: \(NSStringFromRect(finalWindowFrame))") + + // Use stabilized window frame if available, otherwise fallback to mouse position + if finalWindowFrame.origin.x != 0 || finalWindowFrame.origin.y != 0 { + let centerX = Int32(round(finalWindowFrame.origin.x)) + let centerY = Int32(round(screenHeight - finalWindowFrame.origin.y)) + logger.log("[autofill-extension] position: Using window position: x=\(centerX), y=\(centerY)") + return Position(x: centerX, y: centerY) + } else { + // Fallback to mouse position + let mouseLocation = NSEvent.mouseLocation + let mouseX = Int32(round(mouseLocation.x)) + let mouseY = Int32(round(screenHeight - mouseLocation.y)) + logger.log("[autofill-extension] position: Using mouse position fallback: x=\(mouseX), y=\(mouseY)") + return Position(x: mouseX, y: mouseY) + } } - + + override func viewDidLoad() { + super.viewDidLoad() + + // Initially hide the view + self.view.isHidden = true + } + + override func prepareInterfaceForExtensionConfiguration() { + // Show the configuration UI + self.view.isHidden = false + + // Set the localized message + statusLabel.stringValue = NSLocalizedString("autofillConfigurationMessage", comment: "Message shown when Bitwarden is enabled in system settings") + + // Send the native status request asynchronously + Task { + let client = await getClient() + client.sendNativeStatus(key: "request-sync", value: "") + } + + // Complete the configuration after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.extensionContext.completeExtensionConfigurationRequest() + } + } + + /* + In order to implement this method, we need to query the state of the vault to be unlocked and have one and only one matching credential so that it doesn't need to show ui. + If we do show UI, it's going to fail and disconnect after the platform timeout which is 3s. + For now we just claim to always need UI displayed. + */ override func provideCredentialWithoutUserInteraction(for credentialRequest: any ASCredentialRequest) { + let error = ASExtensionError(.userInteractionRequired) + self.extensionContext.cancelRequest(withError: error) + return + } + + /* + Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with + ASExtensionError.userInteractionRequired. In this case, the system may present your extension's + UI and call this method. Show appropriate UI for authenticating the user then provide the password + by completing the extension request with the associated ASPasswordCredential. + */ + override func prepareInterfaceToProvideCredential(for credentialRequest: ASCredentialRequest) { let timeoutTimer = createTimer() - if let request = credentialRequest as? ASPasskeyCredentialRequest { if let passkeyIdentity = request.credentialIdentity as? ASPasskeyCredentialIdentity { - - logger.log("[autofill-extension] provideCredentialWithoutUserInteraction2(passkey) called \(request)") + + logger.log("[autofill-extension] prepareInterfaceToProvideCredential (passkey) called \(request)") class CallbackImpl: PreparePasskeyAssertionCallback { let ctx: ASCredentialProviderExtensionContext @@ -154,18 +293,25 @@ class CredentialProviderViewController: ASCredentialProviderViewController { UserVerification.discouraged } - let req = PasskeyAssertionWithoutUserInterfaceRequest( - rpId: passkeyIdentity.relyingPartyIdentifier, - credentialId: passkeyIdentity.credentialID, - userName: passkeyIdentity.userName, - userHandle: passkeyIdentity.userHandle, - recordIdentifier: passkeyIdentity.recordIdentifier, - clientDataHash: request.clientDataHash, - userVerification: userVerification, - windowXy: self.getWindowPosition() - ) - - self.client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + /* + We're still using the old request type here, because we're sending the same data, we're expecting a single credential to be used + */ + Task { + let windowPosition = await self.getWindowPosition() + let req = PasskeyAssertionWithoutUserInterfaceRequest( + rpId: passkeyIdentity.relyingPartyIdentifier, + credentialId: passkeyIdentity.credentialID, + userName: passkeyIdentity.userName, + userHandle: passkeyIdentity.userHandle, + recordIdentifier: passkeyIdentity.recordIdentifier, + clientDataHash: request.clientDataHash, + userVerification: userVerification, + windowXy: windowPosition + ) + + let client = await getClient() + client.preparePasskeyAssertionWithoutUserInterface(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + } return } } @@ -176,16 +322,6 @@ class CredentialProviderViewController: ASCredentialProviderViewController { self.extensionContext.cancelRequest(withError: BitwardenError.Internal("Invalid authentication request")) } - /* - Implement this method if provideCredentialWithoutUserInteraction(for:) can fail with - ASExtensionError.userInteractionRequired. In this case, the system may present your extension's - UI and call this method. Show appropriate UI for authenticating the user then provide the password - by completing the extension request with the associated ASPasswordCredential. - - override func prepareInterfaceToProvideCredential(for credentialIdentity: ASPasswordCredentialIdentity) { - } - */ - private func createTimer() -> DispatchWorkItem { // Create a timer for 600 second timeout let timeoutTimer = DispatchWorkItem { [weak self] in @@ -246,18 +382,32 @@ class CredentialProviderViewController: ASCredentialProviderViewController { UserVerification.discouraged } - let req = PasskeyRegistrationRequest( - rpId: passkeyIdentity.relyingPartyIdentifier, - userName: passkeyIdentity.userName, - userHandle: passkeyIdentity.userHandle, - clientDataHash: request.clientDataHash, - userVerification: userVerification, - supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) }, - windowXy: self.getWindowPosition() - ) + // Convert excluded credentials to an array of credential IDs + var excludedCredentialIds: [Data] = [] + if #available(macOSApplicationExtension 15.0, *) { + if let excludedCreds = request.excludedCredentials { + excludedCredentialIds = excludedCreds.map { $0.credentialID } + } + } + logger.log("[autofill-extension] prepareInterface(passkey) calling preparePasskeyRegistration") - self.client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + Task { + let windowPosition = await self.getWindowPosition() + let req = PasskeyRegistrationRequest( + rpId: passkeyIdentity.relyingPartyIdentifier, + userName: passkeyIdentity.userName, + userHandle: passkeyIdentity.userHandle, + clientDataHash: request.clientDataHash, + userVerification: userVerification, + supportedAlgorithms: request.supportedAlgorithms.map{ Int32($0.rawValue) }, + windowXy: windowPosition, + excludedCredentials: excludedCredentialIds + ) + + let client = await getClient() + client.preparePasskeyRegistration(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + } return } } @@ -310,18 +460,22 @@ class CredentialProviderViewController: ASCredentialProviderViewController { UserVerification.discouraged } - let req = PasskeyAssertionRequest( - rpId: requestParameters.relyingPartyIdentifier, - clientDataHash: requestParameters.clientDataHash, - userVerification: userVerification, - allowedCredentials: requestParameters.allowedCredentials, - windowXy: self.getWindowPosition() - //extensionInput: requestParameters.extensionInput, - ) - let timeoutTimer = createTimer() - self.client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + Task { + let windowPosition = await self.getWindowPosition() + let req = PasskeyAssertionRequest( + rpId: requestParameters.relyingPartyIdentifier, + clientDataHash: requestParameters.clientDataHash, + userVerification: userVerification, + allowedCredentials: requestParameters.allowedCredentials, + windowXy: windowPosition + //extensionInput: requestParameters.extensionInput, // We don't support extensions yet + ) + + let client = await getClient() + client.preparePasskeyAssertion(request: req, callback: CallbackImpl(self.extensionContext, self.logger, timeoutTimer)) + } return } } diff --git a/apps/desktop/macos/autofill-extension/Info.plist b/apps/desktop/macos/autofill-extension/Info.plist index 539cfa35b9d..7de0d4d152b 100644 --- a/apps/desktop/macos/autofill-extension/Info.plist +++ b/apps/desktop/macos/autofill-extension/Info.plist @@ -10,9 +10,9 @@ ProvidesPasskeys + ShowsConfigurationUI + - ASCredentialProviderExtensionShowsConfigurationUI - NSExtensionPointIdentifier com.apple.authentication-services-credential-provider-ui diff --git a/apps/desktop/macos/autofill-extension/autofill_extension.entitlements b/apps/desktop/macos/autofill-extension/autofill_extension.entitlements index 86c7195768e..d5c7b8a2cc8 100644 --- a/apps/desktop/macos/autofill-extension/autofill_extension.entitlements +++ b/apps/desktop/macos/autofill-extension/autofill_extension.entitlements @@ -2,11 +2,9 @@ - com.apple.developer.authentication-services.autofill-credential-provider - - com.apple.security.app-sandbox - - com.apple.security.application-groups + com.apple.security.app-sandbox + + com.apple.security.application-groups LTZ2PFU5D6.com.bitwarden.desktop diff --git a/apps/desktop/macos/autofill-extension/bitwarden-icon.png b/apps/desktop/macos/autofill-extension/bitwarden-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9a05bc7bbdd8061e741cbfdbcbc7148366f3ad61 GIT binary patch literal 3291 zcmV<13?%c3P)|~|L-n`m1W7rbaV|hwUR~~dlUN8$qJiFrO+Ci>VOCTs|UdD5G?{`J57Prm%ko`HD=KKJEs{E8=T^y8ZcJ*DL1t*sT?RE8{? z+srLt2!oAsX$U5w4TfCGg*6RwVUM1+zx>XB@+JS!Co!MI^Z&$8e!+b`@IUbaA7=#D4Ue9l_~_Ta;|IR{r#+36r?=;L;6Ks} z2$|?W6poQ3Ar}(Dfh0;YsgUg$DQ%6A$%K#%Yl#jzwzZ^=NgSJFq2Ty@87-0 zcYpso+O}e&C~ZbAoPGHHzvmtQ>i56S9h_WYXJ=o5Dui(0aLeKI9y{yLW^|^OSPocHF>l%&*FYg6Q}n z47ts@JKN6AKVOG14YHn9$0 zk7w$|U+{bn+<%XcH^+1`&4pYlO*1+>`+N+!&AF>Rz_B48GmXjNIE1+i-}H*taXNF$ zkeOV_49VdK|Jqmj>Mwi9ueE5Nlv}2`G&&%6aPDa(%*TY4$!6}>`Td+dM+X~BOSur{ z(jjMVSI4r(U~b8prr0qIxy`vNY%NlYT$m{tQo`JYLZ^(@hKw;*E;MFyy9oxxMlQ54 zzbbcd?h18;F*MfD&|#Y~cWJ|p6(X?_=F(=4D4$^HkW(D1xv)_fj+?G>?n=$2+>+za z>106%7UpiGN^zP69av>hP?+0P(YQHJ+9;PQPa1NYb60B;6uY1fSu1Gn){b#Fou))% zaf~6i9NTLBc62cWcrTsQK28!fWgafGulccE%0t7Bvvg}J3wPBM3}R%KI%ZJsi>w1pwJId`STf!V0gJZUn= z+_gp!R)-54WhGjYI|;`$sE#pDYBL>kg>zR5DVK)iLd{@)9i%drkEx)|3PL_n2#e-Y zVOU{>+`+jkbYy2#NCsgOQhptTin$c#mR57A$S24e%P|w%qFCmbp@VLE@fSSb?cVmT$EQ@y zF+@&lLmldX+`-8eP8uPX22pM~w#_4Ne$?H#c^plM4jUXQM29V3{X1UZ`S;)B<2~}` zM{Or0A$8E94mpt1T=-fok)L{J?!v|fsUn2Kg&e;14}Ol9e(l3<_dWmS zmoQ0{4x~gXlPy;{xxz`?TBV|pEh@sPN8a-IUi-+qe7cf4kZF}Ir+Ft*b4|(OQ ze$J<(Bb7Q3)f{{Hq5J&sE56FZFMO`s{jDE(eQ*1f8`?~xZMo%uw$$O4Aa`(bg(IaV zbr_U7=s<{K=l#te_=R5k$UA-NYJzguj=lJ|JkJlk;;TIT!soi(PdxHYulyHp*x}5O z10j?P-CSiZgk0t13a1_8SPqt?4#oz@lnY_!_w7&pjsMnL-}M2XnwA66;m>}<7xbQe&`e@I;JRO z4pkP5MF&EVJ2<(sI-Na1$QGj1LE#u7QP}BhZ+Xv+ebZn0F^|0YS3DyfzUz;D@&5BG zU$U?HvWMKs>)-S%zV16-<1N2(lhfJiKsFLm+L{YRA&TRpZ^zuh$rVmFZ#+&&B?m%C z6(Ms>2jX<*E$_YICExKH-}~yH_sRV9-}?sN^)0{M1NYzKPQLHekNCRpc#X$Cd}e27 zstBpWC?bju$%Q0Q??vw5+?{ni@%SS z^JTxw^Y6dUojm+mpW#j(``|~s@>M_UdtdWQI=dF9Nn0zIHY!-Oxg{(^Nl(1R6;7_u z``-8ygb=c|3Ly&FLP!@J+v(&uJ*V&akvI9eZ~tL${pH6!Q?GsfJG|uEf7tiF=6`c` zAKMI$5e^)bt!>#_MIwZ3?RfkxuSM=)uJ#$f=^MZQbvwK7OI?^tB~RL{7;+&q(PqNp zxM9afyz~#f$alW%D}DZNex5sd?1LZixBmH0`@Vnsi#ofHotJH72IbO*h-k1)hc=7G zI&QqZ|Mcg-?0xTh-(&9J+|@pGJn?}aJv+PiOQEzGwoz1=Te3|RO=DEHGso##ulg@< z_u4nSYcG4r7y8bZ{ccZr?1LZieXoAK?|Jnv^w@{3v*+09jBJ8cmtkG=1GA9RIzdY}25zUlj4?{xk-Gs(v+noGl)RPNi)!yLOer_*4jVhkB_VaV-r z&-+DG2$wQqR0j~wTm?qMfmgk0F>NsSE= zZJyNHOxl{Q;e&7ZXWxADJ#YL49IkNY>Gje7`#;{|-sk?}Jr91-AFPhy_A=im#U_ch=3$M1RQxBY2n_kK1jO!K7DHZ;G+qWLuz z&7}pE&3p>dv5pVkeE-tM{ovntyZ-r@W4IqfB*aM#XacSwQD}!_3PJ% zJn7oCYp!3v4nywX+O=!0U%&3!wQH_lzwW}dYu7yG`t|E@6CcG5T*nj0v!3;=XFcng Z`+p}j2wIZUE=m9Z002ovPDHLkV1iQWv4Q{q literal 0 HcmV?d00001 diff --git a/apps/desktop/macos/autofill-extension/en.lproj/Localizable.strings b/apps/desktop/macos/autofill-extension/en.lproj/Localizable.strings new file mode 100644 index 00000000000..95730dff286 --- /dev/null +++ b/apps/desktop/macos/autofill-extension/en.lproj/Localizable.strings @@ -0,0 +1,2 @@ +/* Message shown during passkey configuration */ +"autofillConfigurationMessage" = "Enabling Bitwarden..."; diff --git a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj index ff257097f26..ed19fc9ef5d 100644 --- a/apps/desktop/macos/desktop.xcodeproj/project.pbxproj +++ b/apps/desktop/macos/desktop.xcodeproj/project.pbxproj @@ -9,6 +9,8 @@ /* Begin PBXBuildFile section */ 3368DB392C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */; }; 3368DB3B2C654F3800896B75 /* BitwardenMacosProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */; }; + 9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */; }; + 9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 9AE2990D2DFB57A200AAE454 /* Localizable.strings */; }; E1DF713F2B342F6900F29026 /* AuthenticationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E1DF713E2B342F6900F29026 /* AuthenticationServices.framework */; }; E1DF71422B342F6900F29026 /* CredentialProviderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */; }; E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */; }; @@ -18,6 +20,8 @@ 3368DB382C654B8100896B75 /* BitwardenMacosProviderFFI.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = BitwardenMacosProviderFFI.xcframework; path = ../desktop_native/macos_provider/BitwardenMacosProviderFFI.xcframework; sourceTree = ""; }; 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BitwardenMacosProvider.swift; sourceTree = ""; }; 968ED08A2C52A47200FFFEE6 /* ReleaseAppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseAppStore.xcconfig; sourceTree = ""; }; + 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "bitwarden-icon.png"; sourceTree = ""; }; + 9AE2990C2DFB57A200AAE454 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; D83832AB2D67B9AE003FB9F8 /* Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; D83832AD2D67B9D0003FB9F8 /* ReleaseDeveloper.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = ReleaseDeveloper.xcconfig; sourceTree = ""; }; E1DF713C2B342F6900F29026 /* autofill-extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "autofill-extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -41,6 +45,14 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 9AE2990E2DFB57A200AAE454 /* en.lproj */ = { + isa = PBXGroup; + children = ( + 9AE2990D2DFB57A200AAE454 /* Localizable.strings */, + ); + path = en.lproj; + sourceTree = ""; + }; E1DF711D2B342E2800F29026 = { isa = PBXGroup; children = ( @@ -73,6 +85,8 @@ E1DF71402B342F6900F29026 /* autofill-extension */ = { isa = PBXGroup; children = ( + 9AE2990E2DFB57A200AAE454 /* en.lproj */, + 9AE299082DF9D82E00AAE454 /* bitwarden-icon.png */, 3368DB3A2C654F3800896B75 /* BitwardenMacosProvider.swift */, E1DF71412B342F6900F29026 /* CredentialProviderViewController.swift */, E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */, @@ -124,6 +138,7 @@ knownRegions = ( en, Base, + sv, ); mainGroup = E1DF711D2B342E2800F29026; productRefGroup = E1DF71272B342E2800F29026 /* Products */; @@ -141,6 +156,8 @@ buildActionMask = 2147483647; files = ( E1DF71452B342F6900F29026 /* CredentialProviderViewController.xib in Resources */, + 9AE299122DFB57A200AAE454 /* Localizable.strings in Resources */, + 9AE299092DF9D82E00AAE454 /* bitwarden-icon.png in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -159,6 +176,14 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXVariantGroup section */ + 9AE2990D2DFB57A200AAE454 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 9AE2990C2DFB57A200AAE454 /* en */, + ); + name = Localizable.strings; + sourceTree = ""; + }; E1DF71432B342F6900F29026 /* CredentialProviderViewController.xib */ = { isa = PBXVariantGroup; children = ( diff --git a/apps/desktop/package.json b/apps/desktop/package.json index bb8118cb7eb..5e85d34cebc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,6 +18,7 @@ "scripts": { "postinstall": "electron-rebuild", "start": "cross-env ELECTRON_IS_DEV=0 ELECTRON_NO_UPDATER=1 electron ./build", + "build-native-macos": "cd desktop_native && ./macos_provider/build.sh && node build.js cross-platform", "build-native": "cd desktop_native && node build.js", "build": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main\" \"npm run build:renderer\" \"npm run build:preload\"", "build:dev": "concurrently -n Main,Rend,Prel -c yellow,cyan \"npm run build:main:dev\" \"npm run build:renderer:dev\" \"npm run build:preload:dev\"", @@ -44,10 +45,9 @@ "pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never", "pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never", "pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never", - "pack:mac:mas": "npm run clean:dist && electron-builder --mac mas --universal -p never", - "pack:mac:mas:with-extension": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", - "pack:mac:masdev": "npm run clean:dist && electron-builder --mac mas-dev --universal -p never", - "pack:mac:masdev:with-extension": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", + "pack:mac:mas": "npm run clean:dist && npm run build:macos-extension:mas && electron-builder --mac mas --universal -p never", + "pack:mac:masdev": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never", + "pack:local:mac": "npm run clean:dist && npm run build:macos-extension:masdev && electron-builder --mac mas-dev --universal -p never -c.mac.provisioningProfile=\"\" -c.mas.provisioningProfile=\"\"", "pack:win": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", "pack:win:beta": "npm run clean:dist && electron-builder --config electron-builder.beta.json --win --x64 --arm64 --ia32 -p never -c.win.signtoolOptions.certificateSubjectName=\"8bit Solutions LLC\"", "pack:win:ci": "npm run clean:dist && electron-builder --win --x64 --arm64 --ia32 -p never", @@ -55,11 +55,8 @@ "dist:lin": "npm run build && npm run pack:lin", "dist:lin:arm64": "npm run build && npm run pack:lin:arm64", "dist:mac": "npm run build && npm run pack:mac", - "dist:mac:with-extension": "npm run build && npm run pack:mac:with-extension", "dist:mac:mas": "npm run build && npm run pack:mac:mas", - "dist:mac:mas:with-extension": "npm run build && npm run pack:mac:mas:with-extension", - "dist:mac:masdev": "npm run build:dev && npm run pack:mac:masdev", - "dist:mac:masdev:with-extension": "npm run build:dev && npm run pack:mac:masdev:with-extension", + "dist:mac:masdev": "npm run build && npm run pack:mac:masdev", "dist:win": "npm run build && npm run pack:win", "dist:win:ci": "npm run build && npm run pack:win:ci", "publish:lin": "npm run build && npm run clean:dist && electron-builder --linux --x64 -p always", diff --git a/apps/desktop/resources/entitlements.mac.plist b/apps/desktop/resources/entitlements.mac.plist index fe49256d71c..7763b84624d 100644 --- a/apps/desktop/resources/entitlements.mac.plist +++ b/apps/desktop/resources/entitlements.mac.plist @@ -6,8 +6,6 @@ LTZ2PFU5D6.com.bitwarden.desktop com.apple.developer.team-identifier LTZ2PFU5D6 - com.apple.developer.authentication-services.autofill-credential-provider - com.apple.security.cs.allow-jit diff --git a/apps/desktop/resources/entitlements.mas.inherit.plist b/apps/desktop/resources/entitlements.mas.inherit.plist index fca5f02d52d..7194d9409fc 100644 --- a/apps/desktop/resources/entitlements.mas.inherit.plist +++ b/apps/desktop/resources/entitlements.mas.inherit.plist @@ -4,9 +4,9 @@ com.apple.security.app-sandbox - com.apple.security.inherit - com.apple.security.cs.allow-jit + com.apple.security.inherit + diff --git a/apps/desktop/resources/entitlements.mas.plist b/apps/desktop/resources/entitlements.mas.plist index 2977e5fd786..226e9827e37 100644 --- a/apps/desktop/resources/entitlements.mas.plist +++ b/apps/desktop/resources/entitlements.mas.plist @@ -6,19 +6,19 @@ LTZ2PFU5D6.com.bitwarden.desktop com.apple.developer.team-identifier LTZ2PFU5D6 - com.apple.developer.authentication-services.autofill-credential-provider - com.apple.security.app-sandbox com.apple.security.application-groups LTZ2PFU5D6.com.bitwarden.desktop - com.apple.security.network.client + com.apple.security.cs.allow-jit + + com.apple.security.device.usb com.apple.security.files.user-selected.read-write - com.apple.security.device.usb + com.apple.security.network.client com.apple.security.temporary-exception.files.home-relative-path.read-write @@ -36,7 +36,5 @@ /Library/Application Support/Zen/NativeMessagingHosts/ /Library/Application Support/net.imput.helium - com.apple.security.cs.allow-jit - diff --git a/apps/desktop/scripts/after-sign.js b/apps/desktop/scripts/after-sign.js index 7c9ad381dc2..4275ec7d051 100644 --- a/apps/desktop/scripts/after-sign.js +++ b/apps/desktop/scripts/after-sign.js @@ -16,7 +16,7 @@ async function run(context) { const appPath = `${context.appOutDir}/${appName}.app`; const macBuild = context.electronPlatformName === "darwin"; const copySafariExtension = ["darwin", "mas"].includes(context.electronPlatformName); - const copyAutofillExtension = ["darwin", "mas"].includes(context.electronPlatformName); + const copyAutofillExtension = ["darwin"].includes(context.electronPlatformName); // Disabled for mas builds let shouldResign = false; diff --git a/apps/desktop/src/app/app-routing.module.ts b/apps/desktop/src/app/app-routing.module.ts index 8fab7df1cd8..fdc421153e1 100644 --- a/apps/desktop/src/app/app-routing.module.ts +++ b/apps/desktop/src/app/app-routing.module.ts @@ -45,11 +45,14 @@ import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/co import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui"; import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard"; +import { reactiveUnlockVaultGuard } from "../autofill/guards/reactive-vault-guard"; +import { Fido2CreateComponent } from "../autofill/modal/credentials/fido2-create.component"; +import { Fido2ExcludedCiphersComponent } from "../autofill/modal/credentials/fido2-excluded-ciphers.component"; +import { Fido2VaultComponent } from "../autofill/modal/credentials/fido2-vault.component"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import { VaultV2Component } from "../vault/app/vault/vault-v2.component"; import { VaultComponent } from "../vault/app/vault-v3/vault.component"; -import { Fido2PlaceholderComponent } from "./components/fido2placeholder.component"; import { DesktopLayoutComponent } from "./layout/desktop-layout.component"; import { SendComponent } from "./tools/send/send.component"; import { SendV2Component } from "./tools/send-v2/send-v2.component"; @@ -120,12 +123,16 @@ const routes: Routes = [ canActivate: [authGuard], }, { - path: "passkeys", - component: Fido2PlaceholderComponent, + path: "fido2-assertion", + component: Fido2VaultComponent, }, { - path: "passkeys", - component: Fido2PlaceholderComponent, + path: "fido2-creation", + component: Fido2CreateComponent, + }, + { + path: "fido2-excluded", + component: Fido2ExcludedCiphersComponent, }, { path: "", @@ -271,7 +278,7 @@ const routes: Routes = [ }, { path: "lock", - canActivate: [lockGuard()], + canActivate: [lockGuard(), reactiveUnlockVaultGuard], data: { pageIcon: LockIcon, pageTitle: { diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index 6243ba1e538..836328142b5 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -104,7 +104,7 @@ const SyncInterval = 6 * 60 * 60 * 1000; // 6 hours - +
@@ -141,6 +141,7 @@ export class AppComponent implements OnInit, OnDestroy { @ViewChild("loginApproval", { read: ViewContainerRef, static: true }) loginApprovalModalRef: ViewContainerRef; + showHeader$ = this.accountService.showHeader$; loading = false; private lastActivity: Date = null; diff --git a/apps/desktop/src/app/components/fido2placeholder.component.ts b/apps/desktop/src/app/components/fido2placeholder.component.ts deleted file mode 100644 index f1f52dae439..00000000000 --- a/apps/desktop/src/app/components/fido2placeholder.component.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { CommonModule } from "@angular/common"; -import { Component, OnDestroy, OnInit } from "@angular/core"; -import { Router } from "@angular/router"; -import { BehaviorSubject, Observable } from "rxjs"; - -import { - DesktopFido2UserInterfaceService, - DesktopFido2UserInterfaceSession, -} from "../../autofill/services/desktop-fido2-user-interface.service"; -import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; - -// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush -// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -@Component({ - standalone: true, - imports: [CommonModule], - template: ` -
-

Select your passkey

- -
- -
- -
- - -
- `, -}) -export class Fido2PlaceholderComponent implements OnInit, OnDestroy { - session?: DesktopFido2UserInterfaceSession = null; - private cipherIdsSubject = new BehaviorSubject([]); - cipherIds$: Observable; - - constructor( - private readonly desktopSettingsService: DesktopSettingsService, - private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, - private readonly router: Router, - ) {} - - ngOnInit() { - this.session = this.fido2UserInterfaceService.getCurrentSession(); - this.cipherIds$ = this.session?.availableCipherIds$; - } - - async chooseCipher(cipherId: string) { - // For now: Set UV to true - this.session?.confirmChosenCipher(cipherId, true); - - await this.router.navigate(["/"]); - await this.desktopSettingsService.setModalMode(false); - } - - ngOnDestroy() { - this.cipherIdsSubject.complete(); // Clean up the BehaviorSubject - } - - async confirmPasskey() { - try { - // Retrieve the current UI session to control the flow - if (!this.session) { - // todo: handle error - throw new Error("No session found"); - } - - // If we want to we could submit information to the session in order to create the credential - // const cipher = await session.createCredential({ - // userHandle: "userHandle2", - // userName: "username2", - // credentialName: "zxsd2", - // rpId: "webauthn.io", - // userVerification: true, - // }); - - this.session.notifyConfirmNewCredential(true); - - // Not sure this clean up should happen here or in session. - // The session currently toggles modal on and send us here - // But if this route is somehow opened outside of session we want to make sure we clean up? - await this.router.navigate(["/"]); - await this.desktopSettingsService.setModalMode(false); - } catch { - // TODO: Handle error appropriately - } - } - - async closeModal() { - await this.router.navigate(["/"]); - await this.desktopSettingsService.setModalMode(false); - - this.session.notifyConfirmNewCredential(false); - // little bit hacky: - this.session.confirmChosenCipher(null); - } -} diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 04f5e8026c2..5e20b2fa921 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -345,6 +345,7 @@ const safeProviders: SafeProvider[] = [ ConfigService, Fido2AuthenticatorServiceAbstraction, AccountService, + AuthService, PlatformUtilsService, ], }), diff --git a/apps/desktop/src/autofill/guards/reactive-vault-guard.ts b/apps/desktop/src/autofill/guards/reactive-vault-guard.ts new file mode 100644 index 00000000000..d16787ef46a --- /dev/null +++ b/apps/desktop/src/autofill/guards/reactive-vault-guard.ts @@ -0,0 +1,42 @@ +import { inject } from "@angular/core"; +import { CanActivateFn, Router } from "@angular/router"; +import { combineLatest, map, switchMap, distinctUntilChanged } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; + +import { DesktopSettingsService } from "../../platform/services/desktop-settings.service"; + +/** + * Reactive route guard that redirects to the unlocked vault. + * Redirects to vault when unlocked in main window. + */ +export const reactiveUnlockVaultGuard: CanActivateFn = () => { + const router = inject(Router); + const authService = inject(AuthService); + const accountService = inject(AccountService); + const desktopSettingsService = inject(DesktopSettingsService); + + return combineLatest([accountService.activeAccount$, desktopSettingsService.modalMode$]).pipe( + switchMap(([account, modalMode]) => { + if (!account) { + return [true]; + } + + // Monitor when the vault has been unlocked. + return authService.authStatusFor$(account.id).pipe( + distinctUntilChanged(), + map((authStatus) => { + // If vault is unlocked and we're not in modal mode, redirect to vault + if (authStatus === AuthenticationStatus.Unlocked && !modalMode?.isModalModeActive) { + return router.createUrlTree(["/vault"]); + } + + // Otherwise keep user on the lock screen + return true; + }), + ); + }), + ); +}; diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html new file mode 100644 index 00000000000..67fc76aa317 --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.html @@ -0,0 +1,66 @@ +
+ + +
+ + +

+ {{ "savePasskeyQuestion" | i18n }} +

+
+ + +
+
+ + +
+
+ +
+ {{ "noMatchingLoginsForSite" | i18n }} +
+ +
+
+ + + + {{ c.subTitle }} + {{ "save" | i18n }} + + + + + + +
+
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts new file mode 100644 index 00000000000..778215895ee --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.spec.ts @@ -0,0 +1,238 @@ +import { TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject, of } from "rxjs"; + +import { AccountService, Account } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { DialogService } from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service"; +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +import { Fido2CreateComponent } from "./fido2-create.component"; + +describe("Fido2CreateComponent", () => { + let component: Fido2CreateComponent; + let mockDesktopSettingsService: MockProxy; + let mockFido2UserInterfaceService: MockProxy; + let mockAccountService: MockProxy; + let mockCipherService: MockProxy; + let mockDesktopAutofillService: MockProxy; + let mockDialogService: MockProxy; + let mockDomainSettingsService: MockProxy; + let mockLogService: MockProxy; + let mockPasswordRepromptService: MockProxy; + let mockRouter: MockProxy; + let mockSession: MockProxy; + let mockI18nService: MockProxy; + + const activeAccountSubject = new BehaviorSubject({ + id: "test-user-id" as UserId, + email: "test@example.com", + emailVerified: true, + name: "Test User", + }); + + beforeEach(async () => { + mockDesktopSettingsService = mock(); + mockFido2UserInterfaceService = mock(); + mockAccountService = mock(); + mockCipherService = mock(); + mockDesktopAutofillService = mock(); + mockDialogService = mock(); + mockDomainSettingsService = mock(); + mockLogService = mock(); + mockPasswordRepromptService = mock(); + mockRouter = mock(); + mockSession = mock(); + mockI18nService = mock(); + + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession); + mockAccountService.activeAccount$ = activeAccountSubject; + + await TestBed.configureTestingModule({ + providers: [ + Fido2CreateComponent, + { provide: DesktopSettingsService, useValue: mockDesktopSettingsService }, + { provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: CipherService, useValue: mockCipherService }, + { provide: DesktopAutofillService, useValue: mockDesktopAutofillService }, + { provide: DialogService, useValue: mockDialogService }, + { provide: DomainSettingsService, useValue: mockDomainSettingsService }, + { provide: LogService, useValue: mockLogService }, + { provide: PasswordRepromptService, useValue: mockPasswordRepromptService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + ], + }).compileComponents(); + + component = TestBed.inject(Fido2CreateComponent); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + function createMockCiphers(): CipherView[] { + const cipher1 = new CipherView(); + cipher1.id = "cipher-1"; + cipher1.name = "Test Cipher 1"; + cipher1.type = CipherType.Login; + cipher1.login = { + username: "test1@example.com", + uris: [{ uri: "https://example.com", match: null }], + matchesUri: jest.fn().mockReturnValue(true), + get hasFido2Credentials() { + return false; + }, + } as any; + cipher1.reprompt = CipherRepromptType.None; + cipher1.deletedDate = null; + + return [cipher1]; + } + + describe("ngOnInit", () => { + beforeEach(() => { + mockSession.getRpId.mockResolvedValue("example.com"); + Object.defineProperty(mockDesktopAutofillService, "lastRegistrationRequest", { + get: jest.fn().mockReturnValue({ + userHandle: new Uint8Array([1, 2, 3]), + }), + configurable: true, + }); + mockDomainSettingsService.getUrlEquivalentDomains.mockReturnValue(of(new Set())); + }); + + it("should initialize session and set show header to false", async () => { + const mockCiphers = createMockCiphers(); + mockCipherService.getAllDecrypted.mockResolvedValue(mockCiphers); + + await component.ngOnInit(); + + expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled(); + expect(component.session).toBe(mockSession); + }); + + it("should show error dialog when no active session found", async () => { + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await component.ngOnInit(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "unableToSavePasskey" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + acceptAction: expect.any(Function), + cancelButtonText: null, + }); + }); + }); + + describe("addCredentialToCipher", () => { + beforeEach(() => { + component.session = mockSession; + }); + + it("should add passkey to cipher", async () => { + const cipher = createMockCiphers()[0]; + + await component.addCredentialToCipher(cipher); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher); + }); + + it("should not add passkey when password reprompt is cancelled", async () => { + const cipher = createMockCiphers()[0]; + cipher.reprompt = CipherRepromptType.Password; + mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false); + + await component.addCredentialToCipher(cipher); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher); + }); + + it("should call openSimpleDialog when cipher already has a fido2 credential", async () => { + const cipher = createMockCiphers()[0]; + Object.defineProperty(cipher.login, "hasFido2Credentials", { + get: jest.fn().mockReturnValue(true), + }); + mockDialogService.openSimpleDialog.mockResolvedValue(true); + + await component.addCredentialToCipher(cipher); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "overwritePasskey" }, + content: { key: "alreadyContainsPasskey" }, + type: "warning", + }); + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true, cipher); + }); + + it("should not add passkey when user cancels overwrite dialog", async () => { + const cipher = createMockCiphers()[0]; + Object.defineProperty(cipher.login, "hasFido2Credentials", { + get: jest.fn().mockReturnValue(true), + }); + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await component.addCredentialToCipher(cipher); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false, cipher); + }); + }); + + describe("confirmPasskey", () => { + beforeEach(() => { + component.session = mockSession; + }); + + it("should confirm passkey creation successfully", async () => { + await component.confirmPasskey(); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(true); + }); + + it("should call openSimpleDialog when session is null", async () => { + component.session = null; + mockDialogService.openSimpleDialog.mockResolvedValue(false); + + await component.confirmPasskey(); + + expect(mockDialogService.openSimpleDialog).toHaveBeenCalledWith({ + title: { key: "unableToSavePasskey" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + acceptAction: expect.any(Function), + cancelButtonText: null, + }); + }); + }); + + describe("closeModal", () => { + it("should close modal and notify session", async () => { + component.session = mockSession; + + await component.closeModal(); + + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false); + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null); + }); + }); +}); diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts new file mode 100644 index 00000000000..67237bedccd --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-create.component.ts @@ -0,0 +1,219 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; +import { combineLatest, map, Observable, Subject, switchMap } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BitwardenShield, NoResults } from "@bitwarden/assets/svg"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service"; +import { Fido2Utils } from "@bitwarden/common/platform/services/fido2/fido2-utils"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherType } from "@bitwarden/common/vault/enums/cipher-type"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + DialogService, + BadgeModule, + ButtonModule, + DialogModule, + IconModule, + ItemModule, + SectionComponent, + TableModule, + SectionHeaderComponent, + BitIconButtonComponent, + SimpleDialogOptions, +} from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { DesktopAutofillService } from "../../../autofill/services/desktop-autofill.service"; +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-create.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Fido2CreateComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + ciphers$: Observable; + private destroy$ = new Subject(); + readonly Icons = { BitwardenShield, NoResults }; + + private get DIALOG_MESSAGES() { + return { + unexpectedErrorShort: { + title: { key: "unexpectedErrorShort" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + cancelButtonText: null as null, + acceptAction: async () => this.dialogService.closeAll(), + }, + unableToSavePasskey: { + title: { key: "unableToSavePasskey" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + cancelButtonText: null as null, + acceptAction: async () => this.dialogService.closeAll(), + }, + overwritePasskey: { + title: { key: "overwritePasskey" }, + content: { key: "alreadyContainsPasskey" }, + type: "warning", + }, + } as const satisfies Record; + } + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly accountService: AccountService, + private readonly cipherService: CipherService, + private readonly desktopAutofillService: DesktopAutofillService, + private readonly dialogService: DialogService, + private readonly domainSettingsService: DomainSettingsService, + private readonly passwordRepromptService: PasswordRepromptService, + private readonly router: Router, + ) {} + + async ngOnInit(): Promise { + this.session = this.fido2UserInterfaceService.getCurrentSession(); + + if (this.session) { + const rpid = await this.session.getRpId(); + this.initializeCiphersObservable(rpid); + } else { + await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey); + } + } + + async ngOnDestroy(): Promise { + this.destroy$.next(); + this.destroy$.complete(); + await this.closeModal(); + } + + async addCredentialToCipher(cipher: CipherView): Promise { + const isConfirmed = await this.validateCipherAccess(cipher); + + try { + if (!this.session) { + throw new Error("Missing session"); + } + + this.session.notifyConfirmCreateCredential(isConfirmed, cipher); + } catch { + await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey); + return; + } + + await this.closeModal(); + } + + async confirmPasskey(): Promise { + try { + if (!this.session) { + throw new Error("Missing session"); + } + + this.session.notifyConfirmCreateCredential(true); + } catch { + await this.showErrorDialog(this.DIALOG_MESSAGES.unableToSavePasskey); + } + + await this.closeModal(); + } + + async closeModal(): Promise { + await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); + + if (this.session) { + this.session.notifyConfirmCreateCredential(false); + this.session.confirmChosenCipher(null); + } + + await this.router.navigate(["/"]); + } + + private initializeCiphersObservable(rpid: string): void { + const lastRegistrationRequest = this.desktopAutofillService.lastRegistrationRequest; + + if (!lastRegistrationRequest || !rpid) { + return; + } + + const userHandle = Fido2Utils.bufferToString( + new Uint8Array(lastRegistrationRequest.userHandle), + ); + + this.ciphers$ = combineLatest([ + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + this.domainSettingsService.getUrlEquivalentDomains(rpid), + ]).pipe( + switchMap(async ([activeUserId, equivalentDomains]) => { + if (!activeUserId) { + return []; + } + + try { + const allCiphers = await this.cipherService.getAllDecrypted(activeUserId); + return allCiphers.filter( + (cipher) => + cipher != null && + cipher.type == CipherType.Login && + cipher.login?.matchesUri(rpid, equivalentDomains) && + Fido2Utils.cipherHasNoOtherPasskeys(cipher, userHandle) && + !cipher.deletedDate, + ); + } catch { + await this.showErrorDialog(this.DIALOG_MESSAGES.unexpectedErrorShort); + return []; + } + }), + ); + } + + private async validateCipherAccess(cipher: CipherView): Promise { + if (cipher.login.hasFido2Credentials) { + const overwriteConfirmed = await this.dialogService.openSimpleDialog( + this.DIALOG_MESSAGES.overwritePasskey, + ); + + if (!overwriteConfirmed) { + return false; + } + } + + if (cipher.reprompt) { + return this.passwordRepromptService.showPasswordPrompt(); + } + + return true; + } + + private async showErrorDialog(config: SimpleDialogOptions): Promise { + await this.dialogService.openSimpleDialog(config); + await this.closeModal(); + } +} diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html new file mode 100644 index 00000000000..792934deedc --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.html @@ -0,0 +1,44 @@ +
+ + +
+ + +

+ {{ "savePasskeyQuestion" | i18n }} +

+
+ + +
+
+ +
+ +
+ +
+ {{ "passkeyAlreadyExists" | i18n }} + {{ "applicationDoesNotSupportDuplicates" | i18n }} +
+ +
+
+
+
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts new file mode 100644 index 00000000000..6a465136458 --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.spec.ts @@ -0,0 +1,78 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; + +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +import { Fido2ExcludedCiphersComponent } from "./fido2-excluded-ciphers.component"; + +describe("Fido2ExcludedCiphersComponent", () => { + let component: Fido2ExcludedCiphersComponent; + let fixture: ComponentFixture; + let mockDesktopSettingsService: MockProxy; + let mockFido2UserInterfaceService: MockProxy; + let mockAccountService: MockProxy; + let mockRouter: MockProxy; + let mockSession: MockProxy; + let mockI18nService: MockProxy; + + beforeEach(async () => { + mockDesktopSettingsService = mock(); + mockFido2UserInterfaceService = mock(); + mockAccountService = mock(); + mockRouter = mock(); + mockSession = mock(); + mockI18nService = mock(); + + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession); + + await TestBed.configureTestingModule({ + imports: [Fido2ExcludedCiphersComponent], + providers: [ + { provide: DesktopSettingsService, useValue: mockDesktopSettingsService }, + { provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(Fido2ExcludedCiphersComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("ngOnInit", () => { + it("should initialize session", async () => { + await component.ngOnInit(); + + expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled(); + expect(component.session).toBe(mockSession); + }); + }); + + describe("closeModal", () => { + it("should close modal and notify session when session exists", async () => { + component.session = mockSession; + + await component.closeModal(); + + expect(mockDesktopSettingsService.setModalMode).toHaveBeenCalledWith(false); + expect(mockAccountService.setShowHeader).toHaveBeenCalledWith(true); + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + }); + }); +}); diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts new file mode 100644 index 00000000000..049771c2252 --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-excluded-ciphers.component.ts @@ -0,0 +1,78 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BitwardenShield, NoResults } from "@bitwarden/assets/svg"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { + BadgeModule, + ButtonModule, + DialogModule, + IconModule, + ItemModule, + SectionComponent, + TableModule, + SectionHeaderComponent, + BitIconButtonComponent, +} from "@bitwarden/components"; + +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-excluded-ciphers.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Fido2ExcludedCiphersComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + readonly Icons = { BitwardenShield, NoResults }; + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly accountService: AccountService, + private readonly router: Router, + ) {} + + async ngOnInit(): Promise { + this.session = this.fido2UserInterfaceService.getCurrentSession(); + } + + async ngOnDestroy(): Promise { + await this.closeModal(); + } + + async closeModal(): Promise { + // Clean up modal state + await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); + + // Clean up session state + if (this.session) { + this.session.notifyConfirmCreateCredential(false); + this.session.confirmChosenCipher(null); + } + + // Navigate away + await this.router.navigate(["/"]); + } +} diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html new file mode 100644 index 00000000000..ed04993d09f --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.html @@ -0,0 +1,37 @@ +
+ + +
+ + +

{{ "passkeyLogin" | i18n }}

+
+ +
+
+ + + + + {{ c.subTitle }} + {{ "select" | i18n }} + + + +
diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts new file mode 100644 index 00000000000..70ef4461f6a --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.spec.ts @@ -0,0 +1,196 @@ +import { NO_ERRORS_SCHEMA } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { Router } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService, Account } 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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType, CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +import { Fido2VaultComponent } from "./fido2-vault.component"; + +describe("Fido2VaultComponent", () => { + let component: Fido2VaultComponent; + let fixture: ComponentFixture; + let mockDesktopSettingsService: MockProxy; + let mockFido2UserInterfaceService: MockProxy; + let mockCipherService: MockProxy; + let mockAccountService: MockProxy; + let mockLogService: MockProxy; + let mockPasswordRepromptService: MockProxy; + let mockRouter: MockProxy; + let mockSession: MockProxy; + let mockI18nService: MockProxy; + + const mockActiveAccount = { id: "test-user-id", email: "test@example.com" }; + const mockCipherIds = ["cipher-1", "cipher-2", "cipher-3"]; + + beforeEach(async () => { + mockDesktopSettingsService = mock(); + mockFido2UserInterfaceService = mock(); + mockCipherService = mock(); + mockAccountService = mock(); + mockLogService = mock(); + mockPasswordRepromptService = mock(); + mockRouter = mock(); + mockSession = mock(); + mockI18nService = mock(); + + mockAccountService.activeAccount$ = of(mockActiveAccount as Account); + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(mockSession); + mockSession.availableCipherIds$ = of(mockCipherIds); + mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of([])); + + await TestBed.configureTestingModule({ + imports: [Fido2VaultComponent], + providers: [ + { provide: DesktopSettingsService, useValue: mockDesktopSettingsService }, + { provide: DesktopFido2UserInterfaceService, useValue: mockFido2UserInterfaceService }, + { provide: CipherService, useValue: mockCipherService }, + { provide: AccountService, useValue: mockAccountService }, + { provide: LogService, useValue: mockLogService }, + { provide: PasswordRepromptService, useValue: mockPasswordRepromptService }, + { provide: Router, useValue: mockRouter }, + { provide: I18nService, useValue: mockI18nService }, + ], + schemas: [NO_ERRORS_SCHEMA], + }).compileComponents(); + + fixture = TestBed.createComponent(Fido2VaultComponent); + component = fixture.componentInstance; + }); + + const mockCiphers: any[] = [ + { + id: "cipher-1", + name: "Test Cipher 1", + type: CipherType.Login, + login: { + username: "test1@example.com", + }, + reprompt: CipherRepromptType.None, + deletedDate: null, + }, + { + id: "cipher-2", + name: "Test Cipher 2", + type: CipherType.Login, + login: { + username: "test2@example.com", + }, + reprompt: CipherRepromptType.None, + deletedDate: null, + }, + { + id: "cipher-3", + name: "Test Cipher 3", + type: CipherType.Login, + login: { + username: "test3@example.com", + }, + reprompt: CipherRepromptType.Password, + deletedDate: null, + }, + ]; + + describe("ngOnInit", () => { + it("should initialize session and load ciphers successfully", async () => { + mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(mockCiphers)); + + await component.ngOnInit(); + + expect(mockFido2UserInterfaceService.getCurrentSession).toHaveBeenCalled(); + expect(component.session).toBe(mockSession); + expect(component.cipherIds$).toBe(mockSession.availableCipherIds$); + expect(mockCipherService.cipherListViews$).toHaveBeenCalledWith(mockActiveAccount.id); + }); + + it("should handle when no active session found", async () => { + mockFido2UserInterfaceService.getCurrentSession.mockReturnValue(null); + + await component.ngOnInit(); + + expect(component.session).toBeNull(); + }); + + it("should filter out deleted ciphers", async () => { + const ciphersWithDeleted = [ + ...mockCiphers.slice(0, 1), + { ...mockCiphers[1], deletedDate: new Date() }, + ...mockCiphers.slice(2), + ]; + mockCipherService.cipherListViews$ = jest.fn().mockReturnValue(of(ciphersWithDeleted)); + + await component.ngOnInit(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + let ciphersResult: CipherView[] = []; + component.ciphers$.subscribe((ciphers) => { + ciphersResult = ciphers; + }); + + expect(ciphersResult).toHaveLength(2); + expect(ciphersResult.every((cipher) => !cipher.deletedDate)).toBe(true); + }); + }); + + describe("chooseCipher", () => { + const cipher = mockCiphers[0]; + + beforeEach(() => { + component.session = mockSession; + }); + + it("should choose cipher when access is validated", async () => { + cipher.reprompt = CipherRepromptType.None; + + await component.chooseCipher(cipher); + + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true); + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + }); + + it("should prompt for password when cipher requires reprompt", async () => { + cipher.reprompt = CipherRepromptType.Password; + mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(true); + + await component.chooseCipher(cipher); + + expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled(); + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, true); + }); + + it("should not choose cipher when password reprompt is cancelled", async () => { + cipher.reprompt = CipherRepromptType.Password; + mockPasswordRepromptService.showPasswordPrompt.mockResolvedValue(false); + + await component.chooseCipher(cipher); + + expect(mockPasswordRepromptService.showPasswordPrompt).toHaveBeenCalled(); + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(cipher.id, false); + }); + }); + + describe("closeModal", () => { + it("should close modal and notify session", async () => { + component.session = mockSession; + + await component.closeModal(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(["/"]); + expect(mockSession.notifyConfirmCreateCredential).toHaveBeenCalledWith(false); + expect(mockSession.confirmChosenCipher).toHaveBeenCalledWith(null); + }); + }); +}); diff --git a/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts new file mode 100644 index 00000000000..897e825c53e --- /dev/null +++ b/apps/desktop/src/autofill/modal/credentials/fido2-vault.component.ts @@ -0,0 +1,161 @@ +import { CommonModule } from "@angular/common"; +import { ChangeDetectionStrategy, Component, OnInit, OnDestroy } from "@angular/core"; +import { RouterModule, Router } from "@angular/router"; +import { + firstValueFrom, + map, + combineLatest, + of, + BehaviorSubject, + Observable, + Subject, + takeUntil, +} from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { BitwardenShield } from "@bitwarden/assets/svg"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; +import { CipherRepromptType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { + BadgeModule, + ButtonModule, + DialogModule, + DialogService, + IconModule, + ItemModule, + SectionComponent, + TableModule, + BitIconButtonComponent, + SectionHeaderComponent, +} from "@bitwarden/components"; +import { PasswordRepromptService } from "@bitwarden/vault"; + +import { DesktopSettingsService } from "../../../platform/services/desktop-settings.service"; +import { + DesktopFido2UserInterfaceService, + DesktopFido2UserInterfaceSession, +} from "../../services/desktop-fido2-user-interface.service"; + +@Component({ + standalone: true, + imports: [ + CommonModule, + RouterModule, + SectionHeaderComponent, + BitIconButtonComponent, + TableModule, + JslibModule, + IconModule, + ButtonModule, + DialogModule, + SectionComponent, + ItemModule, + BadgeModule, + ], + templateUrl: "fido2-vault.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class Fido2VaultComponent implements OnInit, OnDestroy { + session?: DesktopFido2UserInterfaceSession = null; + private destroy$ = new Subject(); + private ciphersSubject = new BehaviorSubject([]); + ciphers$: Observable = this.ciphersSubject.asObservable(); + cipherIds$: Observable | undefined; + readonly Icons = { BitwardenShield }; + + constructor( + private readonly desktopSettingsService: DesktopSettingsService, + private readonly fido2UserInterfaceService: DesktopFido2UserInterfaceService, + private readonly cipherService: CipherService, + private readonly accountService: AccountService, + private readonly dialogService: DialogService, + private readonly logService: LogService, + private readonly passwordRepromptService: PasswordRepromptService, + private readonly router: Router, + ) {} + + async ngOnInit(): Promise { + this.session = this.fido2UserInterfaceService.getCurrentSession(); + this.cipherIds$ = this.session?.availableCipherIds$; + await this.loadCiphers(); + } + + async ngOnDestroy(): Promise { + this.destroy$.next(); + this.destroy$.complete(); + } + + async chooseCipher(cipher: CipherView): Promise { + if (!this.session) { + await this.dialogService.openSimpleDialog({ + title: { key: "unexpectedErrorShort" }, + content: { key: "closeThisBitwardenWindow" }, + type: "danger", + acceptButtonText: { key: "closeThisWindow" }, + cancelButtonText: null, + }); + await this.closeModal(); + + return; + } + + const isConfirmed = await this.validateCipherAccess(cipher); + this.session.confirmChosenCipher(cipher.id, isConfirmed); + + await this.closeModal(); + } + + async closeModal(): Promise { + await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); + + if (this.session) { + this.session.notifyConfirmCreateCredential(false); + this.session.confirmChosenCipher(null); + } + + await this.router.navigate(["/"]); + } + + private async loadCiphers(): Promise { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(map((a) => a?.id)), + ); + + if (!activeUserId) { + return; + } + + // Combine cipher list with optional cipher IDs filter + combineLatest([this.cipherService.cipherListViews$(activeUserId), this.cipherIds$ || of(null)]) + .pipe( + map(([ciphers, cipherIds]) => { + // Filter out deleted ciphers + const activeCiphers = ciphers.filter((cipher) => !cipher.deletedDate); + + // If specific IDs provided, filter by them + if (cipherIds?.length > 0) { + return activeCiphers.filter((cipher) => cipherIds.includes(cipher.id as string)); + } + + return activeCiphers; + }), + takeUntil(this.destroy$), + ) + .subscribe({ + next: (ciphers) => this.ciphersSubject.next(ciphers as CipherView[]), + error: (error: unknown) => this.logService.error("Failed to load ciphers", error), + }); + } + + private async validateCipherAccess(cipher: CipherView): Promise { + if (cipher.reprompt !== CipherRepromptType.None) { + return this.passwordRepromptService.showPasswordPrompt(); + } + + return true; + } +} diff --git a/apps/desktop/src/autofill/preload.ts b/apps/desktop/src/autofill/preload.ts index e839ac223b7..6a7a8459ea9 100644 --- a/apps/desktop/src/autofill/preload.ts +++ b/apps/desktop/src/autofill/preload.ts @@ -12,6 +12,8 @@ export default { runCommand: (params: RunCommandParams): Promise> => ipcRenderer.invoke("autofill.runCommand", params), + listenerReady: () => ipcRenderer.send("autofill.listenerReady"), + listenPasskeyRegistration: ( fn: ( clientId: number, @@ -130,6 +132,25 @@ export default { }, ); }, + + listenNativeStatus: ( + fn: (clientId: number, sequenceNumber: number, status: { key: string; value: string }) => void, + ) => { + ipcRenderer.on( + "autofill.nativeStatus", + ( + event, + data: { + clientId: number; + sequenceNumber: number; + status: { key: string; value: string }; + }, + ) => { + const { clientId, sequenceNumber, status } = data; + fn(clientId, sequenceNumber, status); + }, + ); + }, configureAutotype: (enabled: boolean, keyboardShortcut: string[]) => { ipcRenderer.send("autofill.configureAutotype", { enabled, keyboardShortcut }); }, diff --git a/apps/desktop/src/autofill/services/desktop-autofill.service.ts b/apps/desktop/src/autofill/services/desktop-autofill.service.ts index 18f4652d72a..1f58b03dbda 100644 --- a/apps/desktop/src/autofill/services/desktop-autofill.service.ts +++ b/apps/desktop/src/autofill/services/desktop-autofill.service.ts @@ -1,6 +1,8 @@ import { Injectable, OnDestroy } from "@angular/core"; import { Subject, + combineLatest, + debounceTime, distinctUntilChanged, filter, firstValueFrom, @@ -8,10 +10,11 @@ import { mergeMap, switchMap, takeUntil, - EMPTY, } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; +import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service"; import { DeviceType } from "@bitwarden/common/enums"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; @@ -48,6 +51,7 @@ import type { NativeWindowObject } from "./desktop-fido2-user-interface.service" @Injectable() export class DesktopAutofillService implements OnDestroy { private destroy$ = new Subject(); + private registrationRequest: autofill.PasskeyRegistrationRequest; constructor( private logService: LogService, @@ -55,6 +59,7 @@ export class DesktopAutofillService implements OnDestroy { private configService: ConfigService, private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction, private accountService: AccountService, + private authService: AuthService, private platformUtilsService: PlatformUtilsService, ) {} @@ -68,28 +73,56 @@ export class DesktopAutofillService implements OnDestroy { .getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync) .pipe( distinctUntilChanged(), - switchMap((enabled) => { - if (!enabled) { - return EMPTY; - } - - return this.accountService.activeAccount$.pipe( - map((account) => account?.id), - filter((userId): userId is UserId => userId != null), - switchMap((userId) => this.cipherService.cipherViews$(userId)), + filter((enabled) => enabled === true), // Only proceed if feature is enabled + switchMap(() => { + return combineLatest([ + this.accountService.activeAccount$.pipe( + map((account) => account?.id), + filter((userId): userId is UserId => userId != null), + ), + this.authService.activeAccountStatus$, + ]).pipe( + // Only proceed when the vault is unlocked + filter(([, status]) => status === AuthenticationStatus.Unlocked), + // Then get cipher views + switchMap(([userId]) => this.cipherService.cipherViews$(userId)), ); }), - // TODO: This will unset all the autofill credentials on the OS - // when the account locks. We should instead explicilty clear the credentials - // when the user logs out. Maybe by subscribing to the encrypted ciphers observable instead. + debounceTime(100), // just a precaution to not spam the sync if there are multiple changes (we typically observe a null change) + // No filter for empty arrays here - we want to sync even if there are 0 items + filter((cipherViewMap) => cipherViewMap !== null), + mergeMap((cipherViewMap) => this.sync(Object.values(cipherViewMap ?? []))), takeUntil(this.destroy$), ) .subscribe(); + // Listen for sign out to clear credentials + this.authService.activeAccountStatus$ + .pipe( + filter((status) => status === AuthenticationStatus.LoggedOut), + mergeMap(() => this.sync([])), // sync an empty array + takeUntil(this.destroy$), + ) + .subscribe(); + this.listenIpc(); } + async adHocSync(): Promise { + this.logService.debug("Performing AdHoc sync"); + const account = await firstValueFrom(this.accountService.activeAccount$); + const userId = account?.id; + + if (!userId) { + throw new Error("No active user found"); + } + + const cipherViewMap = await firstValueFrom(this.cipherService.cipherViews$(userId)); + this.logService.info("Performing AdHoc sync", Object.values(cipherViewMap ?? [])); + await this.sync(Object.values(cipherViewMap ?? [])); + } + /** Give metadata about all available credentials in the users vault */ async sync(cipherViews: CipherView[]) { const status = await this.status(); @@ -130,6 +163,11 @@ export class DesktopAutofillService implements OnDestroy { })); } + this.logService.info("Syncing autofill credentials", { + fido2Credentials, + passwordCredentials, + }); + const syncResult = await ipc.autofill.runCommand({ namespace: "autofill", command: "sync", @@ -155,107 +193,152 @@ export class DesktopAutofillService implements OnDestroy { }); } + get lastRegistrationRequest() { + return this.registrationRequest; + } + listenIpc() { - ipc.autofill.listenPasskeyRegistration((clientId, sequenceNumber, request, callback) => { - this.logService.warning("listenPasskeyRegistration", clientId, sequenceNumber, request); - this.logService.warning( - "listenPasskeyRegistration2", - this.convertRegistrationRequest(request), - ); + ipc.autofill.listenPasskeyRegistration(async (clientId, sequenceNumber, request, callback) => { + if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) { + this.logService.debug( + "listenPasskeyRegistration: MacOsNativeCredentialSync feature flag is disabled", + ); + callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null); + return; + } + + this.registrationRequest = request; + + this.logService.debug("listenPasskeyRegistration", clientId, sequenceNumber, request); + this.logService.debug("listenPasskeyRegistration2", this.convertRegistrationRequest(request)); const controller = new AbortController(); - void this.fido2AuthenticatorService - .makeCredential( + + try { + const response = await this.fido2AuthenticatorService.makeCredential( this.convertRegistrationRequest(request), - { windowXy: request.windowXy }, + { windowXy: normalizePosition(request.windowXy) }, controller, - ) - .then((response) => { - callback(null, this.convertRegistrationResponse(request, response)); - }) - .catch((error) => { - this.logService.error("listenPasskeyRegistration error", error); - callback(error, null); - }); + ); + + callback(null, this.convertRegistrationResponse(request, response)); + } catch (error) { + this.logService.error("listenPasskeyRegistration error", error); + callback(error, null); + } }); ipc.autofill.listenPasskeyAssertionWithoutUserInterface( async (clientId, sequenceNumber, request, callback) => { - this.logService.warning( + if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) { + this.logService.debug( + "listenPasskeyAssertionWithoutUserInterface: MacOsNativeCredentialSync feature flag is disabled", + ); + callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null); + return; + } + + this.logService.debug( "listenPasskeyAssertion without user interface", clientId, sequenceNumber, request, ); - // For some reason the credentialId is passed as an empty array in the request, so we need to - // get it from the cipher. For that we use the recordIdentifier, which is the cipherId. - if (request.recordIdentifier && request.credentialId.length === 0) { - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(getOptionalUserId), - ); - if (!activeUserId) { - this.logService.error("listenPasskeyAssertion error", "Active user not found"); - callback(new Error("Active user not found"), null); - return; - } - - const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId); - if (!cipher) { - this.logService.error("listenPasskeyAssertion error", "Cipher not found"); - callback(new Error("Cipher not found"), null); - return; - } - - const decrypted = await this.cipherService.decrypt(cipher, activeUserId); - - const fido2Credential = decrypted.login.fido2Credentials?.[0]; - if (!fido2Credential) { - this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found"); - callback(new Error("Fido2Credential not found"), null); - return; - } - - request.credentialId = Array.from( - new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)), - ); - } - const controller = new AbortController(); - void this.fido2AuthenticatorService - .getAssertion( - this.convertAssertionRequest(request), - { windowXy: request.windowXy }, + + try { + // For some reason the credentialId is passed as an empty array in the request, so we need to + // get it from the cipher. For that we use the recordIdentifier, which is the cipherId. + if (request.recordIdentifier && request.credentialId.length === 0) { + const activeUserId = await firstValueFrom( + this.accountService.activeAccount$.pipe(getOptionalUserId), + ); + if (!activeUserId) { + this.logService.error("listenPasskeyAssertion error", "Active user not found"); + callback(new Error("Active user not found"), null); + return; + } + + const cipher = await this.cipherService.get(request.recordIdentifier, activeUserId); + if (!cipher) { + this.logService.error("listenPasskeyAssertion error", "Cipher not found"); + callback(new Error("Cipher not found"), null); + return; + } + + const decrypted = await this.cipherService.decrypt(cipher, activeUserId); + + const fido2Credential = decrypted.login.fido2Credentials?.[0]; + if (!fido2Credential) { + this.logService.error("listenPasskeyAssertion error", "Fido2Credential not found"); + callback(new Error("Fido2Credential not found"), null); + return; + } + + request.credentialId = Array.from( + new Uint8Array(parseCredentialId(decrypted.login.fido2Credentials?.[0].credentialId)), + ); + } + + const response = await this.fido2AuthenticatorService.getAssertion( + this.convertAssertionRequest(request, true), + { windowXy: normalizePosition(request.windowXy) }, controller, - ) - .then((response) => { - callback(null, this.convertAssertionResponse(request, response)); - }) - .catch((error) => { - this.logService.error("listenPasskeyAssertion error", error); - callback(error, null); - }); + ); + + callback(null, this.convertAssertionResponse(request, response)); + } catch (error) { + this.logService.error("listenPasskeyAssertion error", error); + callback(error, null); + return; + } }, ); ipc.autofill.listenPasskeyAssertion(async (clientId, sequenceNumber, request, callback) => { - this.logService.warning("listenPasskeyAssertion", clientId, sequenceNumber, request); + if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) { + this.logService.debug( + "listenPasskeyAssertion: MacOsNativeCredentialSync feature flag is disabled", + ); + callback(new Error("MacOsNativeCredentialSync feature flag is disabled"), null); + return; + } + + this.logService.debug("listenPasskeyAssertion", clientId, sequenceNumber, request); const controller = new AbortController(); - void this.fido2AuthenticatorService - .getAssertion( + try { + const response = await this.fido2AuthenticatorService.getAssertion( this.convertAssertionRequest(request), - { windowXy: request.windowXy }, + { windowXy: normalizePosition(request.windowXy) }, controller, - ) - .then((response) => { - callback(null, this.convertAssertionResponse(request, response)); - }) - .catch((error) => { - this.logService.error("listenPasskeyAssertion error", error); - callback(error, null); - }); + ); + + callback(null, this.convertAssertionResponse(request, response)); + } catch (error) { + this.logService.error("listenPasskeyAssertion error", error); + callback(error, null); + } }); + + // Listen for native status messages + ipc.autofill.listenNativeStatus(async (clientId, sequenceNumber, status) => { + if (!(await this.configService.getFeatureFlag(FeatureFlag.MacOsNativeCredentialSync))) { + this.logService.debug( + "listenNativeStatus: MacOsNativeCredentialSync feature flag is disabled", + ); + return; + } + + this.logService.info("Received native status", status.key, status.value); + if (status.key === "request-sync") { + // perform ad-hoc sync + await this.adHocSync(); + } + }); + + ipc.autofill.listenerReady(); } private convertRegistrationRequest( @@ -277,7 +360,10 @@ export class DesktopAutofillService implements OnDestroy { alg, type: "public-key", })), - excludeCredentialDescriptorList: [], + excludeCredentialDescriptorList: request.excludedCredentials.map((credentialId) => ({ + id: new Uint8Array(credentialId), + type: "public-key" as const, + })), requireResidentKey: true, requireUserVerification: request.userVerification === "required" || request.userVerification === "preferred", @@ -309,18 +395,19 @@ export class DesktopAutofillService implements OnDestroy { request: | autofill.PasskeyAssertionRequest | autofill.PasskeyAssertionWithoutUserInterfaceRequest, + assumeUserPresence: boolean = false, ): Fido2AuthenticatorGetAssertionParams { let allowedCredentials; if ("credentialId" in request) { allowedCredentials = [ { - id: new Uint8Array(request.credentialId), + id: new Uint8Array(request.credentialId).buffer, type: "public-key" as const, }, ]; } else { allowedCredentials = request.allowedCredentials.map((credentialId) => ({ - id: new Uint8Array(credentialId), + id: new Uint8Array(credentialId).buffer, type: "public-key" as const, })); } @@ -333,7 +420,7 @@ export class DesktopAutofillService implements OnDestroy { requireUserVerification: request.userVerification === "required" || request.userVerification === "preferred", fallbackSupported: false, - assumeUserPresence: true, // For desktop assertions, it's safe to assume UP has been checked by OS dialogues + assumeUserPresence, }; } @@ -358,3 +445,13 @@ export class DesktopAutofillService implements OnDestroy { this.destroy$.complete(); } } + +function normalizePosition(position: { x: number; y: number }): { x: number; y: number } { + // Add 100 pixels to the x-coordinate to offset the native OS dialog positioning. + const xPostionOffset = 100; + + return { + x: Math.round(position.x + xPostionOffset), + y: Math.round(position.y), + }; +} diff --git a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts index 3caf13fa5b7..19946ab590c 100644 --- a/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts +++ b/apps/desktop/src/autofill/services/desktop-fido2-user-interface.service.ts @@ -66,7 +66,7 @@ export class DesktopFido2UserInterfaceService nativeWindowObject: NativeWindowObject, abortController?: AbortController, ): Promise { - this.logService.warning("newSession", fallbackSupported, abortController, nativeWindowObject); + this.logService.debug("newSession", fallbackSupported, abortController, nativeWindowObject); const session = new DesktopFido2UserInterfaceSession( this.authService, this.cipherService, @@ -94,9 +94,11 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi ) {} private confirmCredentialSubject = new Subject(); - private createdCipher: Cipher; - private availableCipherIdsSubject = new BehaviorSubject(null); + private updatedCipher: CipherView; + + private rpId = new BehaviorSubject(null); + private availableCipherIdsSubject = new BehaviorSubject([""]); /** * Observable that emits available cipher IDs once they're confirmed by the UI */ @@ -114,7 +116,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi assumeUserPresence, masterPasswordRepromptRequired, }: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { - this.logService.warning("pickCredential desktop function", { + this.logService.debug("pickCredential desktop function", { cipherIds, userVerification, assumeUserPresence, @@ -123,6 +125,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi try { // Check if we can return the credential without user interaction + await this.accountService.setShowHeader(false); if (assumeUserPresence && cipherIds.length === 1 && !masterPasswordRepromptRequired) { this.logService.debug( "shortcut - Assuming user presence and returning cipherId", @@ -136,22 +139,27 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi // make the cipherIds available to the UI. this.availableCipherIdsSubject.next(cipherIds); - await this.showUi("/passkeys", this.windowObject.windowXy); + await this.showUi("/fido2-assertion", this.windowObject.windowXy, false); const chosenCipherResponse = await this.waitForUiChosenCipher(); this.logService.debug("Received chosen cipher", chosenCipherResponse); return { - cipherId: chosenCipherResponse.cipherId, - userVerified: chosenCipherResponse.userVerified, + cipherId: chosenCipherResponse?.cipherId, + userVerified: chosenCipherResponse?.userVerified, }; } finally { // Make sure to clean up so the app is never stuck in modal mode? await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); } } + async getRpId(): Promise { + return firstValueFrom(this.rpId.pipe(filter((id) => id != null))); + } + confirmChosenCipher(cipherId: string, userVerified: boolean = false): void { this.chosenCipherSubject.next({ cipherId, userVerified }); this.chosenCipherSubject.complete(); @@ -159,7 +167,7 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi private async waitForUiChosenCipher( timeoutMs: number = 60000, - ): Promise<{ cipherId: string; userVerified: boolean } | undefined> { + ): Promise<{ cipherId?: string; userVerified: boolean } | undefined> { try { return await lastValueFrom(this.chosenCipherSubject.pipe(timeout(timeoutMs))); } catch { @@ -174,7 +182,10 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi /** * Notifies the Fido2UserInterfaceSession that the UI operations has completed and it can return to the OS. */ - notifyConfirmNewCredential(confirmed: boolean): void { + notifyConfirmCreateCredential(confirmed: boolean, updatedCipher?: CipherView): void { + if (updatedCipher) { + this.updatedCipher = updatedCipher; + } this.confirmCredentialSubject.next(confirmed); this.confirmCredentialSubject.complete(); } @@ -195,60 +206,79 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi async confirmNewCredential({ credentialName, userName, + userHandle, userVerification, rpId, }: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> { - this.logService.warning( + this.logService.debug( "confirmNewCredential", credentialName, userName, + userHandle, userVerification, rpId, ); + this.rpId.next(rpId); try { - await this.showUi("/passkeys", this.windowObject.windowXy); + await this.showUi("/fido2-creation", this.windowObject.windowXy, false); // Wait for the UI to wrap up const confirmation = await this.waitForUiNewCredentialConfirmation(); if (!confirmation) { return { cipherId: undefined, userVerified: false }; } - // Create the credential - await this.createCredential({ - credentialName, - userName, - rpId, - userHandle: "", - userVerification, - }); - // wait for 10ms to help RXJS catch up(?) - // We sometimes get a race condition from this.createCredential not updating cipherService in time - //console.log("waiting 10ms.."); - //await new Promise((resolve) => setTimeout(resolve, 10)); - //console.log("Just waited 10ms"); - - // Return the new cipher (this.createdCipher) - return { cipherId: this.createdCipher.id, userVerified: userVerification }; + if (this.updatedCipher) { + await this.updateCredential(this.updatedCipher); + return { cipherId: this.updatedCipher.id, userVerified: userVerification }; + } else { + // Create the cipher + const createdCipher = await this.createCipher({ + credentialName, + userName, + rpId, + userHandle, + userVerification, + }); + return { cipherId: createdCipher.id, userVerified: userVerification }; + } } finally { // Make sure to clean up so the app is never stuck in modal mode? await this.desktopSettingsService.setModalMode(false); + await this.accountService.setShowHeader(true); } } - private async showUi(route: string, position?: { x: number; y: number }): Promise { + private async hideUi(): Promise { + await this.desktopSettingsService.setModalMode(false); + await this.router.navigate(["/"]); + } + + private async showUi( + route: string, + position?: { x: number; y: number }, + showTrafficButtons: boolean = false, + disableRedirect?: boolean, + ): Promise { // Load the UI: - await this.desktopSettingsService.setModalMode(true, position); - await this.router.navigate(["/passkeys"]); + await this.desktopSettingsService.setModalMode(true, showTrafficButtons, position); + await this.accountService.setShowHeader(showTrafficButtons); + await this.router.navigate([ + route, + { + "disable-redirect": disableRedirect || null, + }, + ]); } /** - * Can be called by the UI to create a new credential with user input etc. + * Can be called by the UI to create a new cipher with user input etc. * @param param0 */ - async createCredential({ credentialName, userName, rpId }: NewCredentialParams): Promise { + async createCipher({ credentialName, userName, rpId }: NewCredentialParams): Promise { // Store the passkey on a new cipher to avoid replacing something important + const cipher = new CipherView(); cipher.name = credentialName; @@ -267,32 +297,81 @@ export class DesktopFido2UserInterfaceSession implements Fido2UserInterfaceSessi this.accountService.activeAccount$.pipe(map((a) => a?.id)), ); + if (!activeUserId) { + throw new Error("No active user ID found!"); + } + const encCipher = await this.cipherService.encrypt(cipher, activeUserId); - const createdCipher = await this.cipherService.createWithServer(encCipher); - this.createdCipher = createdCipher; + try { + const createdCipher = await this.cipherService.createWithServer(encCipher); - return createdCipher; + return createdCipher; + } catch { + throw new Error("Unable to create cipher"); + } + } + + async updateCredential(cipher: CipherView): Promise { + this.logService.info("updateCredential"); + await firstValueFrom( + this.accountService.activeAccount$.pipe( + map(async (a) => { + if (a) { + const encCipher = await this.cipherService.encrypt(cipher, a.id); + await this.cipherService.updateWithServer(encCipher); + } + }), + ), + ); } async informExcludedCredential(existingCipherIds: string[]): Promise { - this.logService.warning("informExcludedCredential", existingCipherIds); + this.logService.debug("informExcludedCredential", existingCipherIds); + + // make the cipherIds available to the UI. + this.availableCipherIdsSubject.next(existingCipherIds); + + await this.accountService.setShowHeader(false); + await this.showUi("/fido2-excluded", this.windowObject.windowXy, false); } async ensureUnlockedVault(): Promise { - this.logService.warning("ensureUnlockedVault"); + this.logService.debug("ensureUnlockedVault"); const status = await firstValueFrom(this.authService.activeAccountStatus$); if (status !== AuthenticationStatus.Unlocked) { - throw new Error("Vault is not unlocked"); + await this.showUi("/lock", this.windowObject.windowXy, true, true); + + let status2: AuthenticationStatus; + try { + status2 = await lastValueFrom( + this.authService.activeAccountStatus$.pipe( + filter((s) => s === AuthenticationStatus.Unlocked), + take(1), + timeout(1000 * 60 * 5), // 5 minutes + ), + ); + } catch (error) { + this.logService.warning("Error while waiting for vault to unlock", error); + } + + if (status2 === AuthenticationStatus.Unlocked) { + await this.router.navigate(["/"]); + } + + if (status2 !== AuthenticationStatus.Unlocked) { + await this.hideUi(); + throw new Error("Vault is not unlocked"); + } } } async informCredentialNotFound(): Promise { - this.logService.warning("informCredentialNotFound"); + this.logService.debug("informCredentialNotFound"); } async close() { - this.logService.warning("close"); + this.logService.debug("close"); } } diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index 86df61940d1..ba5bbb94d59 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -908,6 +908,12 @@ "unexpectedError": { "message": "An unexpected error has occurred." }, + "unexpectedErrorShort": { + "message": "Unexpected error" + }, + "closeThisBitwardenWindow": { + "message": "Close this Bitwarden window and try again." + }, "itemInformation": { "message": "Item information" }, @@ -3356,7 +3362,7 @@ "orgTrustWarning1": { "message": "This organization has an Enterprise policy that will enroll you in account recovery. Enrollment will allow organization administrators to change your password. Only proceed if you recognize this organization and the fingerprint phrase displayed below matches the organization's fingerprint." }, - "trustUser":{ + "trustUser": { "message": "Trust user" }, "inputRequired": { @@ -3886,6 +3892,75 @@ "fileSavedToDevice": { "message": "File saved to device. Manage from your device downloads." }, + "importantNotice": { + "message": "Important notice" + }, + "setupTwoStepLogin": { + "message": "Set up two-step login" + }, + "newDeviceVerificationNoticeContentPage1": { + "message": "Bitwarden will send a code to your account email to verify logins from new devices starting in February 2025." + }, + "newDeviceVerificationNoticeContentPage2": { + "message": "You can set up two-step login as an alternative way to protect your account or change your email to one you can access." + }, + "remindMeLater": { + "message": "Remind me later" + }, + "newDeviceVerificationNoticePageOneFormContent": { + "message": "Do you have reliable access to your email, $EMAIL$?", + "placeholders": { + "email": { + "content": "$1", + "example": "your_name@email.com" + } + } + }, + "newDeviceVerificationNoticePageOneEmailAccessNo": { + "message": "No, I do not" + }, + "newDeviceVerificationNoticePageOneEmailAccessYes": { + "message": "Yes, I can reliably access my email" + }, + "turnOnTwoStepLogin": { + "message": "Turn on two-step login" + }, + "changeAcctEmail": { + "message": "Change account email" + }, + "passkeyLogin": { + "message": "Log in with passkey?" + }, + "savePasskeyQuestion": { + "message": "Save passkey?" + }, + "saveNewPasskey": { + "message": "Save as new login" + }, + "savePasskeyNewLogin": { + "message": "Save passkey as new login" + }, + "noMatchingLoginsForSite": { + "message": "No matching logins for this site" + }, + "overwritePasskey": { + "message": "Overwrite passkey?" + }, + "unableToSavePasskey": { + "message": "Unable to save passkey" + }, + "alreadyContainsPasskey": { + "message": "This item already contains a passkey. Are you sure you want to overwrite the current passkey?" + }, + "passkeyAlreadyExists": { + "message": "A passkey already exists for this application." + }, + "applicationDoesNotSupportDuplicates": { + "message": "This application does not support duplicates." + }, + "closeThisWindow": { + "message": "Close this window" + }, "allowScreenshots": { "message": "Allow screen capture" }, diff --git a/apps/desktop/src/main/tray.main.ts b/apps/desktop/src/main/tray.main.ts index b7ddefe6e1b..81df6497ca8 100644 --- a/apps/desktop/src/main/tray.main.ts +++ b/apps/desktop/src/main/tray.main.ts @@ -53,9 +53,14 @@ export class TrayMain { }, { visible: isDev(), - label: "Fake Popup", + label: "Fake Popup Select", click: () => this.fakePopup(), }, + { + visible: isDev(), + label: "Fake Popup Create", + click: () => this.fakePopupCreate(), + }, { type: "separator" }, { label: this.i18nService.t("exit"), @@ -218,4 +223,8 @@ export class TrayMain { private async fakePopup() { await this.messagingService.send("loadurl", { url: "/passkeys", modal: true }); } + + private async fakePopupCreate() { + await this.messagingService.send("loadurl", { url: "/create-passkey", modal: true }); + } } diff --git a/apps/desktop/src/main/window.main.ts b/apps/desktop/src/main/window.main.ts index 0e234126ea3..bbdd2ad0a0f 100644 --- a/apps/desktop/src/main/window.main.ts +++ b/apps/desktop/src/main/window.main.ts @@ -100,10 +100,10 @@ export class WindowMain { applyMainWindowStyles(this.win, this.windowStates[mainWindowSizeKey]); // Because modal is used in front of another app, UX wise it makes sense to hide the main window when leaving modal mode. this.win.hide(); - } else if (!lastValue.isModalModeActive && newValue.isModalModeActive) { + } else if (newValue.isModalModeActive) { // Apply the popup modal styles this.logService.info("Applying popup modal styles", newValue.modalPosition); - applyPopupModalStyles(this.win, newValue.modalPosition); + applyPopupModalStyles(this.win, newValue.showTrafficButtons, newValue.modalPosition); this.win.show(); } }), @@ -273,7 +273,7 @@ export class WindowMain { this.win = new BrowserWindow({ width: this.windowStates[mainWindowSizeKey].width, height: this.windowStates[mainWindowSizeKey].height, - minWidth: 680, + minWidth: 600, minHeight: 500, x: this.windowStates[mainWindowSizeKey].x, y: this.windowStates[mainWindowSizeKey].y, diff --git a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts index 71cfcab84ba..7ecd7c2e9e5 100644 --- a/apps/desktop/src/platform/main/autofill/native-autofill.main.ts +++ b/apps/desktop/src/platform/main/autofill/native-autofill.main.ts @@ -7,6 +7,11 @@ import { WindowMain } from "../../../main/window.main"; import { CommandDefinition } from "./command"; +type BufferedMessage = { + channel: string; + data: any; +}; + export type RunCommandParams = { namespace: C["namespace"]; command: C["name"]; @@ -17,12 +22,43 @@ export type RunCommandResult = C["output"]; export class NativeAutofillMain { private ipcServer: autofill.IpcServer | null; + private messageBuffer: BufferedMessage[] = []; + private listenerReady = false; constructor( private logService: LogService, private windowMain: WindowMain, ) {} + /** + * Safely sends a message to the renderer, buffering it if the server isn't ready yet + */ + private safeSend(channel: string, data: any) { + if (this.listenerReady && this.windowMain.win?.webContents) { + this.windowMain.win.webContents.send(channel, data); + } else { + this.messageBuffer.push({ channel, data }); + } + } + + /** + * Flushes all buffered messages to the renderer + */ + private flushMessageBuffer() { + if (!this.windowMain.win?.webContents) { + this.logService.error("Cannot flush message buffer - window not available"); + return; + } + + this.logService.info(`Flushing ${this.messageBuffer.length} buffered messages`); + + for (const { channel, data } of this.messageBuffer) { + this.windowMain.win.webContents.send(channel, data); + } + + this.messageBuffer = []; + } + async init() { ipcMain.handle( "autofill.runCommand", @@ -43,7 +79,7 @@ export class NativeAutofillMain { this.ipcServer.completeError(clientId, sequenceNumber, String(error)); return; } - this.windowMain.win.webContents.send("autofill.passkeyRegistration", { + this.safeSend("autofill.passkeyRegistration", { clientId, sequenceNumber, request, @@ -56,7 +92,7 @@ export class NativeAutofillMain { this.ipcServer.completeError(clientId, sequenceNumber, String(error)); return; } - this.windowMain.win.webContents.send("autofill.passkeyAssertion", { + this.safeSend("autofill.passkeyAssertion", { clientId, sequenceNumber, request, @@ -69,28 +105,49 @@ export class NativeAutofillMain { this.ipcServer.completeError(clientId, sequenceNumber, String(error)); return; } - this.windowMain.win.webContents.send("autofill.passkeyAssertionWithoutUserInterface", { + this.safeSend("autofill.passkeyAssertionWithoutUserInterface", { clientId, sequenceNumber, request, }); }, + // NativeStatusCallback + (error, clientId, sequenceNumber, status) => { + if (error) { + this.logService.error("autofill.IpcServer.nativeStatus", error); + this.ipcServer.completeError(clientId, sequenceNumber, String(error)); + return; + } + this.safeSend("autofill.nativeStatus", { + clientId, + sequenceNumber, + status, + }); + }, ); + ipcMain.on("autofill.listenerReady", () => { + this.listenerReady = true; + this.logService.info( + `Listener is ready, flushing ${this.messageBuffer.length} buffered messages`, + ); + this.flushMessageBuffer(); + }); + ipcMain.on("autofill.completePasskeyRegistration", (event, data) => { - this.logService.warning("autofill.completePasskeyRegistration", data); + this.logService.debug("autofill.completePasskeyRegistration", data); const { clientId, sequenceNumber, response } = data; this.ipcServer.completeRegistration(clientId, sequenceNumber, response); }); ipcMain.on("autofill.completePasskeyAssertion", (event, data) => { - this.logService.warning("autofill.completePasskeyAssertion", data); + this.logService.debug("autofill.completePasskeyAssertion", data); const { clientId, sequenceNumber, response } = data; this.ipcServer.completeAssertion(clientId, sequenceNumber, response); }); ipcMain.on("autofill.completeError", (event, data) => { - this.logService.warning("autofill.completeError", data); + this.logService.debug("autofill.completeError", data); const { clientId, sequenceNumber, error } = data; this.ipcServer.completeError(clientId, sequenceNumber, String(error)); }); diff --git a/apps/desktop/src/platform/models/domain/window-state.ts b/apps/desktop/src/platform/models/domain/window-state.ts index 0efc9a1efab..ab52531bb5d 100644 --- a/apps/desktop/src/platform/models/domain/window-state.ts +++ b/apps/desktop/src/platform/models/domain/window-state.ts @@ -14,5 +14,6 @@ export class WindowState { export class ModalModeState { isModalModeActive: boolean; + showTrafficButtons?: boolean; modalPosition?: { x: number; y: number }; // Modal position is often passed from the native UI } diff --git a/apps/desktop/src/platform/popup-modal-styles.ts b/apps/desktop/src/platform/popup-modal-styles.ts index 5c5619bd463..6ad00b44171 100644 --- a/apps/desktop/src/platform/popup-modal-styles.ts +++ b/apps/desktop/src/platform/popup-modal-styles.ts @@ -3,15 +3,19 @@ import { BrowserWindow } from "electron"; import { WindowState } from "./models/domain/window-state"; // change as needed, however limited by mainwindow minimum size -const popupWidth = 680; -const popupHeight = 500; +const popupWidth = 600; +const popupHeight = 600; type Position = { x: number; y: number }; -export function applyPopupModalStyles(window: BrowserWindow, position?: Position) { +export function applyPopupModalStyles( + window: BrowserWindow, + showTrafficButtons: boolean = true, + position?: Position, +) { window.unmaximize(); window.setSize(popupWidth, popupHeight); - window.setWindowButtonVisibility?.(false); + window.setWindowButtonVisibility?.(showTrafficButtons); window.setMenuBarVisibility?.(false); window.setResizable(false); window.setAlwaysOnTop(true); @@ -40,7 +44,7 @@ function positionWindow(window: BrowserWindow, position?: Position) { } export function applyMainWindowStyles(window: BrowserWindow, existingWindowState: WindowState) { - window.setMinimumSize(680, 500); + window.setMinimumSize(popupWidth, popupHeight); // need to guard against null/undefined values diff --git a/apps/desktop/src/platform/services/desktop-settings.service.ts b/apps/desktop/src/platform/services/desktop-settings.service.ts index c11f10646d7..d7c17433471 100644 --- a/apps/desktop/src/platform/services/desktop-settings.service.ts +++ b/apps/desktop/src/platform/services/desktop-settings.service.ts @@ -335,9 +335,14 @@ export class DesktopSettingsService { * Sets the modal mode of the application. Setting this changes the windows-size and other properties. * @param value `true` if the application is in modal mode, `false` if it is not. */ - async setModalMode(value: boolean, modalPosition?: { x: number; y: number }) { + async setModalMode( + value: boolean, + showTrafficButtons?: boolean, + modalPosition?: { x: number; y: number }, + ) { await this.modalModeState.update(() => ({ isModalModeActive: value, + showTrafficButtons, modalPosition, })); } diff --git a/eslint.config.mjs b/eslint.config.mjs index 881cd5e1f4d..e8f43d4a9ea 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -197,7 +197,7 @@ export default tseslint.config( { // uses negative lookahead to whitelist any class that doesn't start with "tw-" // in other words: classnames that start with tw- must be valid TailwindCSS classes - whitelist: ["(?!(tw)\\-).*"], + whitelist: ["(?!(tw)\\-).*", "tw-app-region-drag", "tw-app-region-no-drag"], }, ], "tailwindcss/enforces-negative-arbitrary-values": "error", @@ -349,6 +349,7 @@ export default tseslint.config( "file-selector", "mfaType.*", "filter.*", // Temporary until filters are migrated + "tw-app-region*", // Custom utility for native passkey modals "tw-@container", ], }, diff --git a/libs/common/spec/fake-account-service.ts b/libs/common/spec/fake-account-service.ts index ba48181faa2..389975dc2e1 100644 --- a/libs/common/spec/fake-account-service.ts +++ b/libs/common/spec/fake-account-service.ts @@ -37,6 +37,8 @@ export class FakeAccountService implements AccountService { accountActivitySubject = new ReplaySubject>(1); // eslint-disable-next-line rxjs/no-exposed-subjects -- test class accountVerifyDevicesSubject = new ReplaySubject(1); + // eslint-disable-next-line rxjs/no-exposed-subjects -- test class + showHeaderSubject = new ReplaySubject(1); private _activeUserId: UserId; get activeUserId() { return this._activeUserId; @@ -55,6 +57,7 @@ export class FakeAccountService implements AccountService { }), ); } + showHeader$ = this.showHeaderSubject.asObservable(); get nextUpAccount$(): Observable { return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe( map(([accounts, activeAccount, sortedUserIds]) => { @@ -114,6 +117,10 @@ export class FakeAccountService implements AccountService { this.accountsSubject.next(updated); await this.mock.clean(userId); } + + async setShowHeader(value: boolean): Promise { + this.showHeaderSubject.next(value); + } } const loggedOutInfo: AccountInfo = { diff --git a/libs/common/src/auth/abstractions/account.service.ts b/libs/common/src/auth/abstractions/account.service.ts index a3dabeecf8a..8b0280feb01 100644 --- a/libs/common/src/auth/abstractions/account.service.ts +++ b/libs/common/src/auth/abstractions/account.service.ts @@ -47,6 +47,8 @@ export abstract class AccountService { abstract sortedUserIds$: Observable; /** Next account that is not the current active account */ abstract nextUpAccount$: Observable; + /** Observable to display the header */ + abstract showHeader$: Observable; /** * Updates the `accounts$` observable with the new account data. * @@ -100,6 +102,11 @@ export abstract class AccountService { * @param lastActivity */ abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise; + /** + * Show the account switcher. + * @param value + */ + abstract setShowHeader(visible: boolean): Promise; } export abstract class InternalAccountService extends AccountService { diff --git a/libs/common/src/auth/services/account.service.spec.ts b/libs/common/src/auth/services/account.service.spec.ts index 3fc47002083..3e3c878eaac 100644 --- a/libs/common/src/auth/services/account.service.spec.ts +++ b/libs/common/src/auth/services/account.service.spec.ts @@ -429,6 +429,16 @@ describe("accountService", () => { }, ); }); + + describe("setShowHeader", () => { + it("should update _showHeader$ when setShowHeader is called", async () => { + expect(sut["_showHeader$"].value).toBe(true); + + await sut.setShowHeader(false); + + expect(sut["_showHeader$"].value).toBe(false); + }); + }); }); }); diff --git a/libs/common/src/auth/services/account.service.ts b/libs/common/src/auth/services/account.service.ts index 1be11b03461..fb4b590ce77 100644 --- a/libs/common/src/auth/services/account.service.ts +++ b/libs/common/src/auth/services/account.service.ts @@ -6,6 +6,7 @@ import { distinctUntilChanged, shareReplay, combineLatest, + BehaviorSubject, Observable, switchMap, filter, @@ -84,6 +85,7 @@ export const getOptionalUserId = map( export class AccountServiceImplementation implements InternalAccountService { private accountsState: GlobalState>; private activeAccountIdState: GlobalState; + private _showHeader$ = new BehaviorSubject(true); accounts$: Observable>; activeAccount$: Observable; @@ -91,6 +93,7 @@ export class AccountServiceImplementation implements InternalAccountService { accountVerifyNewDeviceLogin$: Observable; sortedUserIds$: Observable; nextUpAccount$: Observable; + showHeader$ = this._showHeader$.asObservable(); constructor( private messagingService: MessagingService, @@ -262,6 +265,10 @@ export class AccountServiceImplementation implements InternalAccountService { } } + async setShowHeader(visible: boolean): Promise { + this._showHeader$.next(visible); + } + private async setAccountInfo(userId: UserId, update: Partial): Promise { function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo { return { ...oldAccountInfo, ...update }; diff --git a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts index c34c4b835cf..427266522e9 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-authenticator.service.abstraction.ts @@ -138,7 +138,7 @@ export interface Fido2AuthenticatorGetAssertionParams { rpId: string; /** The hash of the serialized client data, provided by the client. */ hash: BufferSource; - allowCredentialDescriptorList: PublicKeyCredentialDescriptor[]; + allowCredentialDescriptorList?: PublicKeyCredentialDescriptor[]; /** The effective user verification requirement for assertion, a Boolean value provided by the client. */ requireUserVerification: boolean; /** The constant Boolean value true. It is included here as a pseudo-parameter to simplify applying this abstract authenticator model to implementations that may wish to make a test of user presence optional although WebAuthn does not. */ diff --git a/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts b/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts index 28b199da78f..b8be164c837 100644 --- a/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts +++ b/libs/common/src/platform/abstractions/fido2/fido2-user-interface.service.abstraction.ts @@ -95,7 +95,7 @@ export abstract class Fido2UserInterfaceSession { */ abstract confirmNewCredential( params: NewCredentialParams, - ): Promise<{ cipherId: string; userVerified: boolean }>; + ): Promise<{ cipherId?: string; userVerified: boolean }>; /** * Make sure that the vault is unlocked. diff --git a/libs/common/src/platform/services/fido2/fido2-utils.spec.ts b/libs/common/src/platform/services/fido2/fido2-utils.spec.ts index 9bb4ed0a4c5..6b34f772798 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.spec.ts @@ -1,3 +1,9 @@ +import { mock } from "jest-mock-extended"; + +import { CipherType } from "@bitwarden/common/vault/enums"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { Fido2CredentialView } from "@bitwarden/common/vault/models/view/fido2-credential.view"; + import { Fido2Utils } from "./fido2-utils"; describe("Fido2 Utils", () => { @@ -67,4 +73,62 @@ describe("Fido2 Utils", () => { expect(expectedArray).toBeNull(); }); }); + + describe("cipherHasNoOtherPasskeys(...)", () => { + const emptyPasskeyCipher = mock({ + id: "id-5", + localData: { lastUsedDate: 222 }, + name: "name-5", + type: CipherType.Login, + login: { + username: "username-5", + password: "password", + uri: "https://example.com", + fido2Credentials: [], + }, + }); + + const passkeyCipher = mock({ + id: "id-5", + localData: { lastUsedDate: 222 }, + name: "name-5", + type: CipherType.Login, + login: { + username: "username-5", + password: "password", + uri: "https://example.com", + fido2Credentials: [ + mock({ + credentialId: "credential-id", + rpName: "credential-name", + userHandle: "user-handle-1", + userName: "credential-username", + rpId: "jest-testing-website.com", + }), + mock({ + credentialId: "credential-id", + rpName: "credential-name", + userHandle: "user-handle-2", + userName: "credential-username", + rpId: "jest-testing-website.com", + }), + ], + }, + }); + + it("should return true when there is no userHandle", () => { + const userHandle = "user-handle-1"; + expect(Fido2Utils.cipherHasNoOtherPasskeys(emptyPasskeyCipher, userHandle)).toBeTruthy(); + }); + + it("should return true when userHandle matches", () => { + const userHandle = "user-handle-1"; + expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeTruthy(); + }); + + it("should return false when userHandle doesn't match", () => { + const userHandle = "testing"; + expect(Fido2Utils.cipherHasNoOtherPasskeys(passkeyCipher, userHandle)).toBeFalsy(); + }); + }); }); diff --git a/libs/common/src/platform/services/fido2/fido2-utils.ts b/libs/common/src/platform/services/fido2/fido2-utils.ts index 99e260f4a53..8efd4734d81 100644 --- a/libs/common/src/platform/services/fido2/fido2-utils.ts +++ b/libs/common/src/platform/services/fido2/fido2-utils.ts @@ -1,3 +1,5 @@ +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; + // FIXME: Update this file to be type safe and remove this and next line import type { AssertCredentialResult, @@ -111,4 +113,16 @@ export class Fido2Utils { return output; } + + /** + * This methods returns true if a cipher either has no passkeys, or has a passkey matching with userHandle + * @param userHandle + */ + static cipherHasNoOtherPasskeys(cipher: CipherView, userHandle: string): boolean { + if (cipher.login.fido2Credentials == null || cipher.login.fido2Credentials.length === 0) { + return true; + } + + return cipher.login.fido2Credentials.some((passkey) => passkey.userHandle === userHandle); + } } diff --git a/libs/common/src/platform/services/fido2/guid-utils.spec.ts b/libs/common/src/platform/services/fido2/guid-utils.spec.ts index 098ea4bee75..c58bd2720fa 100644 --- a/libs/common/src/platform/services/fido2/guid-utils.spec.ts +++ b/libs/common/src/platform/services/fido2/guid-utils.spec.ts @@ -1,28 +1,63 @@ -import { guidToRawFormat } from "./guid-utils"; +import { guidToRawFormat, guidToStandardFormat } from "./guid-utils"; + +const workingExamples: [string, Uint8Array][] = [ + [ + "00000000-0000-0000-0000-000000000000", + new Uint8Array([ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + ]), + ], + [ + "08d70b74-e9f5-4522-a425-e5dcd40107e7", + new Uint8Array([ + 0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07, + 0xe7, + ]), + ], +]; describe("guid-utils", () => { describe("guidToRawFormat", () => { + it.each(workingExamples)( + "returns UUID in binary format when given a valid UUID string", + (input, expected) => { + const result = guidToRawFormat(input); + + expect(result).toEqual(expected); + }, + ); + it.each([ - [ - "00000000-0000-0000-0000-000000000000", - [ - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, - ], - "08d70b74-e9f5-4522-a425-e5dcd40107e7", - [ - 0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07, - 0xe7, - ], - ], - ])("returns UUID in binary format when given a valid UUID string", (input, expected) => { - const result = guidToRawFormat(input); - - expect(result).toEqual(new Uint8Array(expected)); + "invalid", + "", + "", + "00000000-0000-0000-0000-0000000000000000", + "00000000-0000-0000-0000-000000", + ])("throws an error when given an invalid UUID string", (input) => { + expect(() => guidToRawFormat(input)).toThrow(TypeError); }); + }); - it("throws an error when given an invalid UUID string", () => { - expect(() => guidToRawFormat("invalid")).toThrow(TypeError); + describe("guidToStandardFormat", () => { + it.each(workingExamples)( + "returns UUID in standard format when given a valid UUID array buffer", + (expected, input) => { + const result = guidToStandardFormat(input); + + expect(result).toEqual(expected); + }, + ); + + it.each([ + new Uint8Array(), + new Uint8Array([]), + new Uint8Array([ + 0x08, 0xd7, 0x0b, 0x74, 0xe9, 0xf5, 0x45, 0x22, 0xa4, 0x25, 0xe5, 0xdc, 0xd4, 0x01, 0x07, + 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, 0xe7, + ]), + ])("throws an error when given an invalid UUID array buffer", (input) => { + expect(() => guidToStandardFormat(input)).toThrow(TypeError); }); }); }); diff --git a/libs/common/src/platform/services/fido2/guid-utils.ts b/libs/common/src/platform/services/fido2/guid-utils.ts index 66e6cbb1d7c..e72fe84e930 100644 --- a/libs/common/src/platform/services/fido2/guid-utils.ts +++ b/libs/common/src/platform/services/fido2/guid-utils.ts @@ -53,6 +53,10 @@ export function guidToRawFormat(guid: string) { /** Convert raw 16 byte array to standard format (XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX) UUID. */ export function guidToStandardFormat(bufferSource: BufferSource) { + if (bufferSource.byteLength !== 16) { + throw TypeError("BufferSource length is invalid"); + } + const arr = bufferSource instanceof ArrayBuffer ? new Uint8Array(bufferSource) diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index 9aefd960b2f..b95d9023a7c 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -68,6 +68,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider; + abstract getAllDecryptedForIds(userId: UserId, ids: string[]): Promise; abstract filterCiphersForUrl( ciphers: C[], url: string, diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index b2c5ac8943c..c9b288251d0 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -634,6 +634,15 @@ export class CipherService implements CipherServiceAbstraction { ); } + async getAllDecryptedForIds(userId: UserId, ids: string[]): Promise { + return firstValueFrom( + this.cipherViews$(userId).pipe( + filter((ciphers) => ciphers != null), + map((ciphers) => ciphers.filter((cipher) => ids.includes(cipher.id))), + ), + ); + } + async filterCiphersForUrl( ciphers: C[], url: string, diff --git a/libs/components/src/icon-button/index.ts b/libs/components/src/icon-button/index.ts index cc52f263252..b753e53c96a 100644 --- a/libs/components/src/icon-button/index.ts +++ b/libs/components/src/icon-button/index.ts @@ -1,2 +1,2 @@ export * from "./icon-button.module"; -export { BitIconButtonComponent } from "./icon-button.component"; +export * from "./icon-button.component"; diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index f8e02a7e668..f0e55ddd9e1 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -168,6 +168,18 @@ text-align: unset; } + /** + * tw-app-region-drag and tw-app-region-no-drag are used for Electron window dragging behavior + * These will replace direct -webkit-app-region usage as part of the migration to Tailwind CSS + */ + .tw-app-region-drag { + -webkit-app-region: drag; + } + + .tw-app-region-no-drag { + -webkit-app-region: no-drag; + } + /** * Bootstrap uses z-index: 1050 for modals, dialogs and drag-and-drop previews should appear above them. * When bootstrap is removed, test if these styles are still needed and that overlays display properly over other content. diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index b708d101f82..943beef8091 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -2,7 +2,7 @@ import { DebugElement } from "@angular/core"; import { ComponentFixture, TestBed } from "@angular/core/testing"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; import { By } from "@angular/platform-browser"; -import { Router } from "@angular/router"; +import { ActivatedRoute, Router } from "@angular/router"; import { mock } from "jest-mock-extended"; import { firstValueFrom, interval, map, of, takeWhile, timeout } from "rxjs"; import { ZXCVBNResult } from "zxcvbn"; @@ -94,6 +94,13 @@ describe("LockComponent", () => { const mockBroadcasterService = mock(); const mockEncryptedMigrator = mock(); const mockConfigService = mock(); + const mockActivatedRoute = { + snapshot: { + paramMap: { + get: jest.fn().mockReturnValue(null), // return null for 'disable-redirect' param + }, + }, + }; beforeEach(async () => { jest.resetAllMocks(); @@ -151,6 +158,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: EncryptedMigrator, useValue: mockEncryptedMigrator }, { provide: ConfigService, useValue: mockConfigService }, ], @@ -467,6 +475,14 @@ describe("LockComponent", () => { component.clientType = clientType; mockLockComponentService.getPreviousUrl.mockReturnValue(null); + jest.spyOn(component as any, "doContinue").mockImplementation(async () => { + await mockBiometricStateService.resetUserPromptCancelled(); + mockMessagingService.send("unlocked"); + await mockSyncService.fullSync(false); + await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); + await mockRouter.navigate([navigateUrl]); + }); + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); assertUnlocked(); @@ -478,6 +494,16 @@ describe("LockComponent", () => { component.shouldClosePopout = true; mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); + jest.spyOn(component as any, "doContinue").mockImplementation(async () => { + await mockBiometricStateService.resetUserPromptCancelled(); + mockMessagingService.send("unlocked"); + await mockSyncService.fullSync(false); + await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded( + component.activeAccount!.id, + ); + mockLockComponentService.closeBrowserExtensionPopout(); + }); + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); assertUnlocked(); @@ -611,6 +637,32 @@ describe("LockComponent", () => { ])( "should unlock and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy set during user verification by master password", async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { + jest.spyOn(component as any, "doContinue").mockImplementation(async () => { + await mockBiometricStateService.resetUserPromptCancelled(); + mockMessagingService.send("unlocked"); + + if (masterPasswordPolicyOptions?.enforceOnLogin) { + const passwordStrengthResult = mockPasswordStrengthService.getPasswordStrength( + masterPassword, + component.activeAccount!.email, + ); + const evaluated = mockPolicyService.evaluateMasterPassword( + passwordStrengthResult.score, + masterPassword, + masterPasswordPolicyOptions, + ); + if (!evaluated) { + await mockMasterPasswordService.setForceSetPasswordReason( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + } + } + + await mockSyncService.fullSync(false); + await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); + }); + mockUserVerificationService.verifyUserByMasterPassword.mockResolvedValue({ ...masterPasswordVerificationResponse, policyOptions: @@ -725,6 +777,14 @@ describe("LockComponent", () => { component.clientType = clientType; mockLockComponentService.getPreviousUrl.mockReturnValue(null); + jest.spyOn(component as any, "doContinue").mockImplementation(async () => { + await mockBiometricStateService.resetUserPromptCancelled(); + mockMessagingService.send("unlocked"); + await mockSyncService.fullSync(false); + await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded(userId); + await mockRouter.navigate([navigateUrl]); + }); + await component.unlockViaMasterPassword(); assertUnlocked(); @@ -736,6 +796,16 @@ describe("LockComponent", () => { component.shouldClosePopout = true; mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); + jest.spyOn(component as any, "doContinue").mockImplementation(async () => { + await mockBiometricStateService.resetUserPromptCancelled(); + mockMessagingService.send("unlocked"); + await mockSyncService.fullSync(false); + await mockUserAsymmetricKeysRegenerationService.regenerateIfNeeded( + component.activeAccount!.id, + ); + mockLockComponentService.closeBrowserExtensionPopout(); + }); + await component.unlockViaMasterPassword(); assertUnlocked(); diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index 7f715d2215d..ae8cdc843cf 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -1,7 +1,7 @@ import { CommonModule } from "@angular/common"; import { Component, NgZone, OnDestroy, OnInit } from "@angular/core"; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; -import { Router } from "@angular/router"; +import { Router, ActivatedRoute } from "@angular/router"; import { BehaviorSubject, filter, @@ -160,6 +160,7 @@ export class LockComponent implements OnInit, OnDestroy { private keyService: KeyService, private platformUtilsService: PlatformUtilsService, private router: Router, + private activatedRoute: ActivatedRoute, private dialogService: DialogService, private messagingService: MessagingService, private biometricStateService: BiometricStateService, @@ -710,7 +711,13 @@ export class LockComponent implements OnInit, OnDestroy { } // determine success route based on client type - if (this.clientType != null) { + // The disable-redirect parameter allows callers to prevent automatic navigation after unlock, + // useful when the lock component is used in contexts where custom post-unlock behavior is needed + // such as passkey modals. + if ( + this.clientType != null && + this.activatedRoute.snapshot.paramMap.get("disable-redirect") === null + ) { const successRoute = clientTypeToSuccessRouteRecord[this.clientType]; await this.router.navigate([successRoute]); } From 834caa0c88d6b2f3a5e0e8d3abca0e487dbeab1d Mon Sep 17 00:00:00 2001 From: Jordan Aasen <166539328+jaasen-livefront@users.noreply.github.com> Date: Fri, 5 Dec 2025 10:45:47 -0800 Subject: [PATCH 006/136] [PM-29256] - add feature flag to vault spotlight (#17842) * add feature flag to vault spotlight * fix spec --- .../vault-v2/vault-v2.component.spec.ts | 18 ++++++++++++------ .../components/vault-v2/vault-v2.component.ts | 14 ++++++++++++-- libs/common/src/enums/feature-flag.enum.ts | 2 ++ 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts index 5563cd3033b..4b992d9f1ee 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.spec.ts @@ -180,7 +180,7 @@ describe("VaultV2Component", () => { const nudgesSvc = { showNudgeSpotlight$: jest.fn().mockImplementation((_type: NudgeType) => of(false)), dismissNudge: jest.fn().mockResolvedValue(undefined), - } as Partial; + }; const dialogSvc = {} as Partial; @@ -209,6 +209,10 @@ describe("VaultV2Component", () => { .mockResolvedValue(new Date(Date.now() - 8 * 24 * 60 * 60 * 1000)), // 8 days ago }; + const configSvc = { + getFeatureFlag$: jest.fn().mockImplementation((_flag: string) => of(false)), + }; + beforeEach(async () => { jest.clearAllMocks(); await TestBed.configureTestingModule({ @@ -256,9 +260,7 @@ describe("VaultV2Component", () => { { provide: StateProvider, useValue: mock() }, { provide: ConfigService, - useValue: { - getFeatureFlag$: (_: string) => of(false), - }, + useValue: configSvc, }, { provide: SearchService, @@ -453,7 +455,9 @@ describe("VaultV2Component", () => { hasPremiumFromAnySource$.next(false); - (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => + configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(true)); + + nudgesSvc.showNudgeSpotlight$.mockImplementation((type: NudgeType) => of(type === NudgeType.PremiumUpgrade), ); @@ -482,9 +486,11 @@ describe("VaultV2Component", () => { })); it("renders Empty-Vault spotlight when vaultState is Empty and nudge is on", fakeAsync(() => { + configSvc.getFeatureFlag$.mockImplementation((_flag: string) => of(false)); + itemsSvc.emptyVault$.next(true); - (nudgesSvc.showNudgeSpotlight$ as jest.Mock).mockImplementation((type: NudgeType) => { + nudgesSvc.showNudgeSpotlight$.mockImplementation((type: NudgeType) => { return of(type === NudgeType.EmptyVaultNudge); }); diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts index 9cee4f66b67..63d971081df 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-v2.component.ts @@ -137,6 +137,10 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { FeatureFlag.VaultLoadingSkeletons, ); + protected premiumSpotlightFeatureFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.BrowserPremiumSpotlight, + ); + private showPremiumNudgeSpotlight$ = this.activeUserId$.pipe( switchMap((userId) => this.nudgesService.showNudgeSpotlight$(NudgeType.PremiumUpgrade, userId)), ); @@ -164,6 +168,7 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { ); protected showPremiumSpotlight$ = combineLatest([ + this.premiumSpotlightFeatureFlag$, this.showPremiumNudgeSpotlight$, this.showHasItemsVaultSpotlight$, this.hasPremium$, @@ -171,8 +176,13 @@ export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy { this.accountAgeInDays$, ]).pipe( map( - ([showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => - showPremiumNudge && !showHasItemsNudge && !hasPremium && count >= 5 && age >= 7, + ([featureFlagEnabled, showPremiumNudge, showHasItemsNudge, hasPremium, count, age]) => + featureFlagEnabled && + showPremiumNudge && + !showHasItemsNudge && + !hasPremium && + count >= 5 && + age >= 7, ), shareReplay({ bufferSize: 1, refCount: true }), ); diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 01ffdafcef9..23c1a07601e 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -62,6 +62,7 @@ export enum FeatureFlag { AutofillConfirmation = "pm-25083-autofill-confirm-from-search", RiskInsightsForPremium = "pm-23904-risk-insights-for-premium", VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders", + BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight", /* Platform */ IpcChannelFramework = "ipc-channel-framework", @@ -119,6 +120,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.AutofillConfirmation]: FALSE, [FeatureFlag.RiskInsightsForPremium]: FALSE, [FeatureFlag.VaultLoadingSkeletons]: FALSE, + [FeatureFlag.BrowserPremiumSpotlight]: FALSE, /* Auth */ [FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE, From 6fc6ed88b51483e92397f977b2d7a8adae29fc1d Mon Sep 17 00:00:00 2001 From: Will Martin Date: Fri, 5 Dec 2025 14:20:38 -0500 Subject: [PATCH 007/136] [CL-944] update layout components to support constrained height contexts (#17670) --- libs/components/src/dialog/dialog/dialog.component.html | 2 +- libs/components/src/dialog/dialog/dialog.component.ts | 2 +- libs/components/src/drawer/drawer-body.component.ts | 2 +- libs/components/src/drawer/drawer.component.html | 2 +- libs/components/src/layout/layout.component.html | 8 ++++---- libs/components/src/layout/layout.component.ts | 1 + libs/components/src/navigation/side-nav.component.html | 2 +- libs/components/src/navigation/side-nav.component.ts | 3 +++ 8 files changed, 13 insertions(+), 9 deletions(-) diff --git a/libs/components/src/dialog/dialog/dialog.component.html b/libs/components/src/dialog/dialog/dialog.component.html index c077dc2ceb8..be946c76a57 100644 --- a/libs/components/src/dialog/dialog/dialog.component.html +++ b/libs/components/src/dialog/dialog/dialog.component.html @@ -3,7 +3,7 @@ class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main" [ngClass]="[ width, - isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg', + isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg', ]" cdkTrapFocus cdkTrapFocusAutoCapture diff --git a/libs/components/src/dialog/dialog/dialog.component.ts b/libs/components/src/dialog/dialog/dialog.component.ts index 954f03aabe2..627a509ee8c 100644 --- a/libs/components/src/dialog/dialog/dialog.component.ts +++ b/libs/components/src/dialog/dialog/dialog.component.ts @@ -104,7 +104,7 @@ export class DialogComponent { // `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"]; const sizeClasses = this.dialogRef?.isDrawer - ? ["tw-min-h-screen", "md:tw-w-[23rem]"] + ? ["tw-h-full", "md:tw-w-[23rem]"] : ["md:tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"]; const animationClasses = diff --git a/libs/components/src/drawer/drawer-body.component.ts b/libs/components/src/drawer/drawer-body.component.ts index 9b5d3148d9b..c6499067642 100644 --- a/libs/components/src/drawer/drawer-body.component.ts +++ b/libs/components/src/drawer/drawer-body.component.ts @@ -12,7 +12,7 @@ import { hasScrolledFrom } from "../utils/has-scrolled-from"; imports: [], host: { class: - "tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200", + "tw-p-4 tw-pt-0 tw-flex-1 tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200", "[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top", }, hostDirectives: [ diff --git a/libs/components/src/drawer/drawer.component.html b/libs/components/src/drawer/drawer.component.html index fce6b3c57eb..79cbf319e7d 100644 --- a/libs/components/src/drawer/drawer.component.html +++ b/libs/components/src/drawer/drawer.component.html @@ -1,7 +1,7 @@
diff --git a/libs/components/src/layout/layout.component.html b/libs/components/src/layout/layout.component.html index 8ae26e7771b..255799b6690 100644 --- a/libs/components/src/layout/layout.component.html +++ b/libs/components/src/layout/layout.component.html @@ -1,6 +1,6 @@ @let mainContentId = "main-content"; -
-
+
+
@@ -23,7 +23,7 @@ [id]="mainContentId" tabindex="-1" bitScrollLayoutHost - class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container" + class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container" > @@ -45,7 +45,7 @@
}
-
+
diff --git a/libs/components/src/layout/layout.component.ts b/libs/components/src/layout/layout.component.ts index 1b357424205..7460099cf92 100644 --- a/libs/components/src/layout/layout.component.ts +++ b/libs/components/src/layout/layout.component.ts @@ -29,6 +29,7 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive"; ], host: { "(document:keydown.tab)": "handleKeydown($event)", + class: "tw-block tw-h-screen", }, hostDirectives: [DrawerHostDirective], }) diff --git a/libs/components/src/navigation/side-nav.component.html b/libs/components/src/navigation/side-nav.component.html index 0a0d4af3adc..c8b20ecba77 100644 --- a/libs/components/src/navigation/side-nav.component.html +++ b/libs/components/src/navigation/side-nav.component.html @@ -7,7 +7,7 @@ ) {