diff --git a/Development Assets/DevelopmentModels.swift b/Development Assets/DevelopmentModels.swift index b780246..a4170a3 100644 --- a/Development Assets/DevelopmentModels.swift +++ b/Development Assets/DevelopmentModels.swift @@ -10,19 +10,18 @@ private let devInstanceURL = URL(string: "https://mastodon.social")! private let devIdentityID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")! private let devAccessToken = "DEVELOPMENT_ACCESS_TOKEN" -extension Secrets { - static func fresh() -> Secrets { Secrets(keychainService: MockKeychainService()) } +func freshKeychainService() -> KeychainServiceType { MockKeychainService() } - static let development: Secrets = { - let secrets = Secrets.fresh() +let developmentKeychainService: KeychainServiceType = { + let keychainService = MockKeychainService() + let secretsService = SecretsService(identityID: devIdentityID, keychainService: keychainService) - try! secrets.set("DEVELOPMENT_CLIENT_ID", forItem: .clientID, forIdentityID: devIdentityID) - try! secrets.set("DEVELOPMENT_CLIENT_SECRET", forItem: .clientSecret, forIdentityID: devIdentityID) - try! secrets.set(devAccessToken, forItem: .accessToken, forIdentityID: devIdentityID) + try! secretsService.set("DEVELOPMENT_CLIENT_ID", forItem: .clientID) + try! secretsService.set("DEVELOPMENT_CLIENT_SECRET", forItem: .clientSecret) + try! secretsService.set(devAccessToken, forItem: .accessToken) - return secrets - }() -} + return keychainService +}() extension Defaults { static func fresh() -> Defaults { Defaults(userDefaults: MockUserDefaults()) } @@ -74,13 +73,13 @@ extension AppEnvironment { URLSessionConfiguration: URLSessionConfiguration = .stubbing, identityDatabase: IdentityDatabase = .fresh(), defaults: Defaults = .fresh(), - secrets: Secrets = .fresh(), + keychainService: KeychainServiceType = freshKeychainService(), webAuthSessionType: WebAuthSessionType.Type = SuccessfulMockWebAuthSession.self) -> AppEnvironment { AppEnvironment( URLSessionConfiguration: URLSessionConfiguration, identityDatabase: identityDatabase, defaults: defaults, - secrets: secrets, + keychainService: keychainService, webAuthSessionType: webAuthSessionType) } @@ -88,7 +87,7 @@ extension AppEnvironment { URLSessionConfiguration: .stubbing, identityDatabase: .development, defaults: .development, - secrets: .development, + keychainService: developmentKeychainService, webAuthSessionType: SuccessfulMockWebAuthSession.self) } diff --git a/Metatext.xcodeproj/project.pbxproj b/Metatext.xcodeproj/project.pbxproj index 2ac1381..b8c9393 100644 --- a/Metatext.xcodeproj/project.pbxproj +++ b/Metatext.xcodeproj/project.pbxproj @@ -77,8 +77,6 @@ D0666A6424C6DC6C00F3F04B /* AppAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */; }; D0666A6F24C6DFB300F3F04B /* AccessToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A6E24C6DFB300F3F04B /* AccessToken.swift */; }; D0666A7024C6DFB300F3F04B /* AccessToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A6E24C6DFB300F3F04B /* AccessToken.swift */; }; - D0666A7224C6E0D300F3F04B /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A7124C6E0D300F3F04B /* Secrets.swift */; }; - D0666A7324C6E0D300F3F04B /* Secrets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0666A7124C6E0D300F3F04B /* Secrets.swift */; }; D0666A7D24C7745A00F3F04B /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D0666A7C24C7745A00F3F04B /* GRDB */; }; D06B491F24D3F7FE00642749 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D06B491E24D3F7FE00642749 /* Localizable.strings */; }; D06B492024D3FB8000642749 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = D06B491E24D3F7FE00642749 /* Localizable.strings */; }; @@ -140,6 +138,8 @@ D0EC8DC324DF7D9C00A08489 /* IdentityService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */; }; D0EC8DC524DF842700A08489 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; }; D0EC8DC624DF842700A08489 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC424DF842700A08489 /* KeychainService.swift */; }; + D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; }; + D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */; }; D0ED1B6E24CE100C00B4899C /* AddIdentityViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */; }; D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */; }; @@ -216,7 +216,6 @@ D0666A5324C6C3E500F3F04B /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = ""; }; D0666A6224C6DC6C00F3F04B /* AppAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAuthorization.swift; sourceTree = ""; }; D0666A6E24C6DFB300F3F04B /* AccessToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessToken.swift; sourceTree = ""; }; - D0666A7124C6E0D300F3F04B /* Secrets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Secrets.swift; sourceTree = ""; }; D06B491E24D3F7FE00642749 /* Localizable.strings */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; path = Localizable.strings; sourceTree = ""; }; D074577624D29006004758DB /* MockWebAuthSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MockWebAuthSession.swift; sourceTree = ""; }; D074577924D29366004758DB /* URLSessionConfiguration+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLSessionConfiguration+Extensions.swift"; sourceTree = ""; }; @@ -245,6 +244,7 @@ D0DC177624D0CF2600A75C65 /* MockKeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychainService.swift; sourceTree = ""; }; D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IdentityService.swift; sourceTree = ""; }; D0EC8DC424DF842700A08489 /* KeychainService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; + D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SecretsService.swift; sourceTree = ""; }; D0ED1B6D24CE100C00B4899C /* AddIdentityViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddIdentityViewModelTests.swift; sourceTree = ""; }; D0ED1BB624CE47F400B4899C /* WebAuthSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAuthSession.swift; sourceTree = ""; }; D0ED1BC024CED48800B4899C /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; @@ -351,6 +351,7 @@ children = ( D0EC8DC124DF7D9C00A08489 /* IdentityService.swift */, D0EC8DC424DF842700A08489 /* KeychainService.swift */, + D0EC8DC724DF8B3C00A08489 /* SecretsService.swift */, ); path = Services; sourceTree = ""; @@ -440,7 +441,6 @@ D0666A4D24C6C39600F3F04B /* Instance.swift */, D0ED1BE224CFA84400B4899C /* MastodonError.swift */, D0CD847224DBDEC700CF380C /* MastodonPreferences.swift */, - D0666A7124C6E0D300F3F04B /* Secrets.swift */, D0CD847524DBDF3C00CF380C /* Status.swift */, D0CD847B24DBEA9F00CF380C /* Unknowable.swift */, ); @@ -808,10 +808,10 @@ D0ED1BCE24CF768200B4899C /* MastodonEndpoint.swift in Sources */, D074577A24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, D0ED1BB724CE47F400B4899C /* WebAuthSession.swift in Sources */, - D0666A7224C6E0D300F3F04B /* Secrets.swift in Sources */, D0A1CA7424DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */, D0159F9124DE743700E78478 /* TabNavigationView.swift in Sources */, D0ED1BC424CED54D00B4899C /* HTTPTarget.swift in Sources */, + D0EC8DC824DF8B3C00A08489 /* SecretsService.swift in Sources */, D0159FA324DE955900E78478 /* CustomEmojiText.swift in Sources */, D0C963FE24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, D04FD73C24D4A83A007D572D /* InstanceEndpoint+Stubbing.swift in Sources */, @@ -872,6 +872,7 @@ D0DC174E24CFF1F100A75C65 /* Stubbing.swift in Sources */, D0091B6C24DC10CE0040E8D2 /* PostingReadingPreferencesViewModel.swift in Sources */, D0091B6F24DD68090040E8D2 /* PreferencesView.swift in Sources */, + D0EC8DC924DF8B3C00A08489 /* SecretsService.swift in Sources */, D0DB6EF524C5233E00D965FE /* AddIdentityView.swift in Sources */, D019E6EA24DF72E700697C7D /* InstanceEndpoint.swift in Sources */, D0159F9C24DE748C00E78478 /* SidebarNavigationView.swift in Sources */, @@ -880,7 +881,6 @@ D0ED1BCF24CF768200B4899C /* MastodonEndpoint.swift in Sources */, D074577B24D29366004758DB /* URLSessionConfiguration+Extensions.swift in Sources */, D0ED1BB824CE47F400B4899C /* WebAuthSession.swift in Sources */, - D0666A7324C6E0D300F3F04B /* Secrets.swift in Sources */, D0A1CA7524DAC2F1003063E9 /* KingfisherOptionsInfo+Extensions.swift in Sources */, D0ED1BC524CED54D00B4899C /* HTTPTarget.swift in Sources */, D0C963FF24CC3812003BD330 /* Publisher+Extensions.swift in Sources */, diff --git a/Shared/MetatextApp.swift b/Shared/MetatextApp.swift index 0916c42..c64884e 100644 --- a/Shared/MetatextApp.swift +++ b/Shared/MetatextApp.swift @@ -19,7 +19,7 @@ struct MetatextApp: App { URLSessionConfiguration: .default, identityDatabase: identityDatabase, defaults: Defaults(userDefaults: .standard), - secrets: Secrets(keychainService: KeychainService(serviceName: "com.metabolist.metatext")), + keychainService: KeychainService(serviceName: Self.keychainServiceName), webAuthSessionType: WebAuthSession.self) } @@ -29,3 +29,7 @@ struct MetatextApp: App { } } } + +private extension MetatextApp { + static let keychainServiceName = "com.metabolist.metatext" +} diff --git a/Shared/Model/AppEnvironment.swift b/Shared/Model/AppEnvironment.swift index be5d43d..ff9f7c0 100644 --- a/Shared/Model/AppEnvironment.swift +++ b/Shared/Model/AppEnvironment.swift @@ -6,6 +6,6 @@ struct AppEnvironment { let URLSessionConfiguration: URLSessionConfiguration let identityDatabase: IdentityDatabase let defaults: Defaults - let secrets: Secrets + let keychainService: KeychainServiceType let webAuthSessionType: WebAuthSessionType.Type } diff --git a/Shared/Services/IdentityService.swift b/Shared/Services/IdentityService.swift index cad79b1..cd057c9 100644 --- a/Shared/Services/IdentityService.swift +++ b/Shared/Services/IdentityService.swift @@ -16,7 +16,10 @@ class IdentityService { self.appEnvironment = appEnvironment observationErrors = observationErrorsInput.eraseToAnyPublisher() networkClient = MastodonClient(configuration: appEnvironment.URLSessionConfiguration) - networkClient.accessToken = try appEnvironment.secrets.item(.accessToken, forIdentityID: identityID) + networkClient.accessToken = try SecretsService( + identityID: identityID, + keychainService: appEnvironment.keychainService) + .item(.accessToken) let observation = appEnvironment.identityDatabase.identityObservation(id: identityID).share() diff --git a/Shared/Model/Secrets.swift b/Shared/Services/SecretsService.swift similarity index 63% rename from Shared/Model/Secrets.swift rename to Shared/Services/SecretsService.swift index b3f097a..b4ae80b 100644 --- a/Shared/Model/Secrets.swift +++ b/Shared/Services/SecretsService.swift @@ -11,42 +11,46 @@ enum SecretsStorableError: Error { case conversionFromDataStoredInSecrets(Data) } -class Secrets { +struct SecretsService { + let identityID: UUID private let keychainService: KeychainServiceType - init(keychainService: KeychainServiceType) { + init(identityID: UUID, keychainService: KeychainServiceType) { + self.identityID = identityID self.keychainService = keychainService } } -extension Secrets { - enum Item: String { +extension SecretsService { + enum Item: String, CaseIterable { case clientID = "client-id" case clientSecret = "client-secret" case accessToken = "access-token" } } -extension Secrets { - func set(_ data: SecretsStorable, forItem item: Item, forIdentityID identityID: UUID) throws { - try keychainService.set(data: data.dataStoredInSecrets, forKey: Self.key(item: item, identityID: identityID)) +extension SecretsService { + func set(_ data: SecretsStorable, forItem item: Item) throws { + try keychainService.set(data: data.dataStoredInSecrets, forKey: key(item: item)) } - func item(_ item: Item, forIdentityID identityID: UUID) throws -> T? { - guard let data = try keychainService.getData(key: Self.key(item: item, identityID: identityID)) else { + func item(_ item: Item) throws -> T? { + guard let data = try keychainService.getData(key: key(item: item)) else { return nil } return try T.fromDataStoredInSecrets(data) } - func delete(_ item: Item, forIdentityID identityID: UUID) throws { - try keychainService.deleteData(key: Self.key(item: item, identityID: identityID)) + func deleteAllItems() throws { + for item in SecretsService.Item.allCases { + try keychainService.deleteData(key: key(item: item)) + } } } -private extension Secrets { - static func key(item: Item, identityID: UUID) -> String { +private extension SecretsService { + func key(item: Item) -> String { identityID.uuidString + "." + item.rawValue } } diff --git a/Shared/View Models/AddIdentityViewModel.swift b/Shared/View Models/AddIdentityViewModel.swift index 55b7a73..d9b69ef 100644 --- a/Shared/View Models/AddIdentityViewModel.swift +++ b/Shared/View Models/AddIdentityViewModel.swift @@ -39,7 +39,7 @@ class AddIdentityViewModel: ObservableObject { identityID: identityID, instanceURL: instanceURL, redirectURL: redirectURL, - secrets: environment.secrets) + keychainService: environment.keychainService) .authenticationURL(instanceURL: instanceURL, redirectURL: redirectURL) .authenticate( webAuthSessionType: environment.webAuthSessionType, @@ -67,7 +67,7 @@ private extension AddIdentityViewModel { identityID: UUID, instanceURL: URL, redirectURL: URL, - secrets: Secrets) -> AnyPublisher { + keychainService: KeychainServiceType) -> AnyPublisher { let endpoint = AppAuthorizationEndpoint.apps( clientName: MastodonAPI.OAuth.clientName, redirectURI: redirectURL.absoluteString, @@ -77,8 +77,9 @@ private extension AddIdentityViewModel { return networkClient.request(target) .tryMap { - try secrets.set($0.clientId, forItem: .clientID, forIdentityID: identityID) - try secrets.set($0.clientSecret, forItem: .clientSecret, forIdentityID: identityID) + let secretsService = SecretsService(identityID: identityID, keychainService: keychainService) + try secretsService.set($0.clientId, forItem: .clientID) + try secretsService.set($0.clientSecret, forItem: .clientSecret) return $0 } @@ -174,7 +175,9 @@ private extension Publisher where Output == (AppAuthorization, String), Failure private extension Publisher where Output == AccessToken { func createIdentity(id: UUID, instanceURL: URL, environment: AppEnvironment) -> AnyPublisher { tryMap { accessToken -> (UUID, URL) in - try environment.secrets.set(accessToken.accessToken, forItem: .accessToken, forIdentityID: id) + let secretsService = SecretsService(identityID: id, keychainService: environment.keychainService) + + try secretsService.set(accessToken.accessToken, forItem: .accessToken) return (id, instanceURL) } diff --git a/Shared/View Models/RootViewModel.swift b/Shared/View Models/RootViewModel.swift index a810656..d1ef48e 100644 --- a/Shared/View Models/RootViewModel.swift +++ b/Shared/View Models/RootViewModel.swift @@ -26,6 +26,13 @@ extension RootViewModel { func deleteIdentity(id: UUID) { environment.identityDatabase.deleteIdentity(id: id) + .continuingIfWeakReferenceIsStillAlive(to: self) + .tryMap { + try SecretsService( + identityID: id, + keychainService: $1.environment.keychainService) + .deleteAllItems() + } .sink(receiveCompletion: { _ in }, receiveValue: {}) .store(in: &cancellables) } diff --git a/Tests/View Models/AddIdentityViewModelTests.swift b/Tests/View Models/AddIdentityViewModelTests.swift index e60c29a..273e9b4 100644 --- a/Tests/View Models/AddIdentityViewModelTests.swift +++ b/Tests/View Models/AddIdentityViewModelTests.swift @@ -20,15 +20,15 @@ class AddIdentityViewModelTests: XCTestCase { XCTAssertEqual(addedIdentity.id, addedIdentityID) XCTAssertEqual(addedIdentity.url, URL(string: "https://mastodon.social")!) + + let secretsService = SecretsService(identityID: addedIdentity.id, keychainService: environment.keychainService) + XCTAssertEqual( - try environment.secrets.item(.clientID, forIdentityID: addedIdentityID) as String?, - "AUTHORIZATION_CLIENT_ID_STUB_VALUE") + try secretsService.item(.clientID) as String?, "AUTHORIZATION_CLIENT_ID_STUB_VALUE") XCTAssertEqual( - try environment.secrets.item(.clientSecret, forIdentityID: addedIdentityID) as String?, - "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE") + try secretsService.item(.clientSecret) as String?, "AUTHORIZATION_CLIENT_SECRET_STUB_VALUE") XCTAssertEqual( - try environment.secrets.item(.accessToken, forIdentityID: addedIdentityID) as String?, - "ACCESS_TOKEN_STUB_VALUE") + try secretsService.item(.accessToken) as String?, "ACCESS_TOKEN_STUB_VALUE") } func testAddIdentityWithoutScheme() throws {