1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-22 11:13:46 +00:00

[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
This commit is contained in:
Maciej Zieniuk
2025-12-05 14:55:59 +01:00
committed by GitHub
parent c036ffd775
commit bbea11388a
48 changed files with 3344 additions and 569 deletions

View File

@@ -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"
}
}

View File

@@ -86,12 +86,12 @@
</bit-section-header>
<bit-card>
<bit-session-timeout-input
<bit-session-timeout-input-legacy
[vaultTimeoutOptions]="vaultTimeoutOptions"
[formControl]="form.controls.vaultTimeout"
ngDefaultControl
>
</bit-session-timeout-input>
</bit-session-timeout-input-legacy>
<bit-form-field disableMargin>
<bit-label for="vaultTimeoutAction">{{ "vaultTimeoutAction1" | i18n }}</bit-label>

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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<I18nService>;
let mockSessionTimeoutTypeService: jest.Mocked<SessionTimeoutTypeService>;
let mockPolicyService: jest.Mocked<PolicyService>;
let mockMessagingService: jest.Mocked<MessagingService>;
beforeEach(() => {
mockI18nService = mock<I18nService>();
mockSessionTimeoutTypeService = mock<SessionTimeoutTypeService>();
mockPolicyService = mock<PolicyService>();
mockMessagingService = mock<MessagingService>();
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();
});
});
});

View File

@@ -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<VaultTimeoutOption[]> = 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");
}

View File

@@ -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<PlatformUtilsService>;
beforeEach(() => {
mockPlatformUtilsService = mock<PlatformUtilsService>();
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);
});
});
});

View File

@@ -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<boolean> {
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<VaultTimeout> {
const available = await this.isAvailable(type);
if (!available) {
return VaultTimeoutStringType.OnRestart;
}
return type;
}
}

View File

@@ -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,
],
}),
];