[PM-21681] Handle automatic timeout logout in BWA (#1659)

This commit is contained in:
Katherine Bertelsen 2025-06-13 08:44:24 -05:00 committed by GitHub
parent 5356ac33da
commit 702e43b689
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 795 additions and 225 deletions

View File

@ -1,5 +1,6 @@
import BitwardenKit
import Combine
import CoreData
import Foundation
// MARK: - AuthenticatorBridgeItemService
@ -93,6 +94,9 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
/// The keychain repository for working with the shared key.
let sharedKeychainRepository: SharedKeychainRepository
/// A service that manages account timeout between apps.
let sharedTimeoutService: SharedTimeoutService
// MARK: Initialization
/// Initialize a `DefaultAuthenticatorBridgeItemService`
@ -101,13 +105,16 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
/// - cryptoService: Cryptography service for encrypting/decrypting items.
/// - dataStore: The CoreData store for working with shared data
/// - sharedKeychainRepository: The keychain repository for working with the shared key.
/// - sharedTimeoutService: The shared timeout service for managing session timeouts.
///
public init(cryptoService: SharedCryptographyService,
dataStore: AuthenticatorBridgeDataStore,
sharedKeychainRepository: SharedKeychainRepository) {
sharedKeychainRepository: SharedKeychainRepository,
sharedTimeoutService: SharedTimeoutService) {
self.cryptoService = cryptoService
self.dataStore = dataStore
self.sharedKeychainRepository = sharedKeychainRepository
self.sharedTimeoutService = sharedTimeoutService
}
// MARK: Methods
@ -192,6 +199,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
public func sharedItemsPublisher() async throws ->
AnyPublisher<[AuthenticatorBridgeItemDataView], any Error> {
try await checkForLogout()
let fetchRequest = AuthenticatorBridgeItemData.fetchRequest(
predicate: NSPredicate(
format: "userId != %@", DefaultAuthenticatorBridgeItemService.temporaryUserId
@ -210,4 +218,25 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
}
.eraseToAnyPublisher()
}
// MARK: Private Functions
/// Iterates through all of the users with shared items and determines if they've passed their
/// logout timeout. If so, then their shared items are deleted.
///
private func checkForLogout() async throws {
let fetchRequest = NSFetchRequest<NSDictionary>(entityName: AuthenticatorBridgeItemData.entityName)
fetchRequest.propertiesToFetch = ["userId"]
fetchRequest.returnsDistinctResults = true
fetchRequest.resultType = .dictionaryResultType
let results = try dataStore.persistentContainer.viewContext.fetch(fetchRequest)
let userIds = results.compactMap { ($0 as? [String: Any])?["userId"] as? String }
try await userIds.asyncForEach { userId in
if try await sharedTimeoutService.hasPassedTimeout(userId: userId) {
try await deleteAllForUserId(userId)
}
}
}
}

View File

@ -0,0 +1,59 @@
import AuthenticatorBridgeKit
import Combine
public class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService {
public var errorToThrow: Error?
public var replaceAllCalled = false
public var sharedItemsSubject = CurrentValueSubject<[AuthenticatorBridgeItemDataView], Error>([])
public var storedItems: [String: [AuthenticatorBridgeItemDataView]] = [:]
public var syncOn = false
public var tempItem: AuthenticatorBridgeItemDataView?
public init() {}
public func checkForLogout() async throws {
}
public func deleteAllForUserId(_ userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = []
}
public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataView] {
guard errorToThrow == nil else { throw errorToThrow! }
return storedItems[userId] ?? []
}
public func fetchTemporaryItem() async throws -> AuthenticatorBridgeItemDataView? {
guard errorToThrow == nil else { throw errorToThrow! }
return tempItem
}
public func insertTemporaryItem(_ item: AuthenticatorBridgeItemDataView) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
tempItem = item
}
public func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = items
}
public func isSyncOn() async -> Bool {
syncOn
}
public func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = items
replaceAllCalled = true
}
public func sharedItemsPublisher() async throws ->
AnyPublisher<[AuthenticatorBridgeKit.AuthenticatorBridgeItemDataView], any Error> {
guard errorToThrow == nil else { throw errorToThrow! }
return sharedItemsSubject.eraseToAnyPublisher()
}
}

View File

@ -0,0 +1,39 @@
import AuthenticatorBridgeKit
import BitwardenKit
import Foundation
public final class MockSharedTimeoutService: SharedTimeoutService {
public var clearTimeoutUserIds = [String]()
public var clearTimeoutError: Error?
public var hasPassedTimeoutResult: Result<[String: Bool], Error> = .success([:])
public var updateTimeoutUserId: String?
public var updateTimeoutLastActiveDate: Date?
public var updateTimeoutTimeoutLength: SessionTimeoutValue?
public var updateTimeoutError: Error?
public init() {}
public func clearTimeout(forUserId userId: String) async throws {
if let clearTimeoutError {
throw clearTimeoutError
}
clearTimeoutUserIds.append(userId)
}
public func hasPassedTimeout(userId: String) async throws -> Bool {
try hasPassedTimeoutResult.get()[userId] ?? false
}
public func updateTimeout(
forUserId userId: String,
lastActiveDate: Date?,
timeoutLength: SessionTimeoutValue
) async throws {
if let updateTimeoutError {
throw updateTimeoutError
}
updateTimeoutUserId = userId
updateTimeoutLastActiveDate = lastActiveDate
updateTimeoutTimeoutLength = timeoutLength
}
}

View File

