diff --git a/Package.swift b/Package.swift index ab74f96..cd01699 100644 --- a/Package.swift +++ b/Package.swift @@ -17,16 +17,23 @@ let package = Package( name: "OAuthKit", targets: ["OAuthKit"]) ], + dependencies: [ + // Android / Linux Dependencies + .package(url: "https://github.com/apple/swift-crypto", from: .init(4, 5, 0)) + ], targets: [ .target( name: "OAuthKit", + dependencies: [ + .product(name: "Crypto", + package: "swift-crypto", + condition: .when(platforms: [.android, .linux]) + ) + ], linkerSettings: [ - .linkedFramework("CryptoKit"), - .linkedFramework("LocalAuthentication", .when( - platforms: [.iOS] - )), - .linkedFramework("Network"), - .linkedFramework("Security") + .linkedFramework("LocalAuthentication", + .when(platforms: [.iOS]) + ), ] ), .testTarget( diff --git a/README.md b/README.md index 337f42b..0877c61 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ OAuthKit can be installed using [Swift Package Manager](https://www.swift.org/do ```swift dependencies: [ - .package(url: "https://github.com/codefiesta/OAuthKit", from: "2.1.1") + .package(url: "https://github.com/codefiesta/OAuthKit", from: "2.2.0") ] ``` @@ -315,3 +315,8 @@ OAuthKit should work with any standard OAuth2 provider. Below is a list of teste You can find the complete Swift DocC documentation for the [OAuthKit Framework here](https://codefiesta.github.io/OAuthKit/documentation/oauthkit/). + +## Linux / Android Support + +As of version [2.2.0](https://github.com/codefiesta/OAuthKit/releases/tag/2.2.0) OAuthKit will now compile for both [Android and Linux](https://www.swift.org/documentation/articles/swift-sdk-for-android-getting-started.html). However, secure storage still needs to be implemented for both [Android](https://github.com/codefiesta/OAuthKit/issues/153) and [Linux](https://github.com/codefiesta/OAuthKit/issues/152). + diff --git a/Sources/OAuthKit/Extensions/Data+Extensions.swift b/Sources/OAuthKit/Extensions/Data+Extensions.swift index c1a022d..beed811 100644 --- a/Sources/OAuthKit/Extensions/Data+Extensions.swift +++ b/Sources/OAuthKit/Extensions/Data+Extensions.swift @@ -5,7 +5,11 @@ // Created by Kevin McKee // +#if canImport(CryptoKit) import CryptoKit +#else +import Crypto +#endif import Foundation extension Data { @@ -43,7 +47,16 @@ extension Data { /// - Returns: an array of cryptographically secure random bytes static func secureRandom(count: Int = 32) -> Data { var bytes = [UInt8](repeating: 0, count: count) + #if canImport(CryptoKit) + // Apple _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) + #else + // Android / Linux + var generator = SystemRandomNumberGenerator() + for i in 0.. Bool + + /// Fetches storeed data from the data store with the specified key. + /// - Parameter key: the keychain key + /// - Returns: the data for the specified key or nil if not found + func get(key: String) throws -> Data? + + /// Deletes the value for the specified key. + /// - Parameter key: the key to delete + /// - Returns: true if able to delete from the storage, otherwise false + func delete(key: String) -> Bool + + /// Clears all values and keys for the current account. + /// - Returns: true if values were cleared, otherwise false. + func clear() -> Bool + + /// Builds the combined account key by prefixing the specified key with the account. + /// - Parameter key: the key to prefix. + /// - Returns: the unique account key to use + func accountKey(_ key: String) -> String + } + + #if canImport(Security) + /// The default token storage used by Apple ecosystems. + struct DefaultStorage: Storage { + + var account: String = defaultAccount + + init(account: String) { + self.account = account + } + + var keys: [String] { + var results = [String]() + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecReturnAttributes as String: true, + kSecMatchLimit as String: kSecMatchLimitAll + ] + + var result: AnyObject? + let status = withUnsafeMutablePointer(to: &result) { pointer in + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer(pointer)) + } + + guard status == noErr else { return results } + + if let items = result as? [[String: Any]] { + for item in items { + if let key = item[kSecAttrAccount as String] as? String { + results.append(key) + } + } + } + return results.filter{ $0.starts(with: account)}.sorted{ $0 < $1} + } + + /// Sets the value for the specified key. + /// - Parameters: + /// - value: the value to store + /// - key: the key to use + /// - Returns: true if able to set the value, otherwise false + @discardableResult + func set(_ data: Data, for key: String) throws -> Bool { + assert(key.isNotEmpty, "❌ The keychain key cannot be empty.") + + let account = accountKey(key) + delete(key: account) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: account, + kSecValueData as String: data + ] + + let status = SecItemAdd(query as CFDictionary, nil) + return status == errSecSuccess + } + + /// Fetches storeed data from the data store with the specified key. + /// - Parameter key: the keychain key + /// - Returns: the data for the specified key or nil if not found + func get(key: String) throws -> Data? { + + let account = accountKey(key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecMatchLimit as String: kSecMatchLimitOne, + kSecAttrAccount as String: account, + kSecReturnData as String: true + ] + + var result: AnyObject? + let status = withUnsafeMutablePointer(to: &result) { pointer in + SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer(pointer)) + } + + guard status == noErr, let data = result as? Data else { + return nil + } + + return data + } + + /// Clears all values and keys for the current account. + /// - Returns: true if values were cleared, otherwise false. + @discardableResult + func clear() -> Bool { + + var results: [Bool] = [] + for key in keys { + results.append(delete(key: key)) + } + + guard results.isNotEmpty else { return true } + return results.allSatisfy{ $0 == true } + } + + /// Deletes the value for the specified key. + /// - Parameter key: the key to delete + /// - Returns: true if able to delete from the storage, otherwise false + @discardableResult + func delete(key: String) -> Bool { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + let status = SecItemDelete(query as CFDictionary) + return status == noErr + } + } + #endif +} + +extension Keychain.Storage { + + /// Builds the combined account key by prefixing the specified key with the account. + /// - Parameter key: the key to prefix. + /// - Returns: the unique account key to use + func accountKey(_ key: String) -> String { + account + "." + key + "." + tokenIdentifier + } +} diff --git a/Sources/OAuthKit/Keychain/Keychain.swift b/Sources/OAuthKit/Keychain/Keychain.swift index 99717d2..bcb5b9a 100644 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ b/Sources/OAuthKit/Keychain/Keychain.swift @@ -6,56 +6,37 @@ // import Foundation +#if canImport(Security) import Security +#endif -/// The default application tag to use. -private let defaultApplicationTag = "oauthkit" -/// The default token identifier suffix. -private let tokenIdentifier = "oauth-token" - -/// A helper class used to interact with Keychain access. +/// A helper class used to interact with Keychain storage access. Wraps all storage write operations with threadsafe locks. class Keychain: @unchecked Sendable { static let `default`: Keychain = Keychain() private let lock = NSLock() private let encoder = JSONEncoder() private let decoder = JSONDecoder() - private var applicationTag: String = defaultApplicationTag + private var storage: Storage? = nil private init() { } - /// Initializes the keychain with an overridden application tag. - /// - Parameter applicationTag: the application tag to use. Ideally, use the application identifier for this value. - public init(_ applicationTag: String) { - self.applicationTag = applicationTag + /// Initializes the keychain with an overridden accound identifier. + /// - Parameter account: a key indicating the account owner. Ideally, use the application identifier for this value. + public init(_ account: String) { + assert(account.isNotEmpty, "❌ The account identifier cannot be empty.") + #if canImport(Security) + self.storage = DefaultStorage(account: account) + #elseif os(Android) + // TODO: Android storage not implemented + #elseif os(Linux) + // TODO: Linux storage not implemented + #endif } /// Queries the keychain for keys. var keys: [String] { - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecReturnAttributes as String: true, - kSecMatchLimit as String: kSecMatchLimitAll - ] - - var result: AnyObject? - let status = withUnsafeMutablePointer(to: &result) { pointer in - SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer(pointer)) - } - - guard status == noErr else { return [] } - - var results = [String]() - if let items = result as? [[String: Any]] { - for item in items { - if let key = item[kSecAttrAccount as String] as? String { - results.append(key) - } - } - } - - return results.filter{ $0.starts(with: applicationTag)}.sorted{ $0 < $1} + storage?.keys ?? [] } /// Sets the value for the specified key. @@ -69,21 +50,12 @@ class Keychain: @unchecked Sendable { lock.lock() defer { lock.unlock() } - let account = accountKey(key) - deleteNoLock(account) - + guard let storage else { return false } let data = try encoder.encode(value) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: account, - kSecValueData as String: data - ] - - let status = SecItemAdd(query as CFDictionary, nil) - return status == errSecSuccess + return try storage.set(data, for: key) } - /// Fetches a storeed value from the keychain with the specified key and attempts to decode it from the implied generic. + /// Fetches a stored value from the keychain with the specified key and attempts to decode it from the implied generic. /// - Parameter key: the keychain key /// - Returns: the generic codeable for the specified key or nil if not found func get(key: String) throws -> T? where T: Codable { @@ -91,24 +63,8 @@ class Keychain: @unchecked Sendable { lock.lock() defer { lock.unlock() } - let account = accountKey(key) - - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecMatchLimit as String: kSecMatchLimitOne, - kSecAttrAccount as String: account, - kSecReturnData as String: true - ] - - var result: AnyObject? - let status = withUnsafeMutablePointer(to: &result) { pointer in - SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer(pointer)) - } - - guard status == noErr, let data = result as? Data else { - return nil - } - + guard let storage else { return nil } + guard let data = try storage.get(key: key) else { return nil } let value = try? decoder.decode(T.self, from: data) return value } @@ -117,17 +73,9 @@ class Keychain: @unchecked Sendable { /// - Returns: true if values were cleared, otherwise false. @discardableResult func clear() -> Bool { - lock.lock() defer { lock.unlock() } - - var results: [Bool] = [] - for key in keys { - results.append(deleteNoLock(key)) - } - - guard results.isNotEmpty else { return true } - return results.allSatisfy{ $0 == true } + return storage?.clear() ?? false } /// Deletes the value for the specified key. @@ -138,27 +86,8 @@ class Keychain: @unchecked Sendable { lock.lock() defer { lock.unlock() } - let account = accountKey(key) - return deleteNoLock(account) - } - - /// Attempts to delete the value for the specifed key without a lock in place. - /// - Parameter key: the key to delete - /// - Returns: true if able to delete from the keychain, otherwise false - @discardableResult - private func deleteNoLock(_ key: String) -> Bool { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key - ] - let status = SecItemDelete(query as CFDictionary) - return status == noErr - } - - /// Builds the account key by prefixing the specified key with the application tag. - /// - Parameter key: the key to prefix. - /// - Returns: the unique account key to use - private func accountKey(_ key: String) -> String { - applicationTag + "." + key + "." + tokenIdentifier + guard let storage else { return false } + let account = storage.accountKey(key) + return storage.delete(key: account) } } diff --git a/Sources/OAuthKit/Network/NetworkMonitor.swift b/Sources/OAuthKit/Network/NetworkMonitor.swift index 6ec1113..f48207a 100644 --- a/Sources/OAuthKit/Network/NetworkMonitor.swift +++ b/Sources/OAuthKit/Network/NetworkMonitor.swift @@ -5,7 +5,9 @@ // Created by Kevin McKee // +#if canImport(Network) import Network +#endif import Observation /// An `Observable` type that publishes network reachability information. @@ -16,8 +18,10 @@ public final class NetworkMonitor: Sendable { // The shared singleton network monitor. public static let shared: NetworkMonitor = .init() + #if canImport(Network) @ObservationIgnored private let pathMonitor = NWPathMonitor() + #endif /// Flag indicating if monitoring is currently active or not. public private(set) var isMonitoring = false @@ -39,13 +43,17 @@ public final class NetworkMonitor: Sendable { /// Starts the network monitor (conforms to AsyncSequence). public func start() async { + #if canImport(Network) guard !isMonitoring else { return } isMonitoring.toggle() for await path in pathMonitor { handle(path: path) } + #endif } + #if canImport(Network) + /// Handles the snapshot view of the network path state. /// - Parameter path: the snapshot view of the network path state private func handle(path: NWPath) { @@ -53,4 +61,6 @@ public final class NetworkMonitor: Sendable { onCellular = path.usesInterfaceType(.cellular) onWiredEthernet = path.usesInterfaceType(.wiredEthernet) } + + #endif } diff --git a/Sources/OAuthKit/OAuth+Request.swift b/Sources/OAuthKit/OAuth+Request.swift index f850c39..ef36426 100644 --- a/Sources/OAuthKit/OAuth+Request.swift +++ b/Sources/OAuthKit/OAuth+Request.swift @@ -6,6 +6,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif private let httpPost = "POST" private let httpAcceptHeaderField = "Accept" diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index 178b995..c91de1a 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -5,6 +5,9 @@ // Created by Kevin McKee // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif #if canImport(LocalAuthentication) import LocalAuthentication #endif