From 3d2e552e68da9a816ca039eeaa196f16f6d2f7a7 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 16 Apr 2026 09:31:36 +0900 Subject: [PATCH] [Shared Unlock] [PM-34073] Implement vault timeout supression (#19934) --- apps/browser/src/_locales/en/messages.json | 3 ++ .../browser/src/background/idle.background.ts | 7 +++ apps/desktop/src/app/app.component.ts | 5 ++ apps/web/src/locales/en/messages.json | 3 ++ .../vault-timeout-settings.service.ts | 29 +++++++++++ .../vault-timeout-settings.service.ts | 40 +++++++++++++- .../services/vault-timeout-settings.state.ts | 15 +++++- .../services/vault-timeout.service.spec.ts | 52 +++++++++++++++++++ .../services/vault-timeout.service.ts | 5 ++ .../session-timeout-input.component.ts | 8 ++- .../session-timeout-settings.component.html | 4 +- ...session-timeout-settings.component.spec.ts | 1 + .../session-timeout-settings.component.ts | 23 ++++++-- libs/state/src/core/state-definitions.ts | 1 + 14 files changed, 186 insertions(+), 10 deletions(-) diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 0af9812e425..092b08fbab0 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -6151,6 +6151,9 @@ "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction": { "message": "Set an unlock method to change your timeout action" }, + "sessionTimeoutSuppressedByConnectedDevice": { + "message": "Managed by the Bitwarden desktop app. Open the desktop app to edit." + }, "upgrade": { "message": "Upgrade" }, diff --git a/apps/browser/src/background/idle.background.ts b/apps/browser/src/background/idle.background.ts index 66a5604a8ba..756392dbcbc 100644 --- a/apps/browser/src/background/idle.background.ts +++ b/apps/browser/src/background/idle.background.ts @@ -55,6 +55,13 @@ export default class IdleBackground { // Need to check if any of the current users have their timeout set to `onLocked` const allUsers = await firstValueFrom(this.accountService.accounts$); for (const userId in allUsers) { + // Skip if vault timeout is suppressed by shared unlock + if ( + await this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed(userId as UserId) + ) { + continue; + } + // If the screen is locked or the screensaver activates const timeout = await firstValueFrom( this.vaultTimeoutSettingsService.getVaultTimeoutByUserId$(userId), diff --git a/apps/desktop/src/app/app.component.ts b/apps/desktop/src/app/app.component.ts index de1d43ddece..76d03268db2 100644 --- a/apps/desktop/src/app/app.component.ts +++ b/apps/desktop/src/app/app.component.ts @@ -833,6 +833,11 @@ export class AppComponent implements OnInit, OnDestroy { if (userId == null) { continue; } + // Skip if vault timeout is suppressed by shared unlock + if (await this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed(userId as UserId)) { + continue; + } + const options = await this.getVaultTimeoutOptions(userId); if (options[0] === timeout) { options[1] === "logOut" diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 4022a42d23e..e7cefb0dc2b 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -7119,6 +7119,9 @@ "sessionTimeoutAction": { "message": "Session timeout action" }, + "sessionTimeoutSuppressedByConnectedDevice": { + "message": "Managed by the Bitwarden browser extension app. Open the browser app to edit." + }, "immediately": { "message": "Immediately" }, diff --git a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts index 44108b69513..06b6b3a30e0 100644 --- a/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts +++ b/libs/common/src/key-management/vault-timeout/abstractions/vault-timeout-settings.service.ts @@ -55,4 +55,33 @@ export abstract class VaultTimeoutSettingsService { * @returns boolean true if biometric lock is set */ abstract isBiometricLockSet(userId?: UserId): Promise; + + /** + * Observable that emits the epoch timestamp (ms) until which vault timeout is suppressed, + * or null when not suppressed. Used by shared unlock to prevent timeout during active sessions. + */ + abstract vaultTimeoutSuppressedUntil$(userId: UserId): Observable; + + /** + * Observable that emits true if vault timeout is currently suppressed for the given user + * (i.e. suppression timestamp exists and has not yet elapsed). + */ + abstract isVaultTimeoutSuppressed$(userId: UserId): Observable; + + /** + * Returns true if vault timeout is currently suppressed for the given user + * (i.e. suppression timestamp exists and has not yet elapsed). + */ + abstract isVaultTimeoutSuppressed(userId: UserId): Promise; + + /** + * Suppress vault timeout until the given epoch timestamp (ms). + * While suppressed, the vault timeout service will not lock or log out users. + */ + abstract suppressVaultTimeout(until: number, userId: UserId): Promise; + + /** + * Clear vault timeout suppression for the user, allowing vault timeout to occur as normal. + */ + abstract clearVaultTimeoutSuppression(userId: UserId): Promise; } 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 af14478b1f6..780ffc10ec4 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 @@ -42,7 +42,11 @@ import { VaultTimeoutStringType, } from "../types/vault-timeout.type"; -import { VAULT_TIMEOUT, VAULT_TIMEOUT_ACTION } from "./vault-timeout-settings.state"; +import { + VAULT_TIMEOUT, + VAULT_TIMEOUT_ACTION, + VAULT_TIMEOUT_SUPPRESSED_UNTIL, +} from "./vault-timeout-settings.state"; export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceAbstraction { constructor( @@ -393,4 +397,38 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA map((policy) => (policy?.data ?? null) as MaximumSessionTimeoutPolicyData | null), ); } + + vaultTimeoutSuppressedUntil$(userId: UserId): Observable { + if (!userId) { + throw new Error("User id required. Cannot get vault timeout suppressed until."); + } + + return this.stateProvider.getUserState$(VAULT_TIMEOUT_SUPPRESSED_UNTIL, userId); + } + + isVaultTimeoutSuppressed$(userId: UserId): Observable { + return this.vaultTimeoutSuppressedUntil$(userId).pipe( + map((until) => until != null && Date.now() < until), + ); + } + + async isVaultTimeoutSuppressed(userId: UserId): Promise { + return await firstValueFrom(this.isVaultTimeoutSuppressed$(userId)); + } + + async suppressVaultTimeout(until: number, userId: UserId): Promise { + if (!userId) { + throw new Error("User id required. Cannot suppress vault timeout."); + } + + await this.stateProvider.getUser(userId, VAULT_TIMEOUT_SUPPRESSED_UNTIL).update(() => until); + } + + async clearVaultTimeoutSuppression(userId: UserId): Promise { + if (!userId) { + throw new Error("User id required. Cannot clear vault timeout suppression."); + } + + await this.stateProvider.getUser(userId, VAULT_TIMEOUT_SUPPRESSED_UNTIL).update(() => null); + } } diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.state.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.state.ts index 4d8a0de654a..2677e9d8aa0 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.state.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout-settings.state.ts @@ -1,4 +1,8 @@ -import { UserKeyDefinition, VAULT_TIMEOUT_SETTINGS_DISK_LOCAL } from "../../../platform/state"; +import { + UserKeyDefinition, + VAULT_TIMEOUT_SETTINGS_DISK_LOCAL, + VAULT_TIMEOUT_SETTINGS_MEMORY, +} from "../../../platform/state"; import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum"; import { VaultTimeout } from "../types/vault-timeout.type"; @@ -25,3 +29,12 @@ export const VAULT_TIMEOUT = new UserKeyDefinition( clearOn: [], // persisted on logout }, ); + +export const VAULT_TIMEOUT_SUPPRESSED_UNTIL = new UserKeyDefinition( + VAULT_TIMEOUT_SETTINGS_MEMORY, + "vaultTimeoutSuppressedUntil", + { + deserializer: (value) => value, + clearOn: ["logout"], + }, +); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts index 4e1eb2e09bc..780a348f6e7 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.spec.ts @@ -54,6 +54,7 @@ describe("VaultTimeoutService", () => { vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue( vaultTimeoutActionSubject, ); + vaultTimeoutSettingsService.isVaultTimeoutSuppressed.mockResolvedValue(false); availableVaultTimeoutActionsSubject = new BehaviorSubject([]); @@ -152,6 +153,8 @@ describe("VaultTimeoutService", () => { ], ); }); + + vaultTimeoutSettingsService.isVaultTimeoutSuppressed.mockResolvedValue(false); }; const expectUserToHaveLocked = (userId: string) => { @@ -333,5 +336,54 @@ describe("VaultTimeoutService", () => { expectNoAction("1"); }); + + it("should not run timeout actions for a user while vault timeout is suppressed", async () => { + setupAccounts( + { + 1: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + lastActive: new Date().getTime() - 120 * 1000, // Last active 2 minutes ago + vaultTimeout: 1, // Vault timeout of 1 minute + }, + 2: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + lastActive: new Date().getTime() - 120 * 1000, // Last active 2 minutes ago + vaultTimeout: 1, // Vault timeout of 1 minute + }, + }, + { isViewFocused: false }, + ); + + vaultTimeoutSettingsService.isVaultTimeoutSuppressed.mockImplementation((userId) => + Promise.resolve(userId === "1"), + ); + + await vaultTimeoutService.checkVaultTimeout(); + + expectNoAction("1"); + expectUserToHaveLocked("2"); + }); + + it("should run timeout action when suppression has expired", async () => { + setupAccounts( + { + 1: { + authStatus: AuthenticationStatus.Unlocked, + isAuthenticated: true, + lastActive: new Date().getTime() - 120 * 1000, // Last active 2 minutes ago + vaultTimeout: 1, // Vault timeout of 1 minute + }, + }, + { isViewFocused: false }, + ); + + vaultTimeoutSettingsService.isVaultTimeoutSuppressed.mockResolvedValue(false); + + await vaultTimeoutService.checkVaultTimeout(); + + expectUserToHaveLocked("1"); + }); }); }); diff --git a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts index 281e8e2e67d..f478979af99 100644 --- a/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts +++ b/libs/common/src/key-management/vault-timeout/services/vault-timeout.service.ts @@ -93,6 +93,11 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction { return false; } + // Check if vault timeout is suppressed by shared unlock + if (await this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed(userId as UserId)) { + return false; + } + const authStatus = await this.authService.getAuthStatus(userId); if ( authStatus === AuthenticationStatus.Locked || 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 93fe3b4f982..1e7ad47b83f 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 @@ -243,8 +243,12 @@ export class SessionTimeoutInputComponent implements ControlValueAccessor, Valid // Empty } - setDisabledState?(_isDisabled: boolean): void { - // Empty + setDisabledState?(isDisabled: boolean): void { + if (isDisabled) { + this.form.disable({ emitEvent: false }); + } else { + this.form.enable({ emitEvent: false }); + } } validate(_: AbstractControl): ValidationErrors | null { 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 e6de54dc38e..c47ad646fa8 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 @@ -19,7 +19,9 @@ } - @if (!canLock && supportsLock) { + @if (isTimeoutSuppressed()) { + {{ "sessionTimeoutSuppressedByConnectedDevice" | i18n }} + } @else if (!canLock && supportsLock) { {{ "sessionTimeoutSettingsSetUnlockMethodToChangeTimeoutAction" | i18n }} } @else if ((sessionTimeoutActionFromPolicy$ | async) != null) { {{ "sessionTimeoutSettingsManagedByOrganization" | 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 7b69ee13dc3..8507f372670 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 @@ -88,6 +88,7 @@ describe("SessionTimeoutSettingsComponent", () => { mockVaultTimeoutSettingsService.availableVaultTimeoutActions$.mockImplementation(() => of([VaultTimeoutAction.Lock, VaultTimeoutAction.LogOut]), ); + mockVaultTimeoutSettingsService.isVaultTimeoutSuppressed$.mockImplementation(() => of(false)); mockSessionTimeoutSettingsComponentService.policyFilteredTimeoutOptions$.mockImplementation( (userId) => availableTimeoutOptions$.asObservable(), ); 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 89f085e5127..fc112852a36 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 @@ -128,6 +128,13 @@ export class SessionTimeoutSettingsComponent implements OnInit { protected readonly sessionTimeoutActionFromPolicy = toSignal( this.sessionTimeoutActionFromPolicy$, ); + protected readonly isTimeoutSuppressed = toSignal( + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed$(userId)), + ), + { initialValue: false }, + ); private userId!: UserId; @@ -184,19 +191,25 @@ export class SessionTimeoutSettingsComponent implements OnInit { this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(this.userId), this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(this.userId), this.sessionTimeoutActionFromPolicy$, + this.vaultTimeoutSettingsService.isVaultTimeoutSuppressed$(this.userId), ]), ), takeUntilDestroyed(this.destroyRef), ) - .subscribe(([availableActions, action, sessionTimeoutActionFromPolicy]) => { + .subscribe(([availableActions, action, sessionTimeoutActionFromPolicy, isSuppressed]) => { this.availableTimeoutActions.set(availableActions); this.formGroup.controls.timeoutAction.setValue(action, { emitEvent: false }); - // Enable/disable the action control based on policy or available actions - if (sessionTimeoutActionFromPolicy != null || availableActions.length <= 1) { - this.formGroup.controls.timeoutAction.disable({ emitEvent: false }); + // Disable the entire form when vault timeout is suppressed by shared unlock + if (isSuppressed) { + this.formGroup.disable({ emitEvent: false }); } else { - this.formGroup.controls.timeoutAction.enable({ emitEvent: false }); + this.formGroup.enable({ emitEvent: false }); + + // Enable/disable the action control based on policy or available actions + if (sessionTimeoutActionFromPolicy != null || availableActions.length <= 1) { + this.formGroup.controls.timeoutAction.disable({ emitEvent: false }); + } } }); diff --git a/libs/state/src/core/state-definitions.ts b/libs/state/src/core/state-definitions.ts index 0959e4f0df8..5d93128765c 100644 --- a/libs/state/src/core/state-definitions.ts +++ b/libs/state/src/core/state-definitions.ts @@ -81,6 +81,7 @@ export const VAULT_TIMEOUT_SETTINGS_DISK_LOCAL = new StateDefinition( web: "disk-local", }, ); +export const VAULT_TIMEOUT_SETTINGS_MEMORY = new StateDefinition("vaultTimeoutSettings", "memory"); // Autofill