@ -107,16 +107,18 @@ public class DefaultSharedKeychainStorage: SharedKeychainStorage {
)
guard let resultDictionary = foundItem as? [String: Any],
let data = resultDictionary[kSecValueData as String] as? T else {
let data = resultDictionary[kSecValueData as String] as? Data else {
throw SharedKeychainServiceError.keyNotFound(item)
}
return data
let object = try JSONDecoder.defaultDecoder.decode(T.self, from: data)
return object
}
public func setValue<T: Codable>(_ value: T, for item: SharedKeychainItem) async throws {
let valueData = try JSONEncoder.defaultEncoder.encode(value)
let query = [
kSecValueData: value,
kSecValueData: valueData,
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
kSecAttrAccessGroup: sharedAppGroupIdentifier,
kSecAttrAccount: item.unformattedKey,

View File

@ -52,8 +52,9 @@ final class SharedKeychainStorageTests: BitwardenTestCase {
func test_getValue_success() async throws {
let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data(Array($0)) }
let encodedData = try JSONEncoder.defaultEncoder.encode(data)
keychainService.setSearchResultData(data)
keychainService.setSearchResultData(encodedData)
let returnData: Data = try await subject.getValue(for: .authenticatorKey)
XCTAssertEqual(returnData, data)
@ -113,6 +114,7 @@ final class SharedKeychainStorageTests: BitwardenTestCase {
func test_setAuthenticatorKey_success() async throws {
let key = SymmetricKey(size: .bits256)
let data = key.withUnsafeBytes { Data(Array($0)) }
let encodedData = try JSONEncoder.defaultEncoder.encode(data)
try await subject.setValue(data, for: .authenticatorKey)
let attributes = try XCTUnwrap(keychainService.addAttributes as? [CFString: Any])
@ -123,6 +125,6 @@ final class SharedKeychainStorageTests: BitwardenTestCase {
SharedKeychainItem.authenticatorKey.unformattedKey)
try XCTAssertEqual(XCTUnwrap(attributes[kSecClass] as? String),
String(kSecClassGenericPassword))
try XCTAssertEqual(XCTUnwrap(attributes[kSecValueData] as? Data), data)
try XCTAssertEqual(XCTUnwrap(attributes[kSecValueData] as? Data), encodedData)
}
}

View File

@ -0,0 +1,81 @@
import BitwardenKit
import Foundation
// MARK: - HasSharedTimeoutService
/// Protocol for an object that provides a `SharedTimeoutService`
///
public protocol HasSharedTimeoutService {
/// The service for managing account timeout between apps.
var sharedTimeoutService: SharedTimeoutService { get }
}
// MARK: - SharedTimeoutService
/// A service that manages account timeout between apps.
///
public protocol SharedTimeoutService {
/// Clears the shared timeout for a user.
/// - Parameters:
/// - userId: The user's ID
func clearTimeout(forUserId userId: String) async throws
/// Determines if a user has passed their timeout, using the current time and the saved shared time.
/// If the current time is equal to the timeout time, then it is considered passed. If there is no
/// saved time, then this will always return false.
/// - Parameters:
/// - userId: The user's ID
/// - Returns: whether or not the user has passed their timeout
func hasPassedTimeout(userId: String) async throws -> Bool
/// Updates the shared timeout for a user.
/// - Parameters:
/// - userId: The user's ID
/// - lastActiveDate: The last time the user was active
/// - timeoutLength: The user's preferred timeout length
func updateTimeout(forUserId userId: String, lastActiveDate: Date?, timeoutLength: SessionTimeoutValue) async throws
}
// MARK: - DefaultTimeoutService
public final class DefaultSharedTimeoutService: SharedTimeoutService {
/// A repository for managing keychain items to be shared between Password Manager and Authenticator.
private let sharedKeychainRepository: SharedKeychainRepository
/// A service for providing the current time.
private let timeProvider: TimeProvider
public init(
sharedKeychainRepository: SharedKeychainRepository,
timeProvider: TimeProvider
) {
self.sharedKeychainRepository = sharedKeychainRepository
self.timeProvider = timeProvider
}
public func clearTimeout(forUserId userId: String) async throws {
try await sharedKeychainRepository.setAccountAutoLogoutTime(nil, userId: userId)
}
public func hasPassedTimeout(userId: String) async throws -> Bool {
guard let autoLogoutTime = try await sharedKeychainRepository.getAccountAutoLogoutTime(userId: userId) else {
return false
}
return timeProvider.presentTime >= autoLogoutTime
}
public func updateTimeout(
forUserId userId: String,
lastActiveDate: Date?,
timeoutLength: SessionTimeoutValue
) async throws {
guard let lastActiveDate else {
try await clearTimeout(forUserId: userId)
return
}
let timeout = lastActiveDate.addingTimeInterval(TimeInterval(timeoutLength.seconds))
try await sharedKeychainRepository.setAccountAutoLogoutTime(timeout, userId: userId)
}
}

View File

@ -0,0 +1,115 @@
import AuthenticatorBridgeKit
import AuthenticatorBridgeKitMocks
import BitwardenKit
import BitwardenKitMocks
import TestHelpers
import XCTest
final class SharedTimeoutServiceTests: BitwardenTestCase {
// MARK: Properties
var sharedKeychainRepository: MockSharedKeychainRepository!
var subject: SharedTimeoutService!
var timeProvider: MockTimeProvider!
// MARK: Set up & Tear down
override func setUp() {
super.setUp()
sharedKeychainRepository = MockSharedKeychainRepository()
timeProvider = MockTimeProvider(.mockTime(Date(year: 2024, month: 6, day: 20)))
subject = DefaultSharedTimeoutService(
sharedKeychainRepository: sharedKeychainRepository,
timeProvider: timeProvider
)
}
override func tearDown() {
super.tearDown()
sharedKeychainRepository = nil
subject = nil
timeProvider = nil
}
// MARK: Tests
/// `clearTimeout(:)` clears the timeout for a user.
func test_clearTimeout() async throws {
sharedKeychainRepository.accountAutoLogoutTime["1"] = timeProvider.presentTime
try await subject.clearTimeout(forUserId: "1")
XCTAssertNil(sharedKeychainRepository.accountAutoLogoutTime["1"])
}
/// `clearTimeout(:)` throws errors.
func test_clearTimeout_error() async throws {
sharedKeychainRepository.errorToThrow = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.clearTimeout(forUserId: "1")
}
}
/// `hasPassedTimeout(:)` uses the current time to determine if the timeout has passed.
/// If the current time is the timeout, then it is considered passed.
func test_hasPassedTimeout() async throws {
sharedKeychainRepository.accountAutoLogoutTime["1"] = timeProvider.presentTime.addingTimeInterval(-1)
var value = try await subject.hasPassedTimeout(userId: "1")
XCTAssertTrue(value)
sharedKeychainRepository.accountAutoLogoutTime["1"] = timeProvider.presentTime
value = try await subject.hasPassedTimeout(userId: "1")
XCTAssertTrue(value)
sharedKeychainRepository.accountAutoLogoutTime["1"] = timeProvider.presentTime.addingTimeInterval(1)
value = try await subject.hasPassedTimeout(userId: "1")
XCTAssertFalse(value)
}
/// `hasPassedTimeout(:)` returns false if there is no timeout.
func test_hasPassedTimeout_noTimeout() async throws {
let value = try await subject.hasPassedTimeout(userId: "1")
XCTAssertFalse(value)
}
/// `hasPassedTimeout(:)` throws errors.
func test_hasPassedTimeout_error() async throws {
sharedKeychainRepository.errorToThrow = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.hasPassedTimeout(userId: "1")
}
}
/// `updateTimeout(:::)` updates the timeout accordingly.
func test_updateTimeout() async throws {
try await subject.updateTimeout(
forUserId: "1",
lastActiveDate: timeProvider.presentTime,
timeoutLength: .fourHours
)
XCTAssertEqual(
sharedKeychainRepository.accountAutoLogoutTime["1"],
timeProvider.presentTime.addingTimeInterval(TimeInterval(SessionTimeoutValue.fourHours.seconds))
)
}
/// `updateTimeout(:::)` clears the timeout if the date is nil.
func test_updateTimeout_nil() async throws {
sharedKeychainRepository.accountAutoLogoutTime["1"] = timeProvider.presentTime
try await subject.updateTimeout(forUserId: "1", lastActiveDate: nil, timeoutLength: .fourHours)
XCTAssertNil(sharedKeychainRepository.accountAutoLogoutTime["1"])
}
/// `updateTimeout(:::)` throws errors.
func test_updateTimeout_error() async throws {
sharedKeychainRepository.errorToThrow = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
try await self.subject.updateTimeout(
forUserId: "1",
lastActiveDate: timeProvider.presentTime,
timeoutLength: .fourHours
)
}
}
}

View File

@ -1,4 +1,3 @@
import AuthenticatorBridgeKit
import AuthenticatorBridgeKitMocks
import BitwardenKit
import BitwardenKitMocks
@ -31,7 +30,8 @@ final class AuthenticatorBridgeItemDataTests: AuthenticatorBridgeKitTestCase {
itemService = DefaultAuthenticatorBridgeItemService(
cryptoService: cryptoService,
dataStore: dataStore,
sharedKeychainRepository: MockSharedKeychainRepository()
sharedKeychainRepository: MockSharedKeychainRepository(),
sharedTimeoutService: MockSharedTimeoutService()
)
}

View File

@ -3,9 +3,10 @@ import AuthenticatorBridgeKitMocks
import BitwardenKit
import BitwardenKitMocks
import Foundation
import TestHelpers
import XCTest
@testable import AuthenticatorBridgeKit
// swiftlint:disable file_length type_body_length function_body_length
final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase {
// MARK: Properties
@ -15,6 +16,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase
var dataStore: AuthenticatorBridgeDataStore!
var errorReporter: ErrorReporter!
var keychainRepository: MockSharedKeychainRepository!
var sharedTimeoutService: MockSharedTimeoutService!
var subject: AuthenticatorBridgeItemService!
// MARK: Setup & Teardown
@ -29,10 +31,12 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase
storeType: .memory
)
keychainRepository = MockSharedKeychainRepository()
sharedTimeoutService = MockSharedTimeoutService()
subject = DefaultAuthenticatorBridgeItemService(
cryptoService: cryptoService,
dataStore: dataStore,
sharedKeychainRepository: keychainRepository
sharedKeychainRepository: keychainRepository,
sharedTimeoutService: sharedTimeoutService
)
}
@ -41,6 +45,7 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase
dataStore = nil
errorReporter = nil
keychainRepository = nil
sharedTimeoutService = nil
subject = nil
super.tearDown()
}
@ -340,4 +345,65 @@ final class AuthenticatorBridgeItemServiceTests: AuthenticatorBridgeKitTestCase
XCTAssertEqual(results[0], initialItems)
XCTAssertEqual(results[1], replacedItems)
}
/// The shared items publisher deletes items if the user is timed out.
///
func test_sharedItemsPublisher_deletesItemsOnTimeout() async throws {
let pastTimeoutItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id }
let withinTimeoutItems = [AuthenticatorBridgeItemDataView.fixture(name: "New Item")]
try await subject.insertItems(pastTimeoutItems, forUserId: "pastTimeoutUserId")
try await subject.replaceAllItems(with: withinTimeoutItems, forUserId: "withinTimeoutUserId")
sharedTimeoutService.hasPassedTimeoutResult = .success([
"pastTimeoutUserId": true,
"withinTimeoutUserId": false,
])
var results: [[AuthenticatorBridgeItemDataView]] = []
let publisher = try await subject.sharedItemsPublisher()
.sink(
receiveCompletion: { _ in },
receiveValue: { value in
results.append(value)
}
)
defer { publisher.cancel() }
// Verify items are removed for "userId"
let itemsForPastTimeoutUser = try await subject.fetchAllForUserId("pastTimeoutUserId")
XCTAssertNotNil(itemsForPastTimeoutUser)
XCTAssertEqual(itemsForPastTimeoutUser.count, 0)
// Verify items are still present for "differentUserId"
let itemsForWithinTimeoutUser = try await subject.fetchAllForUserId("withinTimeoutUserId")
XCTAssertNotNil(itemsForWithinTimeoutUser)
XCTAssertEqual(itemsForWithinTimeoutUser.count, withinTimeoutItems.count)
}
/// `sharedItemsPublisher()` throws if checking for logout throws
///
func test_sharedItemsPublisher_logoutError() async throws {
let initialItems = AuthenticatorBridgeItemDataView.fixtures().sorted { $0.id < $1.id }
try await subject.insertItems(initialItems, forUserId: "userId")
sharedTimeoutService.hasPassedTimeoutResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
var results: [[AuthenticatorBridgeItemDataView]] = []
let publisher = try await subject.sharedItemsPublisher()
.sink(
receiveCompletion: { _ in },
receiveValue: { value in
results.append(value)
}
)
publisher.cancel()
}
XCTAssertFalse(cryptoService.decryptCalled)
}
}
// swiftlint:enable file_length type_body_length function_body_length

View File

@ -245,10 +245,16 @@ public class ServiceContainer: Services {
storeType: .persisted
)
let sharedTimeoutService = DefaultSharedTimeoutService(
sharedKeychainRepository: sharedKeychainRepository,
timeProvider: timeProvider
)
let sharedItemService = DefaultAuthenticatorBridgeItemService(
cryptoService: sharedCryptographyService,
dataStore: sharedDataStore,
sharedKeychainRepository: sharedKeychainRepository
sharedKeychainRepository: sharedKeychainRepository,
sharedTimeoutService: sharedTimeoutService
)
let authenticatorItemRepository = DefaultAuthenticatorItemRepository(

View File

@ -1,4 +1,5 @@
import AuthenticatorBridgeKit
import AuthenticatorBridgeKitMocks
import BitwardenKitMocks
import InlineSnapshotTesting
import TestHelpers

View File

@ -1,53 +0,0 @@
import AuthenticatorBridgeKit
import Combine
class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService {
var errorToThrow: Error?
var replaceAllCalled = false
var sharedItemsSubject = CurrentValueSubject<[AuthenticatorBridgeItemDataView], Error>([])
var storedItems: [String: [AuthenticatorBridgeItemDataView]] = [:]
var syncOn = false
var tempItem: AuthenticatorBridgeItemDataView?
func deleteAllForUserId(_ userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = []
}
func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataView] {
guard errorToThrow == nil else { throw errorToThrow! }
return storedItems[userId] ?? []
}
func fetchTemporaryItem() async throws -> AuthenticatorBridgeItemDataView? {
guard errorToThrow == nil else { throw errorToThrow! }
return tempItem
}
func insertTemporaryItem(_ item: AuthenticatorBridgeItemDataView) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
tempItem = item
}
func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = items
}
func isSyncOn() async -> Bool {
syncOn
}
func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = items
replaceAllCalled = true
}
func sharedItemsPublisher() async throws ->
AnyPublisher<[AuthenticatorBridgeKit.AuthenticatorBridgeItemDataView], any Error> {
guard errorToThrow == nil else { throw errorToThrow! }
return sharedItemsSubject.eraseToAnyPublisher()
}
}

View File

