mirror of
https://github.com/bitwarden/ios.git
synced 2025-12-11 04:34:55 -06:00
BIT-1916: Migrate tokens to keychain (#490)
This commit is contained in:
parent
b86aec872b
commit
b01c5aa9e8
@ -35,7 +35,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
guard !isTesting else { return true }
|
||||
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
|
||||
#if DEBUG
|
||||
let errorReporter = OSLogErrorReporter()
|
||||
@ -43,7 +42,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||
let errorReporter = CrashlyticsErrorReporter()
|
||||
#endif
|
||||
|
||||
let services = ServiceContainer(errorReporter: errorReporter, nfcReaderService: DefaultNFCReaderService())
|
||||
let services = ServiceContainer(
|
||||
application: UIApplication.shared,
|
||||
errorReporter: errorReporter,
|
||||
nfcReaderService: DefaultNFCReaderService()
|
||||
)
|
||||
let appModule = DefaultAppModule(services: services)
|
||||
appProcessor = AppProcessor(appModule: appModule, services: services)
|
||||
return true
|
||||
|
||||
4
Bitwarden/Application/UIApplication+Application.swift
Normal file
4
Bitwarden/Application/UIApplication+Application.swift
Normal file
@ -0,0 +1,4 @@
|
||||
import BitwardenShared
|
||||
import UIKit
|
||||
|
||||
extension UIApplication: Application {}
|
||||
@ -204,6 +204,9 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
|
||||
/// The service used by the application to manage the environment settings.
|
||||
private let environmentService: EnvironmentService
|
||||
|
||||
/// The repository used to manages keychain items.
|
||||
private let keychainRepository: KeychainRepository
|
||||
|
||||
/// The service used by the application to manage the policy.
|
||||
private var policyService: PolicyService
|
||||
|
||||
@ -239,6 +242,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
|
||||
/// - clientGenerators: The client used for generating passwords and passphrases.
|
||||
/// - clientPlatform: The client used by the application to handle account fingerprint phrase generation.
|
||||
/// - environmentService: The service used by the application to manage the environment settings.
|
||||
/// - keychainRepository: The repository used to manages keychain items.
|
||||
/// - policyService: The service used by the application to manage the policy.
|
||||
/// - stateService: The object used by the application to retrieve information about this device.
|
||||
/// - systemDevice: The object used by the application to retrieve information about this device.
|
||||
@ -251,6 +255,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
|
||||
clientGenerators: ClientGeneratorsProtocol,
|
||||
clientPlatform: ClientPlatformProtocol,
|
||||
environmentService: EnvironmentService,
|
||||
keychainRepository: KeychainRepository,
|
||||
policyService: PolicyService,
|
||||
stateService: StateService,
|
||||
systemDevice: SystemDevice
|
||||
@ -262,6 +267,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
|
||||
self.clientGenerators = clientGenerators
|
||||
self.clientPlatform = clientPlatform
|
||||
self.environmentService = environmentService
|
||||
self.keychainRepository = keychainRepository
|
||||
self.policyService = policyService
|
||||
self.stateService = stateService
|
||||
self.systemDevice = systemDevice
|
||||
@ -613,11 +619,7 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
|
||||
// Create the account.
|
||||
let urls = await stateService.getPreAuthEnvironmentUrls()
|
||||
let account = try Account(identityTokenResponseModel: identityTokenResponse, environmentUrls: urls)
|
||||
await stateService.addAccount(account)
|
||||
|
||||
// Save the encryption keys.
|
||||
let encryptionKeys = AccountEncryptionKeys(identityTokenResponseModel: identityTokenResponse)
|
||||
try await stateService.setAccountEncryptionKeys(encryptionKeys)
|
||||
try await saveAccount(account, identityTokenResponse: identityTokenResponse)
|
||||
|
||||
return identityTokenResponse
|
||||
} catch let error as IdentityTokenRequestError {
|
||||
@ -645,6 +647,30 @@ class DefaultAuthService: AuthService { // swiftlint:disable:this type_body_leng
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the user's account information.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - account: The user's account.
|
||||
/// - identityTokenResponse: The response from the identity token request.
|
||||
///
|
||||
private func saveAccount(_ account: Account, identityTokenResponse: IdentityTokenResponseModel) async throws {
|
||||
await stateService.addAccount(account)
|
||||
|
||||
// Save the encryption keys.
|
||||
let encryptionKeys = AccountEncryptionKeys(identityTokenResponseModel: identityTokenResponse)
|
||||
try await stateService.setAccountEncryptionKeys(encryptionKeys)
|
||||
|
||||
// Save the account tokens.
|
||||
try await keychainRepository.setAccessToken(
|
||||
identityTokenResponse.accessToken,
|
||||
userId: account.profile.userId
|
||||
)
|
||||
try await keychainRepository.setRefreshToken(
|
||||
identityTokenResponse.refreshToken,
|
||||
userId: account.profile.userId
|
||||
)
|
||||
}
|
||||
|
||||
/// Saves the user's master password hash.
|
||||
///
|
||||
/// - Parameter password: The user's master password to hash and save.
|
||||
|
||||
@ -16,6 +16,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
|
||||
var clientGenerators: MockClientGenerators!
|
||||
var clientPlatform: MockClientPlatform!
|
||||
var environmentService: MockEnvironmentService!
|
||||
var keychainRepository: MockKeychainRepository!
|
||||
var stateService: MockStateService!
|
||||
var policyService: MockPolicyService!
|
||||
var subject: DefaultAuthService!
|
||||
@ -34,6 +35,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
|
||||
clientGenerators = MockClientGenerators()
|
||||
clientPlatform = MockClientPlatform()
|
||||
environmentService = MockEnvironmentService()
|
||||
keychainRepository = MockKeychainRepository()
|
||||
policyService = MockPolicyService()
|
||||
stateService = MockStateService()
|
||||
systemDevice = MockSystemDevice()
|
||||
@ -46,6 +48,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
|
||||
clientGenerators: clientGenerators,
|
||||
clientPlatform: clientPlatform,
|
||||
environmentService: environmentService,
|
||||
keychainRepository: keychainRepository,
|
||||
policyService: policyService,
|
||||
stateService: stateService,
|
||||
systemDevice: systemDevice
|
||||
@ -63,6 +66,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
|
||||
clientGenerators = nil
|
||||
clientPlatform = nil
|
||||
environmentService = nil
|
||||
keychainRepository = nil
|
||||
stateService = nil
|
||||
subject = nil
|
||||
systemDevice = nil
|
||||
@ -246,7 +250,7 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
|
||||
}
|
||||
|
||||
/// `loginWithMasterPassword(_:username:captchaToken:)` logs in with the password.
|
||||
func test_loginWithMasterPassword() async throws {
|
||||
func test_loginWithMasterPassword() async throws { // swiftlint:disable:this function_body_length
|
||||
// Set up the mock data.
|
||||
client.results = [
|
||||
.httpSuccess(testData: .preLoginSuccess),
|
||||
@ -299,6 +303,14 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
|
||||
stateService.masterPasswordHashes,
|
||||
["13512467-9cfe-43b0-969f-07534084764b": "hashed password"]
|
||||
)
|
||||
try XCTAssertEqual(
|
||||
keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
|
||||
IdentityTokenResponseModel.fixture().accessToken
|
||||
)
|
||||
try XCTAssertEqual(
|
||||
keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
|
||||
IdentityTokenResponseModel.fixture().refreshToken
|
||||
)
|
||||
}
|
||||
|
||||
/// `loginWithMasterPassword(_:username:captchaToken:)` logs in with the password updates AccountProfile's
|
||||
@ -428,6 +440,14 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
|
||||
),
|
||||
]
|
||||
)
|
||||
try XCTAssertEqual(
|
||||
keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
|
||||
IdentityTokenResponseModel.fixture().accessToken
|
||||
)
|
||||
try XCTAssertEqual(
|
||||
keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
|
||||
IdentityTokenResponseModel.fixture().refreshToken
|
||||
)
|
||||
|
||||
XCTAssertEqual(account, .fixtureAccountLogin())
|
||||
}
|
||||
@ -493,6 +513,14 @@ class AuthServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body_
|
||||
stateService.masterPasswordHashes,
|
||||
["13512467-9cfe-43b0-969f-07534084764b": "hashed password"]
|
||||
)
|
||||
try XCTAssertEqual(
|
||||
keychainRepository.getValue(for: .accessToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
|
||||
IdentityTokenResponseModel.fixture().accessToken
|
||||
)
|
||||
try XCTAssertEqual(
|
||||
keychainRepository.getValue(for: .refreshToken(userId: "13512467-9cfe-43b0-969f-07534084764b")),
|
||||
IdentityTokenResponseModel.fixture().refreshToken
|
||||
)
|
||||
|
||||
XCTAssertEqual(account, .fixtureAccountLogin())
|
||||
}
|
||||
|
||||
@ -3,21 +3,29 @@ import Foundation
|
||||
// MARK: - KeychainItem
|
||||
|
||||
enum KeychainItem: Equatable {
|
||||
/// The keychain item for a user's access token.
|
||||
case accessToken(userId: String)
|
||||
|
||||
/// The keychain item for biometrics protected user auth key.
|
||||
case biometrics(userId: String)
|
||||
|
||||
/// The keychain item for the neverLock user auth key.
|
||||
case neverLock(userId: String)
|
||||
|
||||
/// The keychain item for a user's refresh token.
|
||||
case refreshToken(userId: String)
|
||||
|
||||
/// The `SecAccessControlCreateFlags` protection level for this keychain item.
|
||||
/// If `nil`, no extra protection is applied.
|
||||
///
|
||||
var protection: SecAccessControlCreateFlags? {
|
||||
switch self {
|
||||
case .accessToken,
|
||||
.neverLock,
|
||||
.refreshToken:
|
||||
nil
|
||||
case .biometrics:
|
||||
.biometryCurrentSet
|
||||
case .neverLock:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,10 +33,14 @@ enum KeychainItem: Equatable {
|
||||
///
|
||||
var unformattedKey: String {
|
||||
switch self {
|
||||
case let .accessToken(userId):
|
||||
"accessToken_\(userId)"
|
||||
case let .biometrics(userId: id):
|
||||
"biometric_key_" + id
|
||||
case let .neverLock(userId: id):
|
||||
"userKeyAutoUnlock_" + id
|
||||
case let .refreshToken(userId):
|
||||
"refreshToken_\(userId)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -42,6 +54,20 @@ protocol KeychainRepository: AnyObject {
|
||||
///
|
||||
func deleteUserAuthKey(for item: KeychainItem) async throws
|
||||
|
||||
/// Gets the stored access token for a user from the keychain.
|
||||
///
|
||||
/// - Parameter userId: The user ID associated with the stored access token.
|
||||
/// - Returns: The user's access token.
|
||||
///
|
||||
func getAccessToken(userId: String) async throws -> String
|
||||
|
||||
/// Gets the stored refresh token for a user from the keychain.
|
||||
///
|
||||
/// - Parameter userId: The user ID associated with the stored refresh token.
|
||||
/// - Returns: The user's refresh token.
|
||||
///
|
||||
func getRefreshToken(userId: String) async throws -> String
|
||||
|
||||
/// Gets a user auth key value.
|
||||
///
|
||||
/// - Parameter item: The storage key of the user auth key.
|
||||
@ -49,6 +75,22 @@ protocol KeychainRepository: AnyObject {
|
||||
///
|
||||
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String
|
||||
|
||||
/// Stores the access token for a user in the keychain.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The access token to store.
|
||||
/// - userId: The user's ID, used to get back the token later on.
|
||||
///
|
||||
func setAccessToken(_ value: String, userId: String) async throws
|
||||
|
||||
/// Stores the refresh token for a user in the keychain.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The refresh token to store.
|
||||
/// - userId: The user's ID, used to get back the token later on.
|
||||
///
|
||||
func setRefreshToken(_ value: String, userId: String) async throws
|
||||
|
||||
/// Sets a user auth key/value pair.
|
||||
///
|
||||
/// - Parameters:
|
||||
@ -107,13 +149,7 @@ class DefaultKeychainRepository: KeychainRepository {
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func deleteUserAuthKey(for item: KeychainItem) async throws {
|
||||
try await keychainService.delete(
|
||||
query: keychainQueryValues(for: item)
|
||||
)
|
||||
}
|
||||
|
||||
/// Generates a formated storage key for a keychain item.
|
||||
/// Generates a formatted storage key for a keychain item.
|
||||
///
|
||||
/// - Parameter item: The keychain item that needs a formatted key.
|
||||
/// - Returns: A formatted storage key.
|
||||
@ -123,7 +159,12 @@ class DefaultKeychainRepository: KeychainRepository {
|
||||
return String(format: storageKeyFormat, appId, item.unformattedKey)
|
||||
}
|
||||
|
||||
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
|
||||
/// Gets the value associated with the keychain item from the keychain.
|
||||
///
|
||||
/// - Parameter item: The keychain item used to fetch the associated value.
|
||||
/// - Returns: The fetched value associated with the keychain item.
|
||||
///
|
||||
func getValue(for item: KeychainItem) async throws -> String {
|
||||
let foundItem = try await keychainService.search(
|
||||
query: keychainQueryValues(
|
||||
for: item,
|
||||
@ -166,7 +207,7 @@ class DefaultKeychainRepository: KeychainRepository {
|
||||
kSecClass: kSecClassGenericPassword,
|
||||
]
|
||||
|
||||
// Add the addional key value pairs.
|
||||
// Add the additional key value pairs.
|
||||
additionalPairs.forEach { key, value in
|
||||
result[key] = value
|
||||
}
|
||||
@ -174,7 +215,13 @@ class DefaultKeychainRepository: KeychainRepository {
|
||||
return result as CFDictionary
|
||||
}
|
||||
|
||||
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
|
||||
/// Sets a value associated with a keychain item in the keychain.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - value: The value associated with the keychain item to set.
|
||||
/// - item: The keychain item used to set the associated value.
|
||||
///
|
||||
func setValue(_ value: String, for item: KeychainItem) async throws {
|
||||
let accessControl = try keychainService.accessControl(
|
||||
for: item.protection ?? []
|
||||
)
|
||||
@ -196,3 +243,35 @@ class DefaultKeychainRepository: KeychainRepository {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultKeychainRepository {
|
||||
func deleteUserAuthKey(for item: KeychainItem) async throws {
|
||||
try await keychainService.delete(
|
||||
query: keychainQueryValues(for: item)
|
||||
)
|
||||
}
|
||||
|
||||
func getAccessToken(userId: String) async throws -> String {
|
||||
try await getValue(for: .accessToken(userId: userId))
|
||||
}
|
||||
|
||||
func getRefreshToken(userId: String) async throws -> String {
|
||||
try await getValue(for: .refreshToken(userId: userId))
|
||||
}
|
||||
|
||||
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
|
||||
try await getValue(for: item)
|
||||
}
|
||||
|
||||
func setAccessToken(_ value: String, userId: String) async throws {
|
||||
try await setValue(value, for: .accessToken(userId: userId))
|
||||
}
|
||||
|
||||
func setRefreshToken(_ value: String, userId: String) async throws {
|
||||
try await setValue(value, for: .refreshToken(userId: userId))
|
||||
}
|
||||
|
||||
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
|
||||
try await setValue(value, for: item)
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import XCTest
|
||||
|
||||
// MARK: - KeychainRepositoryTests
|
||||
|
||||
final class KeychainRepositoryTests: BitwardenTestCase {
|
||||
final class KeychainRepositoryTests: BitwardenTestCase { // swiftlint:disable:this type_body_length
|
||||
// MARK: Properties
|
||||
|
||||
var appSettingsStore: MockAppSettingsStore!
|
||||
@ -105,6 +105,38 @@ final class KeychainRepositoryTests: BitwardenTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
/// `getAccessToken(userId:)` returns the stored access token.
|
||||
func test_getAccessToken() async throws {
|
||||
keychainService.setSearchResultData(string: "ACCESS_TOKEN")
|
||||
let accessToken = try await subject.getAccessToken(userId: "1")
|
||||
XCTAssertEqual(accessToken, "ACCESS_TOKEN")
|
||||
}
|
||||
|
||||
/// `getAccessToken(userId:)` throws an error if one occurs.
|
||||
func test_getAccessToken_error() async {
|
||||
let error = KeychainServiceError.keyNotFound(.accessToken(userId: "1"))
|
||||
keychainService.searchResult = .failure(error)
|
||||
await assertAsyncThrows(error: error) {
|
||||
_ = try await subject.getAccessToken(userId: "1")
|
||||
}
|
||||
}
|
||||
|
||||
/// `getRefreshToken(userId:)` returns the stored refresh token.
|
||||
func test_getRefreshToken() async throws {
|
||||
keychainService.setSearchResultData(string: "REFRESH_TOKEN")
|
||||
let accessToken = try await subject.getRefreshToken(userId: "1")
|
||||
XCTAssertEqual(accessToken, "REFRESH_TOKEN")
|
||||
}
|
||||
|
||||
/// `getRefreshToken(userId:)` throws an error if one occurs.
|
||||
func test_getRefreshToken_error() async {
|
||||
let error = KeychainServiceError.keyNotFound(.refreshToken(userId: "1"))
|
||||
keychainService.searchResult = .failure(error)
|
||||
await assertAsyncThrows(error: error) {
|
||||
_ = try await subject.getRefreshToken(userId: "1")
|
||||
}
|
||||
}
|
||||
|
||||
/// `getUserAuthKeyValue(_:)` failures rethrow.
|
||||
///
|
||||
func test_getUserAuthKeyValue_error_searchError() async {
|
||||
@ -208,6 +240,64 @@ final class KeychainRepositoryTests: BitwardenTestCase {
|
||||
)
|
||||
}
|
||||
|
||||
/// `setAccessToken(userId:)` stored the access token.
|
||||
func test_setAccessToken() async throws {
|
||||
keychainService.accessControlResult = .success(
|
||||
SecAccessControlCreateWithFlags(
|
||||
nil,
|
||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
[],
|
||||
nil
|
||||
)!
|
||||
)
|
||||
keychainService.setSearchResultData(string: "ACCESS_TOKEN")
|
||||
try await subject.setAccessToken("ACCESS_TOKEN", userId: "1")
|
||||
|
||||
let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary
|
||||
try XCTAssertEqual(
|
||||
String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8),
|
||||
"ACCESS_TOKEN"
|
||||
)
|
||||
}
|
||||
|
||||
/// `setAccessToken(userId:)` throws an error if one occurs.
|
||||
func test_setAccessToken_error() async {
|
||||
let error = KeychainServiceError.accessControlFailed(nil)
|
||||
keychainService.addResult = .failure(error)
|
||||
await assertAsyncThrows(error: error) {
|
||||
_ = try await subject.setAccessToken("ACCESS_TOKEN", userId: "1")
|
||||
}
|
||||
}
|
||||
|
||||
/// `setRefreshToken(userId:)` stored the refresh token.
|
||||
func test_setRefreshToken() async throws {
|
||||
keychainService.accessControlResult = .success(
|
||||
SecAccessControlCreateWithFlags(
|
||||
nil,
|
||||
kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
|
||||
[],
|
||||
nil
|
||||
)!
|
||||
)
|
||||
keychainService.setSearchResultData(string: "REFRESH_TOKEN")
|
||||
try await subject.setRefreshToken("REFRESH_TOKEN", userId: "1")
|
||||
|
||||
let attributes = try XCTUnwrap(keychainService.addAttributes) as Dictionary
|
||||
try XCTAssertEqual(
|
||||
String(data: XCTUnwrap(attributes[kSecValueData] as? Data), encoding: .utf8),
|
||||
"REFRESH_TOKEN"
|
||||
)
|
||||
}
|
||||
|
||||
/// `setRefreshToken(userId:)` throws an error if one occurs.
|
||||
func test_setRefreshToken_error() async {
|
||||
let error = KeychainServiceError.accessControlFailed(nil)
|
||||
keychainService.addResult = .failure(error)
|
||||
await assertAsyncThrows(error: error) {
|
||||
_ = try await subject.setRefreshToken("REFRESH_TOKEN", userId: "1")
|
||||
}
|
||||
}
|
||||
|
||||
/// `setUserAuthKey(_:)` failures rethrow.
|
||||
///
|
||||
func test_setUserAuthKey_error_accessControl() async {
|
||||
|
||||
@ -10,12 +10,28 @@ class MockKeychainRepository: KeychainRepository {
|
||||
var getResult: Result<String, Error>?
|
||||
var setResult: Result<Void, Error> = .success(())
|
||||
|
||||
var getAccessTokenResult: Result<String, Error> = .success("ACCESS_TOKEN")
|
||||
|
||||
var getRefreshTokenResult: Result<String, Error> = .success("REFRESH_TOKEN")
|
||||
|
||||
var setAccessTokenResult: Result<Void, Error> = .success(())
|
||||
|
||||
var setRefreshTokenResult: Result<Void, Error> = .success(())
|
||||
|
||||
func deleteUserAuthKey(for item: KeychainItem) async throws {
|
||||
try deleteResult.get()
|
||||
let formattedKey = formattedKey(for: item)
|
||||
mockStorage = mockStorage.filter { $0.key != formattedKey }
|
||||
}
|
||||
|
||||
func getAccessToken(userId: String) async throws -> String {
|
||||
try getAccessTokenResult.get()
|
||||
}
|
||||
|
||||
func getRefreshToken(userId: String) async throws -> String {
|
||||
try getRefreshTokenResult.get()
|
||||
}
|
||||
|
||||
func getUserAuthKeyValue(for item: KeychainItem) async throws -> String {
|
||||
let formattedKey = formattedKey(for: item)
|
||||
if let result = getResult {
|
||||
@ -29,10 +45,28 @@ class MockKeychainRepository: KeychainRepository {
|
||||
}
|
||||
}
|
||||
|
||||
func getValue(for item: KeychainItem) throws -> String {
|
||||
let formattedKey = formattedKey(for: item)
|
||||
guard let value = mockStorage[formattedKey] else {
|
||||
throw KeychainServiceError.keyNotFound(item)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func formattedKey(for item: KeychainItem) -> String {
|
||||
String(format: storageKeyFormat, appId, item.unformattedKey)
|
||||
}
|
||||
|
||||
func setAccessToken(_ value: String, userId: String) async throws {
|
||||
try setAccessTokenResult.get()
|
||||
mockStorage[formattedKey(for: .accessToken(userId: userId))] = value
|
||||
}
|
||||
|
||||
func setRefreshToken(_ value: String, userId: String) async throws {
|
||||
try setRefreshTokenResult.get()
|
||||
mockStorage[formattedKey(for: .refreshToken(userId: userId))] = value
|
||||
}
|
||||
|
||||
func setUserAuthKey(for item: KeychainItem, value: String) async throws {
|
||||
let formattedKey = formattedKey(for: item)
|
||||
securityType = item.protection
|
||||
|
||||
@ -38,3 +38,10 @@ extension MockKeychainService: KeychainService {
|
||||
return try searchResult.get()
|
||||
}
|
||||
}
|
||||
|
||||
extension MockKeychainService {
|
||||
func setSearchResultData(string: String) {
|
||||
let dictionary = [kSecValueData as String: Data(string.utf8)]
|
||||
searchResult = .success(dictionary as AnyObject)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,15 @@
|
||||
/// Domain model for a user account.
|
||||
///
|
||||
public struct Account: Codable, Equatable, Hashable {
|
||||
// MARK: Types
|
||||
|
||||
/// Key names used for encoding and decoding.
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case profile
|
||||
case settings
|
||||
case _tokens = "tokens" // swiftlint:disable:this identifier_name
|
||||
}
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// The account's profile details.
|
||||
@ -10,7 +19,11 @@ public struct Account: Codable, Equatable, Hashable {
|
||||
var settings: AccountSettings
|
||||
|
||||
/// The account's API tokens.
|
||||
var tokens: AccountTokens
|
||||
///
|
||||
/// Note: This is deprecated, but remains to support migration - the tokens have been moved to
|
||||
/// the keychain.
|
||||
///
|
||||
var _tokens: AccountTokens? // swiftlint:disable:this identifier_name
|
||||
}
|
||||
|
||||
extension Account {
|
||||
@ -60,10 +73,7 @@ extension Account {
|
||||
settings: AccountSettings(
|
||||
environmentUrls: environmentUrls
|
||||
),
|
||||
tokens: AccountTokens(
|
||||
accessToken: identityTokenResponseModel.accessToken,
|
||||
refreshToken: identityTokenResponseModel.refreshToken
|
||||
)
|
||||
_tokens: nil // Tokens have been moved out of `State` to the keychain.
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,10 +57,7 @@ class AccountTests: BitwardenTestCase {
|
||||
userId: "13512467-9cfe-43b0-969f-07534084764b"
|
||||
),
|
||||
settings: Account.AccountSettings(environmentUrls: nil),
|
||||
tokens: Account.AccountTokens(
|
||||
accessToken: accessToken,
|
||||
refreshToken: "REFRESH_TOKEN"
|
||||
)
|
||||
_tokens: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,12 +6,12 @@ extension Account {
|
||||
static func fixture(
|
||||
profile: AccountProfile = .fixture(),
|
||||
settings: AccountSettings = .fixture(),
|
||||
tokens: AccountTokens = .fixture()
|
||||
tokens: AccountTokens? = nil
|
||||
) -> Account {
|
||||
Account(
|
||||
profile: profile,
|
||||
settings: settings,
|
||||
tokens: tokens
|
||||
_tokens: tokens
|
||||
)
|
||||
}
|
||||
|
||||
@ -32,9 +32,7 @@ extension Account {
|
||||
settings: Account.AccountSettings(
|
||||
environmentUrls: EnvironmentUrlData(base: URL(string: "https://vault.bitwarden.com")!)
|
||||
),
|
||||
tokens: Account.AccountTokens.fixture(
|
||||
accessToken: IdentityTokenResponseModel.fixture().accessToken
|
||||
)
|
||||
tokens: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -84,15 +82,3 @@ extension Account.AccountSettings {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension Account.AccountTokens {
|
||||
static func fixture(
|
||||
accessToken: String = "ACCESS_TOKEN",
|
||||
refreshToken: String = "REFRESH_TOKEN"
|
||||
) -> Account.AccountTokens {
|
||||
Account.AccountTokens(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
7
BitwardenShared/Core/Platform/Services/Application.swift
Normal file
7
BitwardenShared/Core/Platform/Services/Application.swift
Normal file
@ -0,0 +1,7 @@
|
||||
/// A protocol for the application instance (i.e. `UIApplication`).
|
||||
///
|
||||
public protocol Application {
|
||||
/// Registers the application to receive remote push notifications.
|
||||
///
|
||||
func registerForRemoteNotifications()
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import OSLog
|
||||
|
||||
// MARK: - MigrationService
|
||||
|
||||
/// A protocol for a service which handles app data migrations.
|
||||
///
|
||||
protocol MigrationService: AnyObject {
|
||||
/// Performs any necessary app data migrations.
|
||||
///
|
||||
func performMigrations() async
|
||||
}
|
||||
|
||||
// MARK: - DefaultMigrationService
|
||||
|
||||
/// A default implementation of `MigrationService` which handles app data migrations.
|
||||
///
|
||||
class DefaultMigrationService {
|
||||
// MARK: Properties
|
||||
|
||||
/// The service used by the application to persist app setting values.
|
||||
let appSettingsStore: AppSettingsStore
|
||||
|
||||
/// The service used by the application to report non-fatal errors.
|
||||
let errorReporter: ErrorReporter
|
||||
|
||||
/// The repository used to manage keychain items.
|
||||
let keychainRepository: KeychainRepository
|
||||
|
||||
// MARK: Initialization
|
||||
|
||||
/// Initialize a `DefaultMigrationService`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - appSettingsStore: The service used by the application to persist app setting values.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - keychainRepository: The repository used to manage keychain items.
|
||||
///
|
||||
init(
|
||||
appSettingsStore: AppSettingsStore,
|
||||
errorReporter: ErrorReporter,
|
||||
keychainRepository: KeychainRepository
|
||||
) {
|
||||
self.appSettingsStore = appSettingsStore
|
||||
self.errorReporter = errorReporter
|
||||
self.keychainRepository = keychainRepository
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
/// Performs migration 1.
|
||||
///
|
||||
/// Notes:
|
||||
/// - Migrates account tokens from UserDefaults to Keychain.
|
||||
///
|
||||
private func performMigration1() async throws {
|
||||
guard var state = appSettingsStore.state else { return }
|
||||
defer { appSettingsStore.state = state }
|
||||
|
||||
for (accountId, account) in state.accounts {
|
||||
let tokens = account._tokens
|
||||
state.accounts[accountId]?._tokens = nil
|
||||
|
||||
guard let tokens else { continue }
|
||||
try await keychainRepository.setAccessToken(tokens.accessToken, userId: accountId)
|
||||
try await keychainRepository.setRefreshToken(tokens.refreshToken, userId: accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultMigrationService: MigrationService {
|
||||
func performMigrations() async {
|
||||
var migrationVersion = appSettingsStore.migrationVersion
|
||||
defer { appSettingsStore.migrationVersion = migrationVersion }
|
||||
|
||||
// The list of migrations that can be performed.
|
||||
let migrations: [(version: Int, method: () async throws -> Void)] = [
|
||||
(1, performMigration1),
|
||||
]
|
||||
|
||||
do {
|
||||
for migration in migrations where migrationVersion < migration.version {
|
||||
try await migration.method()
|
||||
migrationVersion = migration.version
|
||||
Logger.application.info("Completed data migration \(migration.version)")
|
||||
}
|
||||
} catch {
|
||||
errorReporter.log(error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
import XCTest
|
||||
|
||||
@testable import BitwardenShared
|
||||
|
||||
class MigrationServiceTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var appSettingsStore: MockAppSettingsStore!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var keychainRepository: MockKeychainRepository!
|
||||
var subject: DefaultMigrationService!
|
||||
|
||||
// MARK: Setup & Teardown
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
appSettingsStore = MockAppSettingsStore()
|
||||
errorReporter = MockErrorReporter()
|
||||
keychainRepository = MockKeychainRepository()
|
||||
|
||||
subject = DefaultMigrationService(
|
||||
appSettingsStore: appSettingsStore,
|
||||
errorReporter: errorReporter,
|
||||
keychainRepository: keychainRepository
|
||||
)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
appSettingsStore = nil
|
||||
errorReporter = nil
|
||||
keychainRepository = nil
|
||||
subject = nil
|
||||
}
|
||||
|
||||
// MARK: Tests
|
||||
|
||||
/// `performMigrations()` logs an error to the error reporter if one occurs.
|
||||
func test_performMigrations_error() async throws {
|
||||
appSettingsStore.migrationVersion = 0
|
||||
appSettingsStore.state = .fixture(
|
||||
accounts: [
|
||||
"1": .fixture(
|
||||
tokens: Account.AccountTokens(
|
||||
accessToken: "ACCESS_TOKEN_1",
|
||||
refreshToken: "REFRESH_TOKEN_1"
|
||||
)
|
||||
),
|
||||
],
|
||||
activeUserId: "1"
|
||||
)
|
||||
keychainRepository.setAccessTokenResult = .failure(KeychainServiceError.osStatusError(-1))
|
||||
|
||||
await subject.performMigrations()
|
||||
|
||||
XCTAssertEqual(appSettingsStore.migrationVersion, 0)
|
||||
XCTAssertEqual(errorReporter.errors as? [KeychainServiceError], [KeychainServiceError.osStatusError(-1)])
|
||||
}
|
||||
|
||||
/// `performMigrations()` performs migration 1 and moves the user's tokens to the keychain.
|
||||
func test_performMigrations_1_withAccounts() async throws {
|
||||
appSettingsStore.migrationVersion = 0
|
||||
appSettingsStore.state = .fixture(
|
||||
accounts: [
|
||||
"1": .fixture(
|
||||
tokens: Account.AccountTokens(
|
||||
accessToken: "ACCESS_TOKEN_1",
|
||||
refreshToken: "REFRESH_TOKEN_1"
|
||||
)
|
||||
),
|
||||
"2": .fixture(
|
||||
tokens: Account.AccountTokens(
|
||||
accessToken: "ACCESS_TOKEN_2",
|
||||
refreshToken: "REFRESH_TOKEN_2"
|
||||
)
|
||||
),
|
||||
],
|
||||
activeUserId: "1"
|
||||
)
|
||||
|
||||
await subject.performMigrations()
|
||||
|
||||
XCTAssertEqual(appSettingsStore.migrationVersion, 1)
|
||||
|
||||
let account1 = try XCTUnwrap(appSettingsStore.state?.accounts["1"])
|
||||
XCTAssertNil(account1._tokens)
|
||||
let account2 = try XCTUnwrap(appSettingsStore.state?.accounts["2"])
|
||||
XCTAssertNil(account2._tokens)
|
||||
|
||||
try XCTAssertEqual(keychainRepository.getValue(for: .accessToken(userId: "1")), "ACCESS_TOKEN_1")
|
||||
try XCTAssertEqual(keychainRepository.getValue(for: .refreshToken(userId: "1")), "REFRESH_TOKEN_1")
|
||||
try XCTAssertEqual(keychainRepository.getValue(for: .accessToken(userId: "2")), "ACCESS_TOKEN_2")
|
||||
try XCTAssertEqual(keychainRepository.getValue(for: .refreshToken(userId: "2")), "REFRESH_TOKEN_2")
|
||||
}
|
||||
|
||||
/// `performMigrations()` for migration 1 handles no existing accounts.
|
||||
func test_performMigrations_1_withNoAccounts() async throws {
|
||||
appSettingsStore.migrationVersion = 0
|
||||
appSettingsStore.state = nil
|
||||
|
||||
await subject.performMigrations()
|
||||
|
||||
XCTAssertEqual(appSettingsStore.migrationVersion, 1)
|
||||
XCTAssertNil(appSettingsStore.state)
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
/// The service used by the application to manage the app's ID.
|
||||
let appIdService: AppIdService
|
||||
|
||||
/// The application instance (i.e. `UIApplication`), if the app isn't running in an extension.
|
||||
let application: Application?
|
||||
|
||||
/// The service used by the application to persist app setting values.
|
||||
let appSettingsStore: AppSettingsStore
|
||||
|
||||
@ -32,7 +35,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
/// The service used by the application to handle authentication tasks.
|
||||
let authService: AuthService
|
||||
|
||||
/// The repository to manage bioemtric unlock policies and access controls the user.
|
||||
/// The repository to manage biometric unlock policies and access controls the user.
|
||||
let biometricsRepository: BiometricsRepository
|
||||
|
||||
/// The service used to obtain device biometrics status & data.
|
||||
@ -65,6 +68,9 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
/// The repository used to manage keychain items.
|
||||
let keychainRepository: KeychainRepository
|
||||
|
||||
/// The serviced used to perform app data migrations.
|
||||
let migrationService: MigrationService
|
||||
|
||||
/// The service used by the application to read NFC tags.
|
||||
let nfcReaderService: NFCReaderService
|
||||
|
||||
@ -123,6 +129,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
/// - Parameters:
|
||||
/// - apiService: The service used by the application to make API requests.
|
||||
/// - appIdService: The service used by the application to manage the app's ID.
|
||||
/// - application: The application instance.
|
||||
/// - appSettingsStore: The service used by the application to persist app setting values.
|
||||
/// - authRepository: The repository used by the application to manage auth data for the UI layer.
|
||||
/// - authService: The service used by the application to handle authentication tasks.
|
||||
@ -137,6 +144,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
/// - generatorRepository: The repository used by the application to manage generator data for the UI layer.
|
||||
/// - keychainRepository: The repository used to manages keychain items.
|
||||
/// - keychainService: The service used to access & store data on the device keychain.
|
||||
/// - migrationService: The serviced used to perform app data migrations.
|
||||
/// - nfcReaderService: The service used by the application to read NFC tags.
|
||||
/// - notificationCenterService: The service used by the application to access the system's notification center.
|
||||
/// - notificationService: The service used by the application to handle notifications.
|
||||
@ -158,6 +166,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
init(
|
||||
apiService: APIService,
|
||||
appIdService: AppIdService,
|
||||
application: Application?,
|
||||
appSettingsStore: AppSettingsStore,
|
||||
authRepository: AuthRepository,
|
||||
authService: AuthService,
|
||||
@ -172,6 +181,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
generatorRepository: GeneratorRepository,
|
||||
keychainRepository: KeychainRepository,
|
||||
keychainService: KeychainService,
|
||||
migrationService: MigrationService,
|
||||
nfcReaderService: NFCReaderService,
|
||||
notificationCenterService: NotificationCenterService,
|
||||
notificationService: NotificationService,
|
||||
@ -192,6 +202,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
) {
|
||||
self.apiService = apiService
|
||||
self.appIdService = appIdService
|
||||
self.application = application
|
||||
self.appSettingsStore = appSettingsStore
|
||||
self.authRepository = authRepository
|
||||
self.authService = authService
|
||||
@ -206,6 +217,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
self.generatorRepository = generatorRepository
|
||||
self.keychainService = keychainService
|
||||
self.keychainRepository = keychainRepository
|
||||
self.migrationService = migrationService
|
||||
self.nfcReaderService = nfcReaderService
|
||||
self.notificationCenterService = notificationCenterService
|
||||
self.notificationService = notificationService
|
||||
@ -228,10 +240,12 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
/// A convenience initializer to initialize the `ServiceContainer` with the default services.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - application: The application instance.
|
||||
/// - errorReporter: The service used by the application to report non-fatal errors.
|
||||
/// - nfcReaderService: The service used by the application to read NFC tags.
|
||||
///
|
||||
public convenience init( // swiftlint:disable:this function_body_length
|
||||
application: Application? = nil,
|
||||
errorReporter: ErrorReporter,
|
||||
nfcReaderService: NFCReaderService? = nil
|
||||
) {
|
||||
@ -263,7 +277,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
let environmentService = DefaultEnvironmentService(stateService: stateService)
|
||||
let collectionService = DefaultCollectionService(collectionDataStore: dataStore, stateService: stateService)
|
||||
let settingsService = DefaultSettingsService(settingsDataStore: dataStore, stateService: stateService)
|
||||
let tokenService = DefaultTokenService(stateService: stateService)
|
||||
let tokenService = DefaultTokenService(keychainRepository: keychainRepository, stateService: stateService)
|
||||
let apiService = APIService(environmentService: environmentService, tokenService: tokenService)
|
||||
let captchaService = DefaultCaptchaService(environmentService: environmentService, stateService: stateService)
|
||||
let notificationCenterService = DefaultNotificationCenterService()
|
||||
@ -350,6 +364,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
clientGenerators: clientService.clientGenerator(),
|
||||
clientPlatform: clientService.clientPlatform(),
|
||||
environmentService: environmentService,
|
||||
keychainRepository: keychainRepository,
|
||||
policyService: policyService,
|
||||
stateService: stateService,
|
||||
systemDevice: UIDevice.current
|
||||
@ -369,6 +384,12 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
vaultTimeoutService: vaultTimeoutService
|
||||
)
|
||||
|
||||
let migrationService = DefaultMigrationService(
|
||||
appSettingsStore: appSettingsStore,
|
||||
errorReporter: errorReporter,
|
||||
keychainRepository: keychainRepository
|
||||
)
|
||||
|
||||
let notificationService = DefaultNotificationService(
|
||||
appIdService: appIdService,
|
||||
authRepository: authRepository,
|
||||
@ -426,6 +447,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
self.init(
|
||||
apiService: apiService,
|
||||
appIdService: appIdService,
|
||||
application: application,
|
||||
appSettingsStore: appSettingsStore,
|
||||
authRepository: authRepository,
|
||||
authService: authService,
|
||||
@ -440,6 +462,7 @@ public class ServiceContainer: Services { // swiftlint:disable:this type_body_le
|
||||
generatorRepository: generatorRepository,
|
||||
keychainRepository: keychainRepository,
|
||||
keychainService: keychainService,
|
||||
migrationService: migrationService,
|
||||
nfcReaderService: nfcReaderService ?? NoopNFCReaderService(),
|
||||
notificationCenterService: notificationCenterService,
|
||||
notificationService: notificationService,
|
||||
|
||||
@ -445,15 +445,6 @@ protocol StateService: AnyObject {
|
||||
///
|
||||
func setTimeoutAction(action: SessionTimeoutAction, userId: String?) async throws
|
||||
|
||||
/// Sets a new access and refresh token for an account.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - accessToken: The account's updated access token.
|
||||
/// - refreshToken: The account's updated refresh token.
|
||||
/// - userId: The user ID of the account. Defaults to the active account if `nil`.
|
||||
///
|
||||
func setTokens(accessToken: String, refreshToken: String, userId: String?) async throws
|
||||
|
||||
/// Sets the user's two-factor token.
|
||||
///
|
||||
/// - Parameters:
|
||||
@ -844,16 +835,6 @@ extension StateService {
|
||||
try await setTimeoutAction(action: action, userId: nil)
|
||||
}
|
||||
|
||||
/// Sets a new access and refresh token for the active account.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - accessToken: The account's updated access token.
|
||||
/// - refreshToken: The account's updated refresh token.
|
||||
///
|
||||
func setTokens(accessToken: String, refreshToken: String) async throws {
|
||||
try await setTokens(accessToken: accessToken, refreshToken: refreshToken, userId: nil)
|
||||
}
|
||||
|
||||
/// Sets the number of unsuccessful attempts to unlock the vault for the active account.
|
||||
///
|
||||
/// - Parameter attempts: The number of unsuccessful unlock attempts.
|
||||
@ -1114,7 +1095,7 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
|
||||
|
||||
func getUnsuccessfulUnlockAttempts(userId: String?) async throws -> Int {
|
||||
let userId = try userId ?? getActiveAccountUserId()
|
||||
return appSettingsStore.unsuccessfulUnlockAttempts(userId: userId) ?? 0
|
||||
return appSettingsStore.unsuccessfulUnlockAttempts(userId: userId)
|
||||
}
|
||||
|
||||
func getUsernameGenerationOptions(userId: String?) async throws -> UsernameGenerationOptions? {
|
||||
@ -1296,20 +1277,6 @@ actor DefaultStateService: StateService { // swiftlint:disable:this type_body_le
|
||||
appSettingsStore.setTimeoutAction(key: action, userId: userId)
|
||||
}
|
||||
|
||||
func setTokens(accessToken: String, refreshToken: String, userId: String?) async throws {
|
||||
guard var state = appSettingsStore.state,
|
||||
let userId = userId ?? state.activeUserId
|
||||
else {
|
||||
throw StateServiceError.noActiveAccount
|
||||
}
|
||||
|
||||
state.accounts[userId]?.tokens = Account.AccountTokens(
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken
|
||||
)
|
||||
appSettingsStore.state = state
|
||||
}
|
||||
|
||||
func setTwoFactorToken(_ token: String?, email: String) async {
|
||||
appSettingsStore.setTwoFactorToken(token, email: email)
|
||||
}
|
||||
|
||||
@ -905,43 +905,6 @@ class StateServiceTests: BitwardenTestCase { // swiftlint:disable:this type_body
|
||||
)
|
||||
}
|
||||
|
||||
/// `setTokens(accessToken:refreshToken)` throws an error if there isn't an active account.
|
||||
func test_setAccountTokens_noAccount() async {
|
||||
await assertAsyncThrows(error: StateServiceError.noActiveAccount) {
|
||||
try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒")
|
||||
}
|
||||
}
|
||||
|
||||
/// `setTokens(accessToken:refreshToken)` sets the tokens for a single account.
|
||||
func test_setAccountTokens_singleAccount() async throws {
|
||||
await subject.addAccount(.fixture())
|
||||
|
||||
try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒")
|
||||
|
||||
let account = try XCTUnwrap(appSettingsStore.state?.accounts["1"])
|
||||
XCTAssertEqual(
|
||||
account,
|
||||
Account.fixture(tokens: Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒"))
|
||||
)
|
||||
}
|
||||
|
||||
/// `setTokens(accessToken:refreshToken)` sets the tokens for an account where there are multiple accounts.
|
||||
func test_setAccountTokens_multipleAccount() async throws {
|
||||
await subject.addAccount(.fixture(profile: .fixture(userId: "1")))
|
||||
await subject.addAccount(.fixture(profile: .fixture(userId: "2")))
|
||||
|
||||
try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒")
|
||||
|
||||
let account = try XCTUnwrap(appSettingsStore.state?.accounts["2"])
|
||||
XCTAssertEqual(
|
||||
account,
|
||||
Account.fixture(
|
||||
profile: .fixture(userId: "2"),
|
||||
tokens: Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// `setActiveAccount(userId: )` returns without action if there are no accounts
|
||||
func test_setActiveAccount_noAccounts() async throws {
|
||||
let storeState = await subject.appSettingsStore.state
|
||||
|
||||
@ -31,6 +31,9 @@ protocol AppSettingsStore: AnyObject {
|
||||
/// The login request information received from a push notification.
|
||||
var loginRequest: LoginRequestNotification? { get set }
|
||||
|
||||
/// The app's last data migration version.
|
||||
var migrationVersion: Int { get set }
|
||||
|
||||
/// The environment URLs used prior to user authentication.
|
||||
var preAuthEnvironmentUrls: EnvironmentUrlData? { get set }
|
||||
|
||||
@ -373,7 +376,7 @@ protocol AppSettingsStore: AnyObject {
|
||||
/// - Parameter userId: The user ID associated with the unsuccessful unlock attempts.
|
||||
/// - Returns: The number of unsuccessful attempts to unlock the vault.
|
||||
///
|
||||
func unsuccessfulUnlockAttempts(userId: String) -> Int?
|
||||
func unsuccessfulUnlockAttempts(userId: String) -> Int
|
||||
|
||||
/// Returns the session timeout date.
|
||||
///
|
||||
@ -435,7 +438,7 @@ class DefaultAppSettingsStore {
|
||||
/// - Parameter key: The key used to store the value.
|
||||
/// - Returns: The value associated with the given key.
|
||||
///
|
||||
private func fetch(for key: Keys) -> Int? {
|
||||
private func fetch(for key: Keys) -> Int {
|
||||
userDefaults.integer(forKey: key.storageKey)
|
||||
}
|
||||
|
||||
@ -526,6 +529,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
case lastUserShouldConnectToWatch
|
||||
case loginRequest
|
||||
case masterPasswordHash(userId: String)
|
||||
case migrationVersion
|
||||
case notificationsLastRegistrationDate(userId: String)
|
||||
case passwordGenerationOptions(userId: String)
|
||||
case pinKeyEncryptedUserKey(userId: String)
|
||||
@ -584,6 +588,8 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
key = "passwordlessLoginNotificationKey"
|
||||
case let .masterPasswordHash(userId):
|
||||
key = "keyHash_\(userId)"
|
||||
case .migrationVersion:
|
||||
key = "migrationVersion"
|
||||
case let .notificationsLastRegistrationDate(userId):
|
||||
key = "pushLastRegistrationDate_\(userId)"
|
||||
case let .passwordGenerationOptions(userId):
|
||||
@ -650,6 +656,11 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
set { store(newValue, for: .loginRequest) }
|
||||
}
|
||||
|
||||
var migrationVersion: Int {
|
||||
get { fetch(for: .migrationVersion) }
|
||||
set { store(newValue, for: .migrationVersion) }
|
||||
}
|
||||
|
||||
var preAuthEnvironmentUrls: EnvironmentUrlData? {
|
||||
get { fetch(for: .preAuthEnvironmentUrls) }
|
||||
set { store(newValue, for: .preAuthEnvironmentUrls) }
|
||||
@ -852,7 +863,7 @@ extension DefaultAppSettingsStore: AppSettingsStore {
|
||||
fetch(for: .vaultTimeout(userId: userId))
|
||||
}
|
||||
|
||||
func unsuccessfulUnlockAttempts(userId: String) -> Int? {
|
||||
func unsuccessfulUnlockAttempts(userId: String) -> Int {
|
||||
fetch(for: .unsuccessfulUnlockAttempts(userId: userId))
|
||||
}
|
||||
|
||||
|
||||
@ -416,6 +416,22 @@ class AppSettingsStoreTests: BitwardenTestCase { // swiftlint:disable:this type_
|
||||
)
|
||||
}
|
||||
|
||||
/// `migrationVersion` returns `0` if there isn't a previously stored value.
|
||||
func test_migrationVersion_isInitiallyZero() {
|
||||
XCTAssertEqual(subject.migrationVersion, 0)
|
||||
}
|
||||
|
||||
/// `migrationVersion` can be used to get and set the migration version.
|
||||
func test_migrationVersion_withValue() throws {
|
||||
subject.migrationVersion = 1
|
||||
XCTAssertEqual(userDefaults.integer(forKey: "bwPreferencesStorage:migrationVersion"), 1)
|
||||
XCTAssertEqual(subject.migrationVersion, 1)
|
||||
|
||||
subject.migrationVersion = 2
|
||||
XCTAssertEqual(userDefaults.integer(forKey: "bwPreferencesStorage:migrationVersion"), 2)
|
||||
XCTAssertEqual(subject.migrationVersion, 2)
|
||||
}
|
||||
|
||||
/// `masterPasswordHash(userId:)` returns `nil` if there isn't a previously stored value.
|
||||
func test_masterPasswordHash_isInitiallyNil() {
|
||||
XCTAssertNil(subject.masterPasswordHash(userId: "-1"))
|
||||
|
||||
@ -12,6 +12,7 @@ class MockAppSettingsStore: AppSettingsStore {
|
||||
var disableWebIcons = false
|
||||
var lastUserShouldConnectToWatch = false
|
||||
var loginRequest: LoginRequestNotification?
|
||||
var migrationVersion = 0
|
||||
var preAuthEnvironmentUrls: EnvironmentUrlData?
|
||||
var rememberedEmail: String?
|
||||
var rememberedOrgIdentifier: String?
|
||||
@ -211,8 +212,8 @@ class MockAppSettingsStore: AppSettingsStore {
|
||||
timeoutAction[userId]
|
||||
}
|
||||
|
||||
func unsuccessfulUnlockAttempts(userId: String) -> Int? {
|
||||
unsuccessfulUnlockAttempts[userId]
|
||||
func unsuccessfulUnlockAttempts(userId: String) -> Int {
|
||||
unsuccessfulUnlockAttempts[userId] ?? 0
|
||||
}
|
||||
|
||||
func usernameGenerationOptions(userId: String) -> UsernameGenerationOptions? {
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
@testable import BitwardenShared
|
||||
|
||||
class MockMigrationService: MigrationService {
|
||||
var didPerformMigrations: Bool?
|
||||
|
||||
func performMigrations() async {
|
||||
didPerformMigrations = true
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import Networking
|
||||
|
||||
extension ServiceContainer {
|
||||
static func withMocks(
|
||||
application: Application? = nil,
|
||||
appSettingsStore: AppSettingsStore = MockAppSettingsStore(),
|
||||
authRepository: AuthRepository = MockAuthRepository(),
|
||||
authService: AuthService = MockAuthService(),
|
||||
@ -20,6 +21,7 @@ extension ServiceContainer {
|
||||
httpClient: HTTPClient = MockHTTPClient(),
|
||||
keychainRepository: KeychainRepository = MockKeychainRepository(),
|
||||
keychainService: KeychainService = MockKeychainService(),
|
||||
migrationService: MigrationService = MockMigrationService(),
|
||||
nfcReaderService: NFCReaderService = MockNFCReaderService(),
|
||||
notificationService: NotificationService = MockNotificationService(),
|
||||
pasteboardService: PasteboardService = MockPasteboardService(),
|
||||
@ -44,6 +46,7 @@ extension ServiceContainer {
|
||||
environmentService: environmentService
|
||||
),
|
||||
appIdService: AppIdService(appSettingStore: appSettingsStore),
|
||||
application: application,
|
||||
appSettingsStore: appSettingsStore,
|
||||
authRepository: authRepository,
|
||||
authService: authService,
|
||||
@ -58,6 +61,7 @@ extension ServiceContainer {
|
||||
generatorRepository: generatorRepository,
|
||||
keychainRepository: keychainRepository,
|
||||
keychainService: keychainService,
|
||||
migrationService: migrationService,
|
||||
nfcReaderService: nfcReaderService,
|
||||
notificationCenterService: notificationCenterService,
|
||||
notificationService: notificationService,
|
||||
|
||||
@ -29,6 +29,9 @@ protocol TokenService: AnyObject {
|
||||
actor DefaultTokenService: TokenService {
|
||||
// MARK: Properties
|
||||
|
||||
/// The repository used to manages keychain items.
|
||||
let keychainRepository: KeychainRepository
|
||||
|
||||
/// The service that manages the account state.
|
||||
let stateService: StateService
|
||||
|
||||
@ -36,23 +39,33 @@ actor DefaultTokenService: TokenService {
|
||||
|
||||
/// Initialize a `DefaultTokenService`.
|
||||
///
|
||||
/// - Parameter stateService: The service that manages the account state.
|
||||
/// - Parameters
|
||||
/// - keychainRepository: The repository used to manages keychain items.
|
||||
/// - stateService: The service that manages the account state.
|
||||
///
|
||||
init(stateService: StateService) {
|
||||
init(
|
||||
keychainRepository: KeychainRepository,
|
||||
stateService: StateService
|
||||
) {
|
||||
self.keychainRepository = keychainRepository
|
||||
self.stateService = stateService
|
||||
}
|
||||
|
||||
// MARK: Methods
|
||||
|
||||
func getAccessToken() async throws -> String {
|
||||
try await stateService.getActiveAccount().tokens.accessToken
|
||||
let userId = try await stateService.getActiveAccountId()
|
||||
return try await keychainRepository.getAccessToken(userId: userId)
|
||||
}
|
||||
|
||||
func getRefreshToken() async throws -> String {
|
||||
try await stateService.getActiveAccount().tokens.refreshToken
|
||||
let userId = try await stateService.getActiveAccountId()
|
||||
return try await keychainRepository.getRefreshToken(userId: userId)
|
||||
}
|
||||
|
||||
func setTokens(accessToken: String, refreshToken: String) async throws {
|
||||
try await stateService.setTokens(accessToken: accessToken, refreshToken: refreshToken)
|
||||
let userId = try await stateService.getActiveAccountId()
|
||||
try await keychainRepository.setAccessToken(accessToken, userId: userId)
|
||||
try await keychainRepository.setRefreshToken(refreshToken, userId: userId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import XCTest
|
||||
class TokenServiceTests: BitwardenTestCase {
|
||||
// MARK: Properties
|
||||
|
||||
var keychainRepository: MockKeychainRepository!
|
||||
var stateService: MockStateService!
|
||||
var subject: DefaultTokenService!
|
||||
|
||||
@ -13,14 +14,16 @@ class TokenServiceTests: BitwardenTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
keychainRepository = MockKeychainRepository()
|
||||
stateService = MockStateService()
|
||||
|
||||
subject = DefaultTokenService(stateService: stateService)
|
||||
subject = DefaultTokenService(keychainRepository: keychainRepository, stateService: stateService)
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
super.tearDown()
|
||||
|
||||
keychainRepository = nil
|
||||
stateService = nil
|
||||
subject = nil
|
||||
}
|
||||
@ -34,7 +37,7 @@ class TokenServiceTests: BitwardenTestCase {
|
||||
let accessToken = try await subject.getAccessToken()
|
||||
XCTAssertEqual(accessToken, "ACCESS_TOKEN")
|
||||
|
||||
stateService.activeAccount = .fixture(tokens: Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒"))
|
||||
keychainRepository.getAccessTokenResult = .success("🔑")
|
||||
|
||||
let updatedAccessToken = try await subject.getAccessToken()
|
||||
XCTAssertEqual(updatedAccessToken, "🔑")
|
||||
@ -56,7 +59,7 @@ class TokenServiceTests: BitwardenTestCase {
|
||||
let refreshToken = try await subject.getRefreshToken()
|
||||
XCTAssertEqual(refreshToken, "REFRESH_TOKEN")
|
||||
|
||||
stateService.activeAccount = .fixture(tokens: Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒"))
|
||||
keychainRepository.getRefreshTokenResult = .success("🔒")
|
||||
|
||||
let updatedRefreshToken = try await subject.getRefreshToken()
|
||||
XCTAssertEqual(updatedRefreshToken, "🔒")
|
||||
@ -78,8 +81,12 @@ class TokenServiceTests: BitwardenTestCase {
|
||||
try await subject.setTokens(accessToken: "🔑", refreshToken: "🔒")
|
||||
|
||||
XCTAssertEqual(
|
||||
stateService.accountTokens,
|
||||
Account.AccountTokens(accessToken: "🔑", refreshToken: "🔒")
|
||||
keychainRepository.mockStorage[keychainRepository.formattedKey(for: .accessToken(userId: "1"))],
|
||||
"🔑"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
keychainRepository.mockStorage[keychainRepository.formattedKey(for: .refreshToken(userId: "1"))],
|
||||
"🔒"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -84,15 +84,16 @@ public class AppProcessor {
|
||||
window?.overrideUserInterfaceStyle = appTheme.userInterfaceStyle
|
||||
}
|
||||
}
|
||||
Task {
|
||||
await services.environmentService.loadURLsForActiveAccount()
|
||||
}
|
||||
|
||||
if let initialRoute {
|
||||
coordinator.navigate(to: initialRoute)
|
||||
} else {
|
||||
// Navigate to the .didStart rotue
|
||||
Task {
|
||||
Task {
|
||||
await services.migrationService.performMigrations()
|
||||
|
||||
await services.environmentService.loadURLsForActiveAccount()
|
||||
services.application?.registerForRemoteNotifications()
|
||||
|
||||
if let initialRoute {
|
||||
coordinator.navigate(to: initialRoute)
|
||||
} else {
|
||||
await coordinator.handleEvent(.didStart)
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
var appSettingStore: MockAppSettingsStore!
|
||||
var coordinator: MockCoordinator<AppRoute, AppEvent>!
|
||||
var errorReporter: MockErrorReporter!
|
||||
var migrationService: MockMigrationService!
|
||||
var notificationCenterService: MockNotificationCenterService!
|
||||
var notificationService: MockNotificationService!
|
||||
var router: MockRouter<AuthEvent, AuthRoute>!
|
||||
@ -31,6 +32,7 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
appModule.appCoordinator = coordinator
|
||||
appSettingStore = MockAppSettingsStore()
|
||||
errorReporter = MockErrorReporter()
|
||||
migrationService = MockMigrationService()
|
||||
notificationCenterService = MockNotificationCenterService()
|
||||
notificationService = MockNotificationService()
|
||||
stateService = MockStateService()
|
||||
@ -43,6 +45,7 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
services: ServiceContainer.withMocks(
|
||||
appSettingsStore: appSettingStore,
|
||||
errorReporter: errorReporter,
|
||||
migrationService: migrationService,
|
||||
notificationService: notificationService,
|
||||
stateService: stateService,
|
||||
syncService: syncService,
|
||||
@ -59,6 +62,7 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
appSettingStore = nil
|
||||
coordinator = nil
|
||||
errorReporter = nil
|
||||
migrationService = nil
|
||||
notificationCenterService = nil
|
||||
notificationService = nil
|
||||
stateService = nil
|
||||
@ -161,11 +165,14 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
window: nil
|
||||
)
|
||||
|
||||
waitFor(!coordinator.routes.isEmpty)
|
||||
|
||||
XCTAssertTrue(appModule.appCoordinator.isStarted)
|
||||
XCTAssertEqual(
|
||||
appModule.appCoordinator.routes,
|
||||
[.extensionSetup(.extensionActivation(type: .appExtension))]
|
||||
)
|
||||
XCTAssertEqual(migrationService.didPerformMigrations, true)
|
||||
}
|
||||
|
||||
/// `start(navigator:)` builds the AppCoordinator and navigates to the `.didStart` route.
|
||||
@ -178,5 +185,6 @@ class AppProcessorTests: BitwardenTestCase {
|
||||
|
||||
XCTAssertTrue(appModule.appCoordinator.isStarted)
|
||||
XCTAssertEqual(appModule.appCoordinator.events, [.didStart])
|
||||
XCTAssertEqual(migrationService.didPerformMigrations, true)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user