From bc927d4c355cfad9f72a776ab407773c74f3215b Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Jul 2025 14:52:41 -0500 Subject: [PATCH] Add SecureStorage to iOS and implement refresh token methods --- ios/App/App.xcodeproj/project.pbxproj | 4 + ios/App/App/plugins/AbsDatabase.swift | 30 ++++-- ios/App/Shared/util/SecureStorage.swift | 116 ++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 ios/App/Shared/util/SecureStorage.swift diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index c72a5f88..7fa9db20 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 4D66B958282EEA14008272D4 /* AbsFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D66B957282EEA14008272D4 /* AbsFileSystem.swift */; }; 4D91EEC62A40F28D004807ED /* EBookFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D91EEC52A40F28D004807ED /* EBookFile.swift */; }; 4DABC04F2B0139CA000F6264 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DABC04E2B0139CA000F6264 /* User.swift */; }; + 4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */; }; 4DF6C7172DB58ABF004059F1 /* AbsLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */; }; 4DF74912287105C600AC7814 /* DeviceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DF74911287105C600AC7814 /* DeviceSettings.swift */; }; 4DFE2DA32D345C390000B204 /* MyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFE2DA22D345C390000B204 /* MyViewController.swift */; }; @@ -98,6 +99,7 @@ 4D8D412C26E187E400BA5F0D /* App-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "App-Bridging-Header.h"; sourceTree = ""; }; 4D91EEC52A40F28D004807ED /* EBookFile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EBookFile.swift; sourceTree = ""; }; 4DABC04E2B0139CA000F6264 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStorage.swift; sourceTree = ""; }; 4DF6C7162DB58ABF004059F1 /* AbsLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsLogger.swift; sourceTree = ""; }; 4DF74911287105C600AC7814 /* DeviceSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceSettings.swift; sourceTree = ""; }; 4DFE2DA22D345C390000B204 /* MyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyViewController.swift; sourceTree = ""; }; @@ -239,6 +241,7 @@ 3AFCB5E627EA23F700ECCC05 /* util */ = { isa = PBXGroup; children = ( + 4DB441E02E19B8BF0056C8F1 /* SecureStorage.swift */, E9FA07E228C82848005520B0 /* Logger.swift */, 3AFCB5E727EA240D00ECCC05 /* NowPlayingInfo.swift */, 3AD4FCEA280443DD006DB301 /* Database.swift */, @@ -550,6 +553,7 @@ E9D5505828AC1C1A00C746DD /* Library.swift in Sources */, 3AD4FCEB280443DD006DB301 /* Database.swift in Sources */, 3AD4FCE528043E50006DB301 /* AbsDatabase.swift in Sources */, + 4DB441E12E19B8BF0056C8F1 /* SecureStorage.swift in Sources */, 4DABC04F2B0139CA000F6264 /* User.swift in Sources */, E9D5506828AC1DC300C746DD /* LocalPodcastEpisode.swift in Sources */, EACB38162BCCA1500060DA4A /* DefaultedAudioPlayerRateManager.swift in Sources */, diff --git a/ios/App/App/plugins/AbsDatabase.swift b/ios/App/App/plugins/AbsDatabase.swift index ca0749b4..ac8d5457 100644 --- a/ios/App/App/plugins/AbsDatabase.swift +++ b/ios/App/App/plugins/AbsDatabase.swift @@ -33,6 +33,8 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "setCurrentServerConnectionConfig", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "removeServerConnectionConfig", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "getRefreshToken", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearRefreshToken", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "logout", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getDeviceData", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "getLocalLibraryItems", returnType: CAPPluginReturnPromise), @@ -49,6 +51,7 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { ] private let logger = AppLogger(category: "AbsDatabase") + private let secureStorage = SecureStorage() @objc func setCurrentServerConnectionConfig(_ call: CAPPluginCall) { var id = call.getString("id") @@ -62,7 +65,9 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { let name = "\(address) (\(username))" if (refreshToken != "") { - // TODO: Implement secure storage + // Store refresh token securely if provided + let hasRefreshToken = secureStorage.storeRefreshToken(serverConnectionConfigId: id ?? "", refreshToken: refreshToken) + logger.log("Refresh token secured = \(hasRefreshToken)") } if id == nil { @@ -83,22 +88,35 @@ public class AbsDatabase: CAPPlugin, CAPBridgedPlugin { let savedConfig = Store.serverConfig // Fetch the latest value call.resolve(convertServerConnectionConfigToJSON(config: savedConfig!)) } + @objc func removeServerConnectionConfig(_ call: CAPPluginCall) { let id = call.getString("serverConnectionConfigId", "") + + // Remove refresh token if it exists + secureStorage.removeRefreshToken(serverConnectionConfigId: id) + Database.shared.deleteServerConnectionConfig(id: id) - call.resolve() } + @objc func getRefreshToken(_ call: CAPPluginCall) { let serverConnectionConfigId = call.getString("serverConnectionConfigId", "") - // TODO: Implement secure storage - call.resolve() + + let refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId: serverConnectionConfigId) + if let refreshToken = refreshToken { + call.resolve(["refreshToken": refreshToken]) + } else { + call.resolve() + } } + @objc func clearRefreshToken(_ call: CAPPluginCall) { let serverConnectionConfigId = call.getString("serverConnectionConfigId", "") - // TODO: Implement secure storage - call.resolve() + + let success = secureStorage.removeRefreshToken(serverConnectionConfigId: serverConnectionConfigId) + call.resolve(["success": success]) } + @objc func logout(_ call: CAPPluginCall) { Store.serverConfig = nil call.resolve() diff --git a/ios/App/Shared/util/SecureStorage.swift b/ios/App/Shared/util/SecureStorage.swift new file mode 100644 index 00000000..babad6f6 --- /dev/null +++ b/ios/App/Shared/util/SecureStorage.swift @@ -0,0 +1,116 @@ +// +// SecureStorage.swift +// App +// +// Created by advplyr on 2025-07-05 +// + +import Foundation +import Security + +class SecureStorage { + private static let tag = "SecureStorage" + private static let serviceName = "AudiobookshelfRefreshTokens" + + /** + * Encrypts and stores a refresh token for a specific server connection + */ + func storeRefreshToken(serverConnectionConfigId: String, refreshToken: String) -> Bool { + let key = "refresh_token_\(serverConnectionConfigId)" + + guard let data = refreshToken.data(using: .utf8) else { + print("\(Self.tag): Failed to convert refresh token to data") + return false + } + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + // First, try to delete any existing item + SecItemDelete(query as CFDictionary) + + // Then add the new item + let status = SecItemAdd(query as CFDictionary, nil) + + if status == errSecSuccess { + print("\(Self.tag): Successfully stored encrypted refresh token for server: \(serverConnectionConfigId)") + return true + } else { + print("\(Self.tag): Failed to store refresh token for server: \(serverConnectionConfigId), status: \(status)") + return false + } + } + + /** + * Retrieves and decrypts a refresh token for a specific server connection + */ + func getRefreshToken(serverConnectionConfigId: String) -> String? { + let key = "refresh_token_\(serverConnectionConfigId)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, + let data = result as? Data, + let refreshToken = String(data: data, encoding: .utf8) { + return refreshToken + } else { + print("\(Self.tag): Failed to retrieve refresh token for server: \(serverConnectionConfigId), status: \(status)") + return nil + } + } + + /** + * Removes a refresh token for a specific server connection + */ + func removeRefreshToken(serverConnectionConfigId: String) -> Bool { + let key = "refresh_token_\(serverConnectionConfigId)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key + ] + + let status = SecItemDelete(query as CFDictionary) + + if status == errSecSuccess || status == errSecItemNotFound { + print("\(Self.tag): Successfully removed refresh token for server: \(serverConnectionConfigId)") + return true + } else { + print("\(Self.tag): Failed to remove refresh token for server: \(serverConnectionConfigId), status: \(status)") + return false + } + } + + /** + * Checks if a refresh token exists for a specific server connection + */ + func hasRefreshToken(serverConnectionConfigId: String) -> Bool { + let key = "refresh_token_\(serverConnectionConfigId)" + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Self.serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: false, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + let status = SecItemCopyMatching(query as CFDictionary, nil) + return status == errSecSuccess + } +} \ No newline at end of file