@ -723,7 +723,7 @@ extension DefaultAuthRepository: AuthRepository {
}
func isPinUnlockAvailable(userId: String?) async throws -> Bool {
try await stateService.pinProtectedUserKey(userId: userId) != nil
try await vaultTimeoutService.isPinUnlockAvailable(userId: userId)
}
func isUserManagedByOrganization() async throws -> Bool {
@ -784,20 +784,7 @@ extension DefaultAuthRepository: AuthRepository {
}
func sessionTimeoutAction(userId: String?) async throws -> SessionTimeoutAction {
let hasMasterPassword = try await stateService.getUserHasMasterPassword(userId: userId)
let timeoutAction = try await stateService.getTimeoutAction(userId: userId)
guard hasMasterPassword else {
let isBiometricsEnabled = try await biometricsRepository.getBiometricUnlockStatus().isEnabled
let isPinEnabled = try await isPinUnlockAvailable()
if isPinEnabled || isBiometricsEnabled {
return timeoutAction
} else {
// If the user doesn't have a master password and hasn't enabled a pin or
// biometrics, their timeout action needs to be logout.
return .logout
}
}
return timeoutAction
try await vaultTimeoutService.sessionTimeoutAction(userId: userId)
}
func requestOtp() async throws {

View File

@ -150,7 +150,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
// MARK: Tests
/// `.canBeLocked(userId:)` shoulr reutrn true when user has face ID.
/// `.canBeLocked(userId:)` should return true when user has face ID.
func test_canBeLocked_hasFaceId() async {
stateService.userHasMasterPassword["1"] = false
stateService.pinProtectedUserKeyValue["1"] = "123"
@ -460,12 +460,13 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
stateService.activeAccount = beeAccount
stateService.timeoutAction = [anneAccount.profile.userId: .logout]
vaultTimeoutService.shouldSessionTimeout[anneAccount.profile.userId] = true
vaultTimeoutService.sessionTimeoutAction[anneAccount.profile.userId] = .logout
await subject.checkSessionTimeouts(handleActiveUser: nil)
XCTAssertTrue(vaultTimeoutService.removedIds.contains(anneAccount.profile.userId))
XCTAssertTrue(stateService.accountsLoggedOut.contains(anneAccount.profile.userId))
}
/// `checkSessionTimeout()` takes no action to an active account when the session timeout if the `handleActiveUser`
/// `checkSessionTimeout()` takes no action to an active account when the session timeout if the `handleActiveUser`
/// closure is nil.
func test_checkSessionTimeout_activeAccount() async {
stateService.accounts = [anneAccount, beeAccount]
@ -624,6 +625,7 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
anneAccount.profile.userId: true,
beeAccount.profile.userId: true,
]
vaultTimeoutService.isClientLocked = [
anneAccount.profile.userId: true,
beeAccount.profile.userId: true,
@ -633,9 +635,9 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
anneAccount.profile.userId: false,
beeAccount.profile.userId: false,
]
stateService.pinProtectedUserKeyValue = [
beeAccount.profile.userId: "123",
]
vaultTimeoutService.pinUnlockAvailabilityResult = .success([beeAccount.profile.userId: true])
let accounts = await subject.getProfilesState(
allowLockAndLogout: true,
isVisible: true,
@ -1020,22 +1022,26 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
}
}
/// `isPinUnlockAvailable` returns the pin unlock availability for the active user.
func test_isPinUnlockAvailable_noValue() async throws {
stateService.activeAccount = .fixture()
let value = try await subject.isPinUnlockAvailable()
/// `isPinUnlockAvailable` calls the VaultTimeoutService.
func test_isPinUnlockAvailable() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
vaultTimeoutService.pinUnlockAvailabilityResult = .success(["1": false])
var value = try await subject.isPinUnlockAvailable(userId: "1")
XCTAssertFalse(value)
vaultTimeoutService.pinUnlockAvailabilityResult = .success(["1": true])
value = try await subject.isPinUnlockAvailable(userId: "1")
XCTAssertTrue(value)
}
/// `isPinUnlockAvailable` returns the pin unlock availability for the active user.
func test_isPinUnlockAvailable_value() async throws {
let active = Account.fixture()
stateService.activeAccount = active
stateService.pinProtectedUserKeyValue = [
active.profile.userId: "123",
]
let value = try await subject.isPinUnlockAvailable()
XCTAssertTrue(value)
/// `isPinUnlockAvailable` throws errors.
func test_isPinUnlockAvailable_error() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
vaultTimeoutService.pinUnlockAvailabilityResult = .failure(BitwardenTestError.example)
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.isPinUnlockAvailable(userId: "1")
}
}
/// `isUserManagedByOrganization` returns false when the feature flag is off.
@ -1583,12 +1589,12 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertNil(clientService.mockAuthUserId)
}
/// `sessionTimeoutAction()` returns the session timeout action for a user.
/// `sessionTimeoutAction()` uses the VaultTimeoutService.
func test_sessionTimeoutAction() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
stateService.accounts = [.fixture(profile: .fixture(userId: "2"))]
stateService.timeoutAction["1"] = .lock
stateService.timeoutAction["2"] = .logout
vaultTimeoutService.sessionTimeoutAction["1"] = .lock
vaultTimeoutService.sessionTimeoutAction["2"] = .logout
var timeoutAction = try await subject.sessionTimeoutAction()
XCTAssertEqual(timeoutAction, .lock)
@ -1597,49 +1603,13 @@ class AuthRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_bo
XCTAssertEqual(timeoutAction, .logout)
}
/// `sessionTimeoutAction()` defaults to logout if the user doesn't have a master password and
/// hasn't enabled pin or biometrics unlock.
func test_sessionTimeoutAction_noMasterPassword() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
stateService.timeoutAction["1"] = .lock
stateService.userHasMasterPassword["1"] = false
/// `sessionTimeoutAction()` throws errors.
func test_sessionTimeoutAction_error() async throws {
vaultTimeoutService.sessionTimeoutActionError = BitwardenTestError.example
let timeoutAction = try await subject.sessionTimeoutAction()
XCTAssertEqual(timeoutAction, .logout)
}
/// `sessionTimeoutAction()` allows lock or logout if the user doesn't have a master password
/// and has biometrics unlock enabled.
func test_sessionTimeoutAction_noMasterPassword_biometricsEnabled() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
stateService.timeoutAction["1"] = .lock
stateService.userHasMasterPassword["1"] = false
biometricsRepository.biometricUnlockStatus = .success(
.available(.faceID, enabled: true)
)
var timeoutAction = try await subject.sessionTimeoutAction()
XCTAssertEqual(timeoutAction, .lock)
stateService.timeoutAction["1"] = .logout
timeoutAction = try await subject.sessionTimeoutAction()
XCTAssertEqual(timeoutAction, .logout)
}
/// `sessionTimeoutAction()` allows lock or logout if the user doesn't have a master password
/// and has pin unlock enabled.
func test_sessionTimeoutAction_noMasterPassword_pinEnabled() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
stateService.pinProtectedUserKeyValue["1"] = "KEY"
stateService.timeoutAction["1"] = .lock
stateService.userHasMasterPassword["1"] = false
var timeoutAction = try await subject.sessionTimeoutAction()
XCTAssertEqual(timeoutAction, .lock)
stateService.timeoutAction["1"] = .logout
timeoutAction = try await subject.sessionTimeoutAction()
XCTAssertEqual(timeoutAction, .logout)
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.sessionTimeoutAction(userId: "1")
}
}
/// `setActiveAccount(userId: )` loads the environment URLs for the active account.

