Skip to content

Commit 702e43b

Browse files
[PM-21681] Handle automatic timeout logout in BWA (#1659)
1 parent 5356ac3 commit 702e43b

22 files changed

+795
-225
lines changed

AuthenticatorBridgeKit/AuthenticatorBridgeItemService.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import BitwardenKit
22
import Combine
3+
import CoreData
34
import Foundation
45

56
// MARK: - AuthenticatorBridgeItemService
@@ -93,6 +94,9 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
9394
/// The keychain repository for working with the shared key.
9495
let sharedKeychainRepository: SharedKeychainRepository
9596

97+
/// A service that manages account timeout between apps.
98+
let sharedTimeoutService: SharedTimeoutService
99+
96100
// MARK: Initialization
97101

98102
/// Initialize a `DefaultAuthenticatorBridgeItemService`
@@ -101,13 +105,16 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
101105
/// - cryptoService: Cryptography service for encrypting/decrypting items.
102106
/// - dataStore: The CoreData store for working with shared data
103107
/// - sharedKeychainRepository: The keychain repository for working with the shared key.
108+
/// - sharedTimeoutService: The shared timeout service for managing session timeouts.
104109
///
105110
public init(cryptoService: SharedCryptographyService,
106111
dataStore: AuthenticatorBridgeDataStore,
107-
sharedKeychainRepository: SharedKeychainRepository) {
112+
sharedKeychainRepository: SharedKeychainRepository,
113+
sharedTimeoutService: SharedTimeoutService) {
108114
self.cryptoService = cryptoService
109115
self.dataStore = dataStore
110116
self.sharedKeychainRepository = sharedKeychainRepository
117+
self.sharedTimeoutService = sharedTimeoutService
111118
}
112119

113120
// MARK: Methods
@@ -192,6 +199,7 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
192199

193200
public func sharedItemsPublisher() async throws ->
194201
AnyPublisher<[AuthenticatorBridgeItemDataView], any Error> {
202+
try await checkForLogout()
195203
let fetchRequest = AuthenticatorBridgeItemData.fetchRequest(
196204
predicate: NSPredicate(
197205
format: "userId != %@", DefaultAuthenticatorBridgeItemService.temporaryUserId
@@ -210,4 +218,25 @@ public class DefaultAuthenticatorBridgeItemService: AuthenticatorBridgeItemServi
210218
}
211219
.eraseToAnyPublisher()
212220
}
221+
222+
// MARK: Private Functions
223+
224+
/// Iterates through all of the users with shared items and determines if they've passed their
225+
/// logout timeout. If so, then their shared items are deleted.
226+
///
227+
private func checkForLogout() async throws {
228+
let fetchRequest = NSFetchRequest<NSDictionary>(entityName: AuthenticatorBridgeItemData.entityName)
229+
fetchRequest.propertiesToFetch = ["userId"]
230+
fetchRequest.returnsDistinctResults = true
231+
fetchRequest.resultType = .dictionaryResultType
232+
233+
let results = try dataStore.persistentContainer.viewContext.fetch(fetchRequest)
234+
let userIds = results.compactMap { ($0 as? [String: Any])?["userId"] as? String }
235+
236+
try await userIds.asyncForEach { userId in
237+
if try await sharedTimeoutService.hasPassedTimeout(userId: userId) {
238+
try await deleteAllForUserId(userId)
239+
}
240+
}
241+
}
213242
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import AuthenticatorBridgeKit
2+
import Combine
3+
4+
public class MockAuthenticatorBridgeItemService: AuthenticatorBridgeItemService {
5+
public var errorToThrow: Error?
6+
public var replaceAllCalled = false
7+
public var sharedItemsSubject = CurrentValueSubject<[AuthenticatorBridgeItemDataView], Error>([])
8+
public var storedItems: [String: [AuthenticatorBridgeItemDataView]] = [:]
9+
public var syncOn = false
10+
public var tempItem: AuthenticatorBridgeItemDataView?
11+
12+
public init() {}
13+
14+
public func checkForLogout() async throws {
15+
16+
}
17+
18+
public func deleteAllForUserId(_ userId: String) async throws {
19+
guard errorToThrow == nil else { throw errorToThrow! }
20+
storedItems[userId] = []
21+
}
22+
23+
public func fetchAllForUserId(_ userId: String) async throws -> [AuthenticatorBridgeItemDataView] {
24+
guard errorToThrow == nil else { throw errorToThrow! }
25+
return storedItems[userId] ?? []
26+
}
27+
28+
public func fetchTemporaryItem() async throws -> AuthenticatorBridgeItemDataView? {
29+
guard errorToThrow == nil else { throw errorToThrow! }
30+
return tempItem
31+
}
32+
33+
public func insertTemporaryItem(_ item: AuthenticatorBridgeItemDataView) async throws {
34+
guard errorToThrow == nil else { throw errorToThrow! }
35+
tempItem = item
36+
}
37+
38+
public func insertItems(_ items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws {
39+
guard errorToThrow == nil else { throw errorToThrow! }
40+
storedItems[userId] = items
41+
}
42+
43+
public func isSyncOn() async -> Bool {
44+
syncOn
45+
}
46+
47+
public func replaceAllItems(with items: [AuthenticatorBridgeItemDataView], forUserId userId: String) async throws {
48+
guard errorToThrow == nil else { throw errorToThrow! }
49+
storedItems[userId] = items
50+
replaceAllCalled = true
51+
}
52+
53+
public func sharedItemsPublisher() async throws ->
54+
AnyPublisher<[AuthenticatorBridgeKit.AuthenticatorBridgeItemDataView], any Error> {
55+
guard errorToThrow == nil else { throw errorToThrow! }
56+
57+
return sharedItemsSubject.eraseToAnyPublisher()
58+
}
59+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import AuthenticatorBridgeKit
2+
import BitwardenKit
3+
import Foundation
4+
5+
public final class MockSharedTimeoutService: SharedTimeoutService {
6+
public var clearTimeoutUserIds = [String]()
7+
public var clearTimeoutError: Error?
8+
public var hasPassedTimeoutResult: Result<[String: Bool], Error> = .success([:])
9+
public var updateTimeoutUserId: String?
10+
public var updateTimeoutLastActiveDate: Date?
11+
public var updateTimeoutTimeoutLength: SessionTimeoutValue?
12+
public var updateTimeoutError: Error?
13+
14+
public init() {}
15+
16+
public func clearTimeout(forUserId userId: String) async throws {
17+
if let clearTimeoutError {
18+
throw clearTimeoutError
19+
}
20+
clearTimeoutUserIds.append(userId)
21+
}
22+
23+
public func hasPassedTimeout(userId: String) async throws -> Bool {
24+
try hasPassedTimeoutResult.get()[userId] ?? false
25+
}
26+
27+
public func updateTimeout(
28+
forUserId userId: String,
29+
lastActiveDate: Date?,
30+
timeoutLength: SessionTimeoutValue
31+
) async throws {
32+
if let updateTimeoutError {
33+
throw updateTimeoutError
34+
}
35+
updateTimeoutUserId = userId
36+
updateTimeoutLastActiveDate = lastActiveDate
37+
updateTimeoutTimeoutLength = timeoutLength
38+
}
39+
}

AuthenticatorBridgeKit/SharedKeychain/SharedKeychainStorage.swift

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,18 @@ public class DefaultSharedKeychainStorage: SharedKeychainStorage {
107107
)
108108

109109
guard let resultDictionary = foundItem as? [String: Any],
110-
let data = resultDictionary[kSecValueData as String] as? T else {
110+
let data = resultDictionary[kSecValueData as String] as? Data else {
111111
throw SharedKeychainServiceError.keyNotFound(item)
112112
}
113113

114-
return data
114+
let object = try JSONDecoder.defaultDecoder.decode(T.self, from: data)
115+
return object
115116
}
116117

117118
public func setValue<T: Codable>(_ value: T, for item: SharedKeychainItem) async throws {
119+
let valueData = try JSONEncoder.defaultEncoder.encode(value)
118120
let query = [
119-
kSecValueData: value,
121+
kSecValueData: valueData,
120122
kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
121123
kSecAttrAccessGroup: sharedAppGroupIdentifier,
122124
kSecAttrAccount: item.unformattedKey,

AuthenticatorBridgeKit/SharedKeychain/SharedKeychainStorageTests.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,9 @@ final class SharedKeychainStorageTests: BitwardenTestCase {
5252
func test_getValue_success() async throws {
5353
let key = SymmetricKey(size: .bits256)
5454
let data = key.withUnsafeBytes { Data(Array($0)) }
55+
let encodedData = try JSONEncoder.defaultEncoder.encode(data)
5556

56-
keychainService.setSearchResultData(data)
57+
keychainService.setSearchResultData(encodedData)
5758

5859
let returnData: Data = try await subject.getValue(for: .authenticatorKey)
5960
XCTAssertEqual(returnData, data)
@@ -113,6 +114,7 @@ final class SharedKeychainStorageTests: BitwardenTestCase {
113114
func test_setAuthenticatorKey_success() async throws {
114115
let key = SymmetricKey(size: .bits256)
115116
let data = key.withUnsafeBytes { Data(Array($0)) }
117+
let encodedData = try JSONEncoder.defaultEncoder.encode(data)
116118
try await subject.setValue(data, for: .authenticatorKey)
117119

118120
let attributes = try XCTUnwrap(keychainService.addAttributes as? [CFString: Any])
@@ -123,6 +125,6 @@ final class SharedKeychainStorageTests: BitwardenTestCase {
123125
SharedKeychainItem.authenticatorKey.unformattedKey)
124126
try XCTAssertEqual(XCTUnwrap(attributes[kSecClass] as? String),
125127
String(kSecClassGenericPassword))
126-
try XCTAssertEqual(XCTUnwrap(attributes[kSecValueData] as? Data), data)
128+
try XCTAssertEqual(XCTUnwrap(attributes[kSecValueData] as? Data), encodedData)
127129
}
128130
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import BitwardenKit
2+
import Foundation
3+
4+
// MARK: - HasSharedTimeoutService
5+
6+
/// Protocol for an object that provides a `SharedTimeoutService`
7+
///
8+
public protocol HasSharedTimeoutService {
9+
/// The service for managing account timeout between apps.
10+
var sharedTimeoutService: SharedTimeoutService { get }
11+
}
12+
13+
// MARK: - SharedTimeoutService
14+
15+
/// A service that manages account timeout between apps.
16+
///
17+
public protocol SharedTimeoutService {
18+
/// Clears the shared timeout for a user.
19+
/// - Parameters:
20+
/// - userId: The user's ID
21+
func clearTimeout(forUserId userId: String) async throws
22+
23+
/// Determines if a user has passed their timeout, using the current time and the saved shared time.
24+
/// If the current time is equal to the timeout time, then it is considered passed. If there is no
25+
/// saved time, then this will always return false.
26+
/// - Parameters:
27+
/// - userId: The user's ID
28+
/// - Returns: whether or not the user has passed their timeout
29+
func hasPassedTimeout(userId: String) async throws -> Bool
30+
31+
/// Updates the shared timeout for a user.
32+
/// - Parameters:
33+
/// - userId: The user's ID
34+
/// - lastActiveDate: The last time the user was active
35+
/// - timeoutLength: The user's preferred timeout length
36+
func updateTimeout(forUserId userId: String, lastActiveDate: Date?, timeoutLength: SessionTimeoutValue) async throws
37+
}
38+
39+
// MARK: - DefaultTimeoutService
40+
41+
public final class DefaultSharedTimeoutService: SharedTimeoutService {
42+
/// A repository for managing keychain items to be shared between Password Manager and Authenticator.
43+
private let sharedKeychainRepository: SharedKeychainRepository
44+
45+
/// A service for providing the current time.
46+
private let timeProvider: TimeProvider
47+
48+
public init(
49+
sharedKeychainRepository: SharedKeychainRepository,
50+
timeProvider: TimeProvider
51+
) {
52+
self.sharedKeychainRepository = sharedKeychainRepository
53+
self.timeProvider = timeProvider
54+
}
55+
56+
public func clearTimeout(forUserId userId: String) async throws {
57+
try await sharedKeychainRepository.setAccountAutoLogoutTime(nil, userId: userId)
58+
}
59+
60+
public func hasPassedTimeout(userId: String) async throws -> Bool {
61+
guard let autoLogoutTime = try await sharedKeychainRepository.getAccountAutoLogoutTime(userId: userId) else {
62+
return false
63+
}
64+
return timeProvider.presentTime >= autoLogoutTime
65+
}
66+
67+
public func updateTimeout(
68+
forUserId userId: String,
69+
lastActiveDate: Date?,
70+
timeoutLength: SessionTimeoutValue
71+
) async throws {
72+
guard let lastActiveDate else {
73+
try await clearTimeout(forUserId: userId)
74+
return
75+
}
76+
77+
let timeout = lastActiveDate.addingTimeInterval(TimeInterval(timeoutLength.seconds))
78+
79+
try await sharedKeychainRepository.setAccountAutoLogoutTime(timeout, userId: userId)
80+
}
81+
}

0 commit comments

Comments
 (0)