View File

@ -148,6 +148,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// The repository used by the application to manage data for the UI layer.
let settingsRepository: SettingsRepository
/// The service that manages account timeout between apps.
public let sharedTimeoutService: SharedTimeoutService
/// The service used by the application to manage account state.
let stateService: StateService
@ -242,6 +245,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
/// - policyService: The service for managing the polices for the user.
/// - sendRepository: The repository used by the application to manage send data for the UI layer.
/// - settingsRepository: The repository used by the application to manage data for the UI layer.
/// - sharedTimeoutService: The service that manages account timeout between apps.
/// - stateService: The service used by the application to manage account state.
/// - syncService: The service used to handle syncing vault data with the API.
/// - systemDevice: The object used by the application to retrieve information about this device.
@ -299,6 +303,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
reviewPromptService: ReviewPromptService,
sendRepository: SendRepository,
settingsRepository: SettingsRepository,
sharedTimeoutService: SharedTimeoutService,
stateService: StateService,
syncService: SyncService,
systemDevice: SystemDevice,
@ -355,6 +360,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
self.reviewPromptService = reviewPromptService
self.sendRepository = sendRepository
self.settingsRepository = settingsRepository
self.sharedTimeoutService = sharedTimeoutService
self.stateService = stateService
self.syncService = syncService
self.systemDevice = systemDevice
@ -538,9 +544,25 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
tokenService: tokenService
)
let sharedKeychainStorage = DefaultSharedKeychainStorage(
keychainService: keychainService,
sharedAppGroupIdentifier: Bundle.main.sharedAppGroupIdentifier
)
let sharedKeychainRepository = DefaultSharedKeychainRepository(
storage: sharedKeychainStorage
)
let sharedTimeoutService = DefaultSharedTimeoutService(
sharedKeychainRepository: sharedKeychainRepository,
timeProvider: timeProvider
)
let vaultTimeoutService = DefaultVaultTimeoutService(
biometricsRepository: biometricsRepository,
clientService: clientService,
errorReporter: errorReporter,
sharedTimeoutService: sharedTimeoutService,
stateService: stateService,
timeProvider: timeProvider
)
@ -777,15 +799,6 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
storeType: .persisted
)
let sharedKeychainStorage = DefaultSharedKeychainStorage(
keychainService: keychainService,
sharedAppGroupIdentifier: Bundle.main.sharedAppGroupIdentifier
)
let sharedKeychainRepository = DefaultSharedKeychainRepository(
storage: sharedKeychainStorage
)
let sharedCryptographyService = DefaultAuthenticatorCryptographyService(
sharedKeychainRepository: sharedKeychainRepository
)
@ -793,7 +806,8 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
let authBridgeItemService = DefaultAuthenticatorBridgeItemService(
cryptoService: sharedCryptographyService,
dataStore: authenticatorDataStore,
sharedKeychainRepository: sharedKeychainRepository
sharedKeychainRepository: sharedKeychainRepository,
sharedTimeoutService: sharedTimeoutService
)
let authenticatorSyncService = DefaultAuthenticatorSyncService(
@ -852,6 +866,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
reviewPromptService: reviewPromptService,
sendRepository: sendRepository,
settingsRepository: settingsRepository,
sharedTimeoutService: sharedTimeoutService,
stateService: stateService,
syncService: syncService,
systemDevice: UIDevice.current,

View File

@ -1,3 +1,4 @@
import AuthenticatorBridgeKit
import BitwardenKit
import BitwardenSdk
@ -45,6 +46,7 @@ typealias Services = HasAPIService
& HasReviewPromptService
& HasSendRepository
& HasSettingsRepository
& HasSharedTimeoutService
& HasStateService
& HasSyncService
& HasSystemDevice

View File

@ -1,54 +0,0 @@
import AuthenticatorBridgeKit
import BitwardenShared
import Combine
class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService {
var errorToThrow: Error?
var replaceAllCalled = false
var sharedItemsSubject = CurrentValueSubject<[AuthenticatorBridgeItemDataView], Error>([])
var storedItems: [String: [AuthenticatorBridgeItemDataView]] = [:]
var syncOn = false
var tempItem: AuthenticatorBridgeItemDataView?
func deleteAllForUserId(_ userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = []
}
func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataView] {
guard errorToThrow == nil else { throw errorToThrow! }
return storedItems[userId] ?? []
}
func fetchTemporaryItem() async throws -> AuthenticatorBridgeItemDataView? {
guard errorToThrow == nil else { throw errorToThrow! }
return tempItem
}
func insertTemporaryItem(_ item: AuthenticatorBridgeItemDataView) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
tempItem = item
}
func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = items
}
func isSyncOn() async -> Bool {
syncOn
}
func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws {
guard errorToThrow == nil else { throw errorToThrow! }
storedItems[userId] = items
replaceAllCalled = true
}
func sharedItemsPublisher() async throws ->
AnyPublisher<[AuthenticatorBridgeKit.AuthenticatorBridgeItemDataView], any Error> {
guard errorToThrow == nil else { throw errorToThrow! }
return sharedItemsSubject.eraseToAnyPublisher()
}
}

View File

@ -67,6 +67,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
var passwordGenerationOptions = [String: PasswordGenerationOptions]()
var pendingAppIntentActions: [PendingAppIntentAction]?
var pendingAppIntentActionsSubject = CurrentValueSubject<[PendingAppIntentAction]?, Never>(nil)
var pinProtectedUserKeyError: Error?
var pinProtectedUserKeyValue = [String: String]()
var preAuthEnvironmentURLs: EnvironmentURLData?
var accountCreationEnvironmentURLs = [String: EnvironmentURLData]()
@ -87,6 +88,8 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
var setAppRehydrationStateError: Error?
var setBiometricAuthenticationEnabledResult: Result<Void, Error> = .success(())
var setBiometricIntegrityStateError: Error?
var setLastActiveTimeError: Error?
var setVaultTimeoutError: Error?
var settingsBadgeSubject = CurrentValueSubject<SettingsBadgeState, Never>(.fixture())
var shouldTrustDevice = [String: Bool?]()
var syncToAuthenticatorByUserId = [String: Bool]()
@ -97,6 +100,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
var updateProfileResponse: ProfileResponseModel?
var updateProfileUserId: String?
var userHasMasterPassword = [String: Bool]()
var userHasMasterPasswordError: Error?
var userIds = [String]()
var usernameGenerationOptions = [String: UsernameGenerationOptions]()
var usesKeyConnector = [String: Bool]()
@ -381,6 +385,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func getUserHasMasterPassword(userId: String?) async throws -> Bool {
if let userHasMasterPasswordError { throw userHasMasterPasswordError }
let userId = try unwrapUserId(userId)
return userHasMasterPassword[userId] ?? true
}
@ -417,6 +422,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func pinProtectedUserKey(userId: String?) async throws -> String? {
if let pinProtectedUserKeyError { throw pinProtectedUserKeyError }
let userId = try unwrapUserId(userId)
return pinProtectedUserKeyValue[userId] ?? nil
}
@ -563,8 +569,9 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func setLastActiveTime(_ date: Date?, userId: String?) async throws {
if let setLastActiveTimeError { throw setLastActiveTimeError }
let userId = try unwrapUserId(userId)
lastActiveTime[userId] = timeProvider.presentTime
lastActiveTime[userId] = date
}
func setLastSyncTime(_ date: Date?, userId: String?) async throws {
@ -711,6 +718,7 @@ class MockStateService: StateService { // swiftlint:disable:this type_body_lengt
}
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws {
if let setVaultTimeoutError { throw setVaultTimeoutError }
let userId = try unwrapUserId(userId)
vaultTimeout[userId] = value
}

View File

@ -1,3 +1,5 @@
import AuthenticatorBridgeKit
import AuthenticatorBridgeKitMocks
import BitwardenKit
import BitwardenKitMocks
import BitwardenSdk
@ -49,6 +51,7 @@ extension ServiceContainer {
reviewPromptService: ReviewPromptService = MockReviewPromptService(),
sendRepository: SendRepository = MockSendRepository(),
settingsRepository: SettingsRepository = MockSettingsRepository(),
sharedTimeoutService: SharedTimeoutService = MockSharedTimeoutService(),
stateService: StateService = MockStateService(),
syncService: SyncService = MockSyncService(),
systemDevice: SystemDevice = MockSystemDevice(),
@ -109,6 +112,7 @@ extension ServiceContainer {
reviewPromptService: reviewPromptService,
sendRepository: sendRepository,
settingsRepository: settingsRepository,
sharedTimeoutService: sharedTimeoutService,
stateService: stateService,
syncService: syncService,
systemDevice: systemDevice,

View File

@ -8,10 +8,13 @@ import Foundation
class MockVaultTimeoutService: VaultTimeoutService {
var account: Account = .fixture()
var lastActiveTime = [String: Date]()
var pinUnlockAvailabilityResult: Result<[String: Bool], Error> = .success([:])
var setLastActiveTimeError: Error?
var shouldSessionTimeout = [String: Bool]()
var shouldSessionTimeoutError: Error?
var timeProvider = MockTimeProvider(.currentTime)
var sessionTimeoutAction = [String: SessionTimeoutAction]()
var sessionTimeoutActionError: Error?
var sessionTimeoutValueError: Error?
var unlockVaultHadUserInteraction = false
var vaultTimeout = [String: SessionTimeoutValue]()
@ -45,6 +48,12 @@ class MockVaultTimeoutService: VaultTimeoutService {
vaultTimeout[account.profile.userId] = value
}
func isPinUnlockAvailable(userId: String?) async throws -> Bool {
guard let userId else { throw StateServiceError.noActiveAccount }
return try pinUnlockAvailabilityResult.get()[userId] ?? false
}
func hasPassedSessionTimeout(userId: String) async throws -> Bool {
if let shouldSessionTimeoutError {
throw shouldSessionTimeoutError
@ -52,16 +61,17 @@ class MockVaultTimeoutService: VaultTimeoutService {
return shouldSessionTimeout[userId] ?? false
}
func unlockVault(userId: String?, hadUserInteraction: Bool) async throws {
guard let userId else { return }
isClientLocked[userId] = false
unlockVaultHadUserInteraction = hadUserInteraction
}
func remove(userId: String?) async {
removedIds.append(userId)
}
func sessionTimeoutAction(userId: String?) async throws -> SessionTimeoutAction {
if let sessionTimeoutActionError {
throw sessionTimeoutActionError
}
return sessionTimeoutAction[userId ?? account.profile.userId] ?? .lock
}
func sessionTimeoutValue(userId: String?) async throws -> SessionTimeoutValue {
if let sessionTimeoutValueError {
throw sessionTimeoutValueError
@ -69,6 +79,12 @@ class MockVaultTimeoutService: VaultTimeoutService {
return vaultTimeout[userId ?? account.profile.userId] ?? .fifteenMinutes
}
func unlockVault(userId: String?, hadUserInteraction: Bool) async throws {
guard let userId else { return }
isClientLocked[userId] = false
unlockVaultHadUserInteraction = hadUserInteraction
}
func vaultLockStatusPublisher() async -> AnyPublisher<VaultLockStatus?, Never> {
vaultLockStatusSubject.eraseToAnyPublisher()
}

View File

@ -1,3 +1,4 @@
import AuthenticatorBridgeKit
import BitwardenKit
import BitwardenSdk
import Combine
@ -32,6 +33,12 @@ protocol VaultTimeoutService: AnyObject {
///
func isLocked(userId: String) -> Bool
/// Whether pin unlock is available for a userId.
/// - Parameter userId: The userId of the account.
/// - Returns: Whether pin unlock is available.
///
func isPinUnlockAvailable(userId: String?) async throws -> Bool
/// Locks the user's vault
///
/// - Parameter userId: The userId of the account to lock.
@ -45,6 +52,19 @@ protocol VaultTimeoutService: AnyObject {
///
func remove(userId: String?) async
/// Gets the `SessionTimeoutAction` for a user.
///
/// - Parameter userId: The userId of the account. Defaults to the active user if nil.
///
func sessionTimeoutAction(userId: String?) async throws -> SessionTimeoutAction
/// Gets the `SessionTimeoutValue` for a user.
///
/// - Parameter userId: The userId of the account.
/// Defaults to the active user if nil.
///
func sessionTimeoutValue(userId: String?) async throws -> SessionTimeoutValue
/// Sets the last active time within the app.
///
/// - Parameter userId: The user ID associated with the last active time within the app.
@ -68,13 +88,6 @@ protocol VaultTimeoutService: AnyObject {
/// or the never lock key was used.
func unlockVault(userId: String?, hadUserInteraction: Bool) async throws
/// Gets the `SessionTimeoutValue` for a user.
///
/// - Parameter userId: The userId of the account.
/// Defaults to the active user if nil.
///
func sessionTimeoutValue(userId: String?) async throws -> SessionTimeoutValue
/// A publisher containing the active user ID and whether their vault is locked.
///
/// - Returns: A publisher for the active user ID whether their vault is locked.
@ -87,12 +100,18 @@ protocol VaultTimeoutService: AnyObject {
class DefaultVaultTimeoutService: VaultTimeoutService {
// MARK: Private properties
/// The service to use system biometrics for vault unlock.
private let biometricsRepository: BiometricsRepository
/// The service that handles common client functionality such as encryption and decryption.
private let clientService: ClientService
/// The service used by the application to report non-fatal errors.
private let errorReporter: ErrorReporter
/// A service that manages account timeout between apps.
private let sharedTimeoutService: SharedTimeoutService
/// The state service used by this Default Service.
private let stateService: StateService
@ -107,19 +126,25 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
/// Creates a new `DefaultVaultTimeoutService`.
///
/// - Parameters:
/// - biometricsRepository: The service to use system biometrics for vault unlock.
/// - clientService: The service that handles common client functionality such as encryption and decryption.
/// - errorReporter: The service used by the application to report non-fatal errors.
/// - sharedTimeoutService: The service that manages account timeout between apps.
/// - stateService: The StateService used by DefaultVaultTimeoutService.
/// - timeProvider: Provides the current time.
///
init(
biometricsRepository: BiometricsRepository,
clientService: ClientService,
errorReporter: ErrorReporter,
sharedTimeoutService: SharedTimeoutService,
stateService: StateService,
timeProvider: TimeProvider
) {
self.biometricsRepository = biometricsRepository
self.clientService = clientService
self.errorReporter = errorReporter
self.sharedTimeoutService = sharedTimeoutService
self.stateService = stateService
self.timeProvider = timeProvider
}
@ -149,6 +174,10 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
return isLocked
}
func isPinUnlockAvailable(userId: String?) async throws -> Bool {
try await stateService.pinProtectedUserKey(userId: userId) != nil
}
func lockVault(userId: String?) async {
do {
let userId = try await stateService.getAccountIdOrActiveId(userId: userId)
@ -171,12 +200,43 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
}
}
func sessionTimeoutAction(userId: String?) async throws -> SessionTimeoutAction {
let hasMasterPassword = try await stateService.getUserHasMasterPassword(userId: userId)
let timeoutAction = try await stateService.getTimeoutAction(userId: userId)
guard hasMasterPassword else {
let isBiometricsEnabled = try await biometricsRepository.getBiometricUnlockStatus().isEnabled
let isPinEnabled = try await isPinUnlockAvailable(userId: userId)
if isPinEnabled || isBiometricsEnabled {
return timeoutAction
} else {
// If the user doesn't have a master password and hasn't enabled a pin or
// biometrics, their timeout action needs to be logout.
return .logout
}
}
return timeoutAction
}
func setLastActiveTime(userId: String) async throws {
try await stateService.setLastActiveTime(timeProvider.presentTime, userId: userId)
let now = timeProvider.presentTime
try await stateService.setLastActiveTime(now, userId: userId)
let vaultTimeout = try await sessionTimeoutValue(userId: userId)
try await updateSharedTimeout(
lastActiveTime: now,
timeoutValue: vaultTimeout,
userId: userId
)
}
func setVaultTimeout(value: SessionTimeoutValue, userId: String?) async throws {
try await stateService.setVaultTimeout(value: value, userId: userId)
guard let userId else { return }
let lastActiveTime = try await stateService.getLastActiveTime(userId: userId)
try await updateSharedTimeout(
lastActiveTime: lastActiveTime,
timeoutValue: value,
userId: userId
)
}
func unlockVault(userId: String?, hadUserInteraction: Bool) async throws {
@ -202,4 +262,34 @@ class DefaultVaultTimeoutService: VaultTimeoutService {
.removeDuplicates()
.eraseToAnyPublisher()
}
/// Updates the shared timeout value in the SharedTimeoutService, so that BWA can log users out
/// on timeout. In the event that the user should not be automatically logged out after a time,
/// it will clear the timeout value.
private func updateSharedTimeout(
lastActiveTime: Date?,
timeoutValue: SessionTimeoutValue,
userId: String
) async throws {
let vaultTimeout = try await sessionTimeoutValue(userId: userId)
switch vaultTimeout {
case .never,
.onAppRestart:
// For timeouts of `.never` or `.onAppRestart`, timeouts cannot be calculated.
// Therefore we can't have one saved.
try await sharedTimeoutService.clearTimeout(forUserId: userId)
default:
let timeoutAction = try await sessionTimeoutAction(userId: userId)
switch timeoutAction {
case .lock:
try await sharedTimeoutService.clearTimeout(forUserId: userId)
case .logout:
try await sharedTimeoutService.updateTimeout(
forUserId: userId,
lastActiveDate: lastActiveTime,
timeoutLength: timeoutValue
)
}
}
}
}

View File

@ -1,3 +1,5 @@
import AuthenticatorBridgeKit
import AuthenticatorBridgeKitMocks
import BitwardenKitMocks
import BitwardenSdk
import Combine
@ -7,12 +9,14 @@ import XCTest
@testable import BitwardenShared
@MainActor
final class VaultTimeoutServiceTests: BitwardenTestCase {
final class VaultTimeoutServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
// MARK: Properties
var biometricsRepository: MockBiometricsRepository!
var cancellables: Set<AnyCancellable>!
var clientService: MockClientService!
var errorReporter: MockErrorReporter!
var sharedTimeoutService: MockSharedTimeoutService!
var stateService: MockStateService!
var subject: DefaultVaultTimeoutService!
var timeProvider: MockTimeProvider!
@ -22,9 +26,11 @@ final class VaultTimeoutServiceTests: BitwardenTestCase {
override func setUp() {
super.setUp()
biometricsRepository = MockBiometricsRepository()
cancellables = []
clientService = MockClientService()
errorReporter = MockErrorReporter()
sharedTimeoutService = MockSharedTimeoutService()
stateService = MockStateService()
timeProvider = MockTimeProvider(
.mockTime(
@ -32,8 +38,10 @@ final class VaultTimeoutServiceTests: BitwardenTestCase {
)
)
subject = DefaultVaultTimeoutService(
biometricsRepository: biometricsRepository,
clientService: clientService,
errorReporter: errorReporter,
sharedTimeoutService: sharedTimeoutService,
stateService: stateService,
timeProvider: timeProvider
)
@ -42,6 +50,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase {
override func tearDown() async throws {
try await super.tearDown()
biometricsRepository = nil
cancellables = nil
clientService = nil
errorReporter = nil
@ -150,6 +159,32 @@ final class VaultTimeoutServiceTests: BitwardenTestCase {
XCTAssertFalse(shouldTimeout)
}
/// `isPinUnlockAvailable` returns the pin unlock availability for the active user.
func test_isPinUnlockAvailable_noValue() async throws {
stateService.activeAccount = .fixture()
let value = try await subject.isPinUnlockAvailable(userId: "1")
XCTAssertFalse(value)
}
/// `isPinUnlockAvailable` returns the pin unlock availability for the active user.
func test_isPinUnlockAvailable_value() async throws {
let active = Account.fixture()
stateService.activeAccount = active
stateService.pinProtectedUserKeyValue = [
active.profile.userId: "123",
]
let value = try await subject.isPinUnlockAvailable(userId: "1")
XCTAssertTrue(value)
}
/// `isPinUnlockAvailable` throws errors.
func test_isPinUnlockAvailable_error() async throws {
stateService.pinProtectedUserKeyError = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.isPinUnlockAvailable(userId: "1")
}
}
/// `lockVault(userId:)` logs an error if one occurs.
func test_lock_error() async {
await subject.lockVault(userId: nil)
@ -241,16 +276,130 @@ final class VaultTimeoutServiceTests: BitwardenTestCase {
XCTAssertNotNil(clientService.userClientArray[userId])
}
/// `sessionTimeoutAction()` returns the session timeout action for a user.
func test_sessionTimeoutAction() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
stateService.accounts = [.fixture(profile: .fixture(userId: "2"))]
stateService.timeoutAction["1"] = .lock
stateService.timeoutAction["2"] = .logout
var timeoutAction = try await subject.sessionTimeoutAction(userId: "1")
XCTAssertEqual(timeoutAction, .lock)
timeoutAction = try await subject.sessionTimeoutAction(userId: "2")
XCTAssertEqual(timeoutAction, .logout)
}
/// `sessionTimeoutAction()` defaults to logout if the user doesn't have a master password and
/// hasn't enabled pin or biometrics unlock.
func test_sessionTimeoutAction_noMasterPassword() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
stateService.timeoutAction["1"] = .lock
stateService.userHasMasterPassword["1"] = false
let timeoutAction = try await subject.sessionTimeoutAction(userId: "1")
XCTAssertEqual(timeoutAction, .logout)
}
/// `sessionTimeoutAction()` allows lock or logout if the user doesn't have a master password
/// and has biometrics unlock enabled.
func test_sessionTimeoutAction_noMasterPassword_biometricsEnabled() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
stateService.timeoutAction["1"] = .lock
stateService.userHasMasterPassword["1"] = false
biometricsRepository.biometricUnlockStatus = .success(
.available(.faceID, enabled: true)
)
var timeoutAction = try await subject.sessionTimeoutAction(userId: "1")
XCTAssertEqual(timeoutAction, .lock)
stateService.timeoutAction["1"] = .logout
timeoutAction = try await subject.sessionTimeoutAction(userId: "1")
XCTAssertEqual(timeoutAction, .logout)
}
/// `sessionTimeoutAction()` allows lock or logout if the user doesn't have a master password
/// and has pin unlock enabled.
func test_sessionTimeoutAction_noMasterPassword_pinEnabled() async throws {
stateService.activeAccount = .fixture(profile: .fixture(userId: "1"))
stateService.pinProtectedUserKeyValue["1"] = "KEY"
stateService.timeoutAction["1"] = .lock
stateService.userHasMasterPassword["1"] = false
var timeoutAction = try await subject.sessionTimeoutAction(userId: "1")
XCTAssertEqual(timeoutAction, .lock)
stateService.timeoutAction["1"] = .logout
timeoutAction = try await subject.sessionTimeoutAction(userId: "1")
XCTAssertEqual(timeoutAction, .logout)
}
/// `sessionTimeoutAction()` throws errors.
func test_sessionTimeoutAction_error() async throws {
stateService.userHasMasterPasswordError = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
_ = try await subject.sessionTimeoutAction(userId: "1")
}
}
/// `.setLastActiveTime(userId:)` sets the user's last active time.
func test_setLastActiveTime() async throws {
let account = Account.fixture()
stateService.activeAccount = account
try await subject.setLastActiveTime(userId: account.profile.userId)
XCTAssertEqual(
stateService.lastActiveTime[account.profile.userId]!.timeIntervalSince1970,
Date().timeIntervalSince1970,
accuracy: 1.0
stateService.lastActiveTime[account.profile.userId]!,
timeProvider.presentTime,
)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
/// `.setLastActiveTime(userId:)` clears shared timeout on a timeout of `.never` or `.onAppRestart`
func test_setLastActiveTime_neverOrOnAppRestart() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .never
try await subject.setLastActiveTime(userId: account.profile.userId)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
stateService.vaultTimeout[account.profile.userId] = .onAppRestart
try await subject.setLastActiveTime(userId: account.profile.userId)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1", "1"])
}
/// `.setLastActiveTime(userId:)` clears shared timeout if the user's action is `.lock`
func test_setLastActiveTime_lock() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .fifteenMinutes
stateService.timeoutAction[account.profile.userId] = .lock
try await subject.setLastActiveTime(userId: account.profile.userId)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
/// `.setLastActiveTime(userId:)` updates shared timeout if the user's action is `.logout`
func test_setLastActiveTime_logout() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.vaultTimeout[account.profile.userId] = .oneMinute
stateService.timeoutAction[account.profile.userId] = .logout
try await subject.setLastActiveTime(userId: account.profile.userId)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, [])
XCTAssertEqual(sharedTimeoutService.updateTimeoutUserId, "1")
XCTAssertEqual(sharedTimeoutService.updateTimeoutLastActiveDate, timeProvider.presentTime)
XCTAssertEqual(sharedTimeoutService.updateTimeoutTimeoutLength, .oneMinute)
}
/// `.setLastActiveTime(userId:)` throws errors.
func test_setLastActive_time_error() async throws {
stateService.setLastActiveTimeError = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.setLastActiveTime(userId: "1")
}
}
/// `.setVaultTimeout(value:userId:)` sets the user's vault timeout value.
@ -259,6 +408,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase {
stateService.activeAccount = account
try await subject.setVaultTimeout(value: .custom(120), userId: account.profile.userId)
XCTAssertEqual(stateService.vaultTimeout[account.profile.userId], .custom(120))
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
/// `.setVaultTimeout(value:userId:)` sets the user's vault timeout value to on app restart.
@ -267,6 +417,7 @@ final class VaultTimeoutServiceTests: BitwardenTestCase {
stateService.activeAccount = account
try await subject.setVaultTimeout(value: .onAppRestart, userId: account.profile.userId)
XCTAssertEqual(stateService.vaultTimeout[account.profile.userId], .onAppRestart)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
/// `.setVaultTimeout(value:userId:)` sets the user's vault timeout value to never.
@ -275,6 +426,40 @@ final class VaultTimeoutServiceTests: BitwardenTestCase {
stateService.activeAccount = account
try await subject.setVaultTimeout(value: .never, userId: account.profile.userId)
XCTAssertEqual(stateService.vaultTimeout[account.profile.userId], .never)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
/// `.setVaultTimeout(value:userId:)` clears shared timeout if the user's action is `.lock`
func test_setVaultTimeout_lock() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.timeoutAction[account.profile.userId] = .lock
try await subject.setVaultTimeout(value: .oneMinute, userId: account.profile.userId)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, ["1"])
}
/// `.setVaultTimeout(value:userId:)` updates shared timeout if the user's action is `.logout`
func test_setVaultTimeout_logout() async throws {
let account = Account.fixture()
stateService.activeAccount = account
stateService.lastActiveTime[account.profile.userId] = timeProvider.presentTime
stateService.timeoutAction[account.profile.userId] = .logout
try await subject.setVaultTimeout(value: .oneMinute, userId: account.profile.userId)
XCTAssertEqual(sharedTimeoutService.clearTimeoutUserIds, [])
XCTAssertEqual(sharedTimeoutService.updateTimeoutUserId, "1")
XCTAssertEqual(sharedTimeoutService.updateTimeoutLastActiveDate, timeProvider.presentTime)
XCTAssertEqual(sharedTimeoutService.updateTimeoutTimeoutLength, .oneMinute)
}
/// `.setVaultTimeout(value:userId:)` throws errors.
func test_setVaultTimeout_error() async throws {
stateService.setVaultTimeoutError = BitwardenTestError.example
await assertAsyncThrows(error: BitwardenTestError.example) {
try await subject.setVaultTimeout(value: .fiveMinutes, userId: "1")
}
}
/// `unlockVault(userId: nil)` should unlock the active account.