From 6e7edc0248ecac6c71e0b26b5f85b57081e877a7 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Mon, 17 Aug 2026 11:32:53 -0700 Subject: [PATCH 01/13] Android / Linux compiling. --- Package.swift | 27 ++++++++++++++----- .../OAuthKit/Extensions/Data+Extensions.swift | 9 +++++++ .../Extensions/Digest+Extensions.swift | 4 +++ .../EnvironmentValues+Extensions.swift | 3 ++- .../Extensions/String+Extensions.swift | 4 +++ .../OAuthKit/Extensions/URL+Extensions.swift | 4 +++ .../Extensions/URLRequest+Extensions.swift | 3 +++ .../Extensions/URLResponse+Extensions.swift | 3 +++ .../OAuthKit/Extensions/UUID+Extensions.swift | 4 +++ Sources/OAuthKit/Keychain/Keychain.swift | 26 +++++++++++++++++- Sources/OAuthKit/Network/NetworkMonitor.swift | 10 +++++++ Sources/OAuthKit/OAuth+Request.swift | 3 +++ Sources/OAuthKit/OAuth.swift | 3 +++ 13 files changed, 95 insertions(+), 8 deletions(-) diff --git a/Package.swift b/Package.swift index ab74f96..558433b 100644 --- a/Package.swift +++ b/Package.swift @@ -3,6 +3,14 @@ import PackageDescription +#if os(Linux) || os(Android) +let dependencies: [Pacakge.Dependency] = [ + .package(url: "https://github.com/apple/swift-crypto.git", from: .init(4, 5, 0)) +] +#else +let dependencies: [Package.Dependency] = [] +#endif + let package = Package( name: "OAuthKit", platforms: [ @@ -17,16 +25,23 @@ let package = Package( name: "OAuthKit", targets: ["OAuthKit"]) ], + dependencies: dependencies, targets: [ .target( name: "OAuthKit", linkerSettings: [ - .linkedFramework("CryptoKit"), - .linkedFramework("LocalAuthentication", .when( - platforms: [.iOS] - )), - .linkedFramework("Network"), - .linkedFramework("Security") + .linkedFramework("CryptoKit", + .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) + ), + .linkedFramework("LocalAuthentication", + .when(platforms: [.iOS]) + ), + .linkedFramework("Network", + .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) + ), + .linkedFramework("Security", + .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) + ) ] ), .testTarget( diff --git a/Sources/OAuthKit/Extensions/Data+Extensions.swift b/Sources/OAuthKit/Extensions/Data+Extensions.swift index c1a022d..08289e6 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 { @@ -42,8 +46,13 @@ extension Data { /// - Parameter count: The number of bytes to generate. /// - Returns: an array of cryptographically secure random bytes static func secureRandom(count: Int = 32) -> Data { + #if canImport(CryptoKit) var bytes = [UInt8](repeating: 0, count: count) _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) return Data(bytes: &bytes, count: bytes.count) + #else + // Android / Linux + return .init() + #endif } } diff --git a/Sources/OAuthKit/Extensions/Digest+Extensions.swift b/Sources/OAuthKit/Extensions/Digest+Extensions.swift index 7214686..b854edc 100644 --- a/Sources/OAuthKit/Extensions/Digest+Extensions.swift +++ b/Sources/OAuthKit/Extensions/Digest+Extensions.swift @@ -5,7 +5,11 @@ // Created by Kevin McKee // +#if canImport(CryptoKit) import CryptoKit +#else +import Crypto +#endif import Foundation extension Digest { diff --git a/Sources/OAuthKit/Extensions/EnvironmentValues+Extensions.swift b/Sources/OAuthKit/Extensions/EnvironmentValues+Extensions.swift index f50488e..01983b4 100644 --- a/Sources/OAuthKit/Extensions/EnvironmentValues+Extensions.swift +++ b/Sources/OAuthKit/Extensions/EnvironmentValues+Extensions.swift @@ -4,7 +4,7 @@ // // Created by Kevin McKee // - +#if canImport(SwiftUI) import SwiftUI public extension EnvironmentValues { @@ -21,3 +21,4 @@ struct OAuthKey: @preconcurrency EnvironmentKey { /// The default OAuth instance that is loaded into the environment. @MainActor static let defaultValue: OAuth = .init(.main) } +#endif diff --git a/Sources/OAuthKit/Extensions/String+Extensions.swift b/Sources/OAuthKit/Extensions/String+Extensions.swift index 05c3239..def3f36 100644 --- a/Sources/OAuthKit/Extensions/String+Extensions.swift +++ b/Sources/OAuthKit/Extensions/String+Extensions.swift @@ -5,7 +5,11 @@ // Created by Kevin McKee // +#if canImport(CryptoKit) import CryptoKit +#else +import Crypto +#endif import Foundation extension String { diff --git a/Sources/OAuthKit/Extensions/URL+Extensions.swift b/Sources/OAuthKit/Extensions/URL+Extensions.swift index 8db00b8..aa67434 100644 --- a/Sources/OAuthKit/Extensions/URL+Extensions.swift +++ b/Sources/OAuthKit/Extensions/URL+Extensions.swift @@ -5,7 +5,11 @@ // Created by Kevin McKee // +#if canImport(CryptoKit) import CryptoKit +#else +import Crypto +#endif import Foundation extension URL { diff --git a/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift b/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift index b3bbfa4..507e1ae 100644 --- a/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift +++ b/Sources/OAuthKit/Extensions/URLRequest+Extensions.swift @@ -6,6 +6,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif private let authHeader = "Authorization" diff --git a/Sources/OAuthKit/Extensions/URLResponse+Extensions.swift b/Sources/OAuthKit/Extensions/URLResponse+Extensions.swift index 3a1cd63..08cbdf6 100644 --- a/Sources/OAuthKit/Extensions/URLResponse+Extensions.swift +++ b/Sources/OAuthKit/Extensions/URLResponse+Extensions.swift @@ -6,6 +6,9 @@ // import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif public extension URLResponse { diff --git a/Sources/OAuthKit/Extensions/UUID+Extensions.swift b/Sources/OAuthKit/Extensions/UUID+Extensions.swift index 4545753..fa58c69 100644 --- a/Sources/OAuthKit/Extensions/UUID+Extensions.swift +++ b/Sources/OAuthKit/Extensions/UUID+Extensions.swift @@ -5,7 +5,11 @@ // Created by Kevin McKee // +#if canImport(CryptoKit) import CryptoKit +#else +import Crypto +#endif import Foundation extension UUID { diff --git a/Sources/OAuthKit/Keychain/Keychain.swift b/Sources/OAuthKit/Keychain/Keychain.swift index 99717d2..7df6c36 100644 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ b/Sources/OAuthKit/Keychain/Keychain.swift @@ -6,7 +6,9 @@ // import Foundation +#if canImport(Security) import Security +#endif /// The default application tag to use. private let defaultApplicationTag = "oauthkit" @@ -33,6 +35,7 @@ class Keychain: @unchecked Sendable { /// Queries the keychain for keys. var keys: [String] { + #if canImport(Security) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecReturnAttributes as String: true, @@ -56,6 +59,10 @@ class Keychain: @unchecked Sendable { } return results.filter{ $0.starts(with: applicationTag)}.sorted{ $0 < $1} + #else + // Android / Linux storage + return [] + #endif } /// Sets the value for the specified key. @@ -69,6 +76,7 @@ class Keychain: @unchecked Sendable { lock.lock() defer { lock.unlock() } + #if canImport(Security) let account = accountKey(key) deleteNoLock(account) @@ -81,6 +89,10 @@ class Keychain: @unchecked Sendable { let status = SecItemAdd(query as CFDictionary, nil) return status == errSecSuccess + #else + // Android / Linux storage + return false + #endif } /// Fetches a storeed value from the keychain with the specified key and attempts to decode it from the implied generic. @@ -92,7 +104,8 @@ class Keychain: @unchecked Sendable { defer { lock.unlock() } let account = accountKey(key) - + + #if canImport(Security) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecMatchLimit as String: kSecMatchLimitOne, @@ -111,6 +124,11 @@ class Keychain: @unchecked Sendable { let value = try? decoder.decode(T.self, from: data) return value + + #else + // Android / Linux storage + return nil + #endif } /// Clears the keychain @@ -147,12 +165,18 @@ class Keychain: @unchecked Sendable { /// - Returns: true if able to delete from the keychain, otherwise false @discardableResult private func deleteNoLock(_ key: String) -> Bool { + + #if canImport(Security) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key ] let status = SecItemDelete(query as CFDictionary) return status == noErr + #else + // Android / Linux storage + return false + #endif } /// Builds the account key by prefixing the specified key with the application tag. 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 From f17324958d2f0679ed1a66e560456a61797ec47e Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Mon, 17 Aug 2026 12:13:35 -0700 Subject: [PATCH 02/13] WIP --- Package.swift | 45 +++++++++++-------- .../OAuthKit/Extensions/Data+Extensions.swift | 6 +-- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/Package.swift b/Package.swift index 558433b..f7fa513 100644 --- a/Package.swift +++ b/Package.swift @@ -3,12 +3,33 @@ import PackageDescription +var packageDependencies: [Package.Dependency] = [] +var targetDependencies: [Target.Dependency] = [] +var linkerSettings: [LinkerSetting] = [] #if os(Linux) || os(Android) -let dependencies: [Pacakge.Dependency] = [ +// Android and Linux +packageDependencies = [ .package(url: "https://github.com/apple/swift-crypto.git", from: .init(4, 5, 0)) ] +targetDependencies = [ + .product(name: "Crypto", package: "swift-crypto") +] #else -let dependencies: [Package.Dependency] = [] +// Apple +linkerSettings = [ + .linkedFramework("CryptoKit", + .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) + ), + .linkedFramework("LocalAuthentication", + .when(platforms: [.iOS]) + ), + .linkedFramework("Network", + .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) + ), + .linkedFramework("Security", + .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) + ) +] #endif let package = Package( @@ -18,31 +39,19 @@ let package = Package( .macOS(.v15), .tvOS(.v18), .visionOS(.v1), - .watchOS(.v10) + .watchOS(.v10), ], products: [ .library( name: "OAuthKit", targets: ["OAuthKit"]) ], - dependencies: dependencies, + dependencies: packageDependencies, targets: [ .target( name: "OAuthKit", - linkerSettings: [ - .linkedFramework("CryptoKit", - .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) - ), - .linkedFramework("LocalAuthentication", - .when(platforms: [.iOS]) - ), - .linkedFramework("Network", - .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) - ), - .linkedFramework("Security", - .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) - ) - ] + dependencies: targetDependencies, + linkerSettings: linkerSettings ), .testTarget( name: "OAuthKitTests", diff --git a/Sources/OAuthKit/Extensions/Data+Extensions.swift b/Sources/OAuthKit/Extensions/Data+Extensions.swift index 08289e6..99cd10a 100644 --- a/Sources/OAuthKit/Extensions/Data+Extensions.swift +++ b/Sources/OAuthKit/Extensions/Data+Extensions.swift @@ -46,13 +46,13 @@ extension Data { /// - Parameter count: The number of bytes to generate. /// - Returns: an array of cryptographically secure random bytes static func secureRandom(count: Int = 32) -> Data { - #if canImport(CryptoKit) var bytes = [UInt8](repeating: 0, count: count) + #if canImport(CryptoKit) + // Apple _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) - return Data(bytes: &bytes, count: bytes.count) #else // Android / Linux - return .init() #endif + return Data(bytes: &bytes, count: bytes.count) } } From 4e04d6dce1cc84075a84d788b2a2e30dd9263b1b Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Mon, 17 Aug 2026 12:46:27 -0700 Subject: [PATCH 03/13] WIP --- Package.swift | 27 +++++++++++++----------- Sources/OAuthKit/Keychain/Keychain.swift | 8 +++---- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/Package.swift b/Package.swift index f7fa513..582e3aa 100644 --- a/Package.swift +++ b/Package.swift @@ -3,9 +3,18 @@ import PackageDescription +let supportedPlatforms: [SupportedPlatform] = [ + .iOS(.v17), + .macOS(.v15), + .tvOS(.v18), + .visionOS(.v1), + .watchOS(.v10) +] var packageDependencies: [Package.Dependency] = [] var targetDependencies: [Target.Dependency] = [] var linkerSettings: [LinkerSetting] = [] +let platforms: [Platform] = [.iOS, .macOS, .tvOS, .watchOS, .visionOS] + #if os(Linux) || os(Android) // Android and Linux packageDependencies = [ @@ -18,29 +27,23 @@ targetDependencies = [ // Apple linkerSettings = [ .linkedFramework("CryptoKit", - .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) + .when(platforms: platforms) ), .linkedFramework("LocalAuthentication", - .when(platforms: [.iOS]) + .when(platforms: platforms) ), .linkedFramework("Network", - .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) + .when(platforms: platforms) ), .linkedFramework("Security", - .when(platforms: [.iOS, .macOS, .tvOS, .visionOS, .watchOS]) - ) + .when(platforms: platforms) + ), ] #endif let package = Package( name: "OAuthKit", - platforms: [ - .iOS(.v17), - .macOS(.v15), - .tvOS(.v18), - .visionOS(.v1), - .watchOS(.v10), - ], + platforms: supportedPlatforms, products: [ .library( name: "OAuthKit", diff --git a/Sources/OAuthKit/Keychain/Keychain.swift b/Sources/OAuthKit/Keychain/Keychain.swift index 7df6c36..5b79582 100644 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ b/Sources/OAuthKit/Keychain/Keychain.swift @@ -60,7 +60,7 @@ class Keychain: @unchecked Sendable { return results.filter{ $0.starts(with: applicationTag)}.sorted{ $0 < $1} #else - // Android / Linux storage + // TODO: Android / Linux storage return [] #endif } @@ -90,7 +90,7 @@ class Keychain: @unchecked Sendable { let status = SecItemAdd(query as CFDictionary, nil) return status == errSecSuccess #else - // Android / Linux storage + // TODO: Android / Linux storage return false #endif } @@ -126,7 +126,7 @@ class Keychain: @unchecked Sendable { return value #else - // Android / Linux storage + // TODO: Android / Linux storage return nil #endif } @@ -174,7 +174,7 @@ class Keychain: @unchecked Sendable { let status = SecItemDelete(query as CFDictionary) return status == noErr #else - // Android / Linux storage + // TODO: Android / Linux storage return false #endif } From ca6b2bf36528c2575f1010c43afd378c0c3be134 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Mon, 17 Aug 2026 12:57:42 -0700 Subject: [PATCH 04/13] Lint fixes --- Sources/OAuthKit/Keychain/Keychain.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/OAuthKit/Keychain/Keychain.swift b/Sources/OAuthKit/Keychain/Keychain.swift index 5b79582..0d4b6af 100644 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ b/Sources/OAuthKit/Keychain/Keychain.swift @@ -104,7 +104,7 @@ class Keychain: @unchecked Sendable { defer { lock.unlock() } let account = accountKey(key) - + #if canImport(Security) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -165,7 +165,7 @@ class Keychain: @unchecked Sendable { /// - Returns: true if able to delete from the keychain, otherwise false @discardableResult private func deleteNoLock(_ key: String) -> Bool { - + #if canImport(Security) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, From 52c738af173b5bb1df1ffd391a8d7866cbed913d Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Mon, 17 Aug 2026 16:12:23 -0700 Subject: [PATCH 05/13] Using SystemRandomNumberGenerator for Linux/Android. --- Sources/OAuthKit/Extensions/Data+Extensions.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/OAuthKit/Extensions/Data+Extensions.swift b/Sources/OAuthKit/Extensions/Data+Extensions.swift index 99cd10a..beed811 100644 --- a/Sources/OAuthKit/Extensions/Data+Extensions.swift +++ b/Sources/OAuthKit/Extensions/Data+Extensions.swift @@ -52,6 +52,10 @@ extension Data { _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) #else // Android / Linux + var generator = SystemRandomNumberGenerator() + for i in 0.. Date: Tue, 18 Aug 2026 09:09:29 -0700 Subject: [PATCH 06/13] WIP --- Package.swift | 65 ++++++++++++++++++--------------------------------- 1 file changed, 23 insertions(+), 42 deletions(-) diff --git a/Package.swift b/Package.swift index 582e3aa..9135031 100644 --- a/Package.swift +++ b/Package.swift @@ -3,58 +3,39 @@ import PackageDescription -let supportedPlatforms: [SupportedPlatform] = [ - .iOS(.v17), - .macOS(.v15), - .tvOS(.v18), - .visionOS(.v1), - .watchOS(.v10) -] -var packageDependencies: [Package.Dependency] = [] -var targetDependencies: [Target.Dependency] = [] -var linkerSettings: [LinkerSetting] = [] -let platforms: [Platform] = [.iOS, .macOS, .tvOS, .watchOS, .visionOS] - -#if os(Linux) || os(Android) -// Android and Linux -packageDependencies = [ - .package(url: "https://github.com/apple/swift-crypto.git", from: .init(4, 5, 0)) -] -targetDependencies = [ - .product(name: "Crypto", package: "swift-crypto") -] -#else -// Apple -linkerSettings = [ - .linkedFramework("CryptoKit", - .when(platforms: platforms) - ), - .linkedFramework("LocalAuthentication", - .when(platforms: platforms) - ), - .linkedFramework("Network", - .when(platforms: platforms) - ), - .linkedFramework("Security", - .when(platforms: platforms) - ), -] -#endif - let package = Package( name: "OAuthKit", - platforms: supportedPlatforms, + platforms: [ + .iOS(.v17), + .macOS(.v15), + .tvOS(.v18), + .visionOS(.v1), + .watchOS(.v10) + ], products: [ .library( name: "OAuthKit", targets: ["OAuthKit"]) ], - dependencies: packageDependencies, + dependencies: [ + // Android / Linux Dependencies + .package(url: "https://github.com/apple/swift-crypto.git", from: .init(4, 5, 0)) + ], targets: [ .target( name: "OAuthKit", - dependencies: targetDependencies, - linkerSettings: linkerSettings + dependencies: [ + .product(name: "Crypto", + package: "swift-crypto", + condition: + .when(platforms: [.android, .linux]) + ) + ], + linkerSettings: [ + .linkedFramework("LocalAuthentication", + .when(platforms: [.iOS]) + ), + ] ), .testTarget( name: "OAuthKitTests", From e83211b12e3adb7ba6dedcea65a5939d3157cb61 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Tue, 18 Aug 2026 11:29:32 -0700 Subject: [PATCH 07/13] WIP --- Package.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 9135031..0f6bcfd 100644 --- a/Package.swift +++ b/Package.swift @@ -27,8 +27,7 @@ let package = Package( dependencies: [ .product(name: "Crypto", package: "swift-crypto", - condition: - .when(platforms: [.android, .linux]) + condition: .when(platforms: [.android, .linux]) ) ], linkerSettings: [ From e8df591af5d3b704ef7973333d9a585d283ad39d Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Wed, 19 Aug 2026 08:54:31 -0700 Subject: [PATCH 08/13] WIP --- Package.swift | 2 +- Sources/OAuthKit/Keychain/Keychain.swift | 8 +++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Package.swift b/Package.swift index 0f6bcfd..cd01699 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ let package = Package( ], dependencies: [ // Android / Linux Dependencies - .package(url: "https://github.com/apple/swift-crypto.git", from: .init(4, 5, 0)) + .package(url: "https://github.com/apple/swift-crypto", from: .init(4, 5, 0)) ], targets: [ .target( diff --git a/Sources/OAuthKit/Keychain/Keychain.swift b/Sources/OAuthKit/Keychain/Keychain.swift index 0d4b6af..e4d6e34 100644 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ b/Sources/OAuthKit/Keychain/Keychain.swift @@ -35,6 +35,7 @@ class Keychain: @unchecked Sendable { /// Queries the keychain for keys. var keys: [String] { + var results = [String]() #if canImport(Security) let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -47,9 +48,8 @@ class Keychain: @unchecked Sendable { SecItemCopyMatching(query as CFDictionary, UnsafeMutablePointer(pointer)) } - guard status == noErr else { return [] } + guard status == noErr else { return results } - var results = [String]() if let items = result as? [[String: Any]] { for item in items { if let key = item[kSecAttrAccount as String] as? String { @@ -58,11 +58,10 @@ class Keychain: @unchecked Sendable { } } - return results.filter{ $0.starts(with: applicationTag)}.sorted{ $0 < $1} #else // TODO: Android / Linux storage - return [] #endif + return results.filter{ $0.starts(with: applicationTag)}.sorted{ $0 < $1} } /// Sets the value for the specified key. @@ -124,7 +123,6 @@ class Keychain: @unchecked Sendable { let value = try? decoder.decode(T.self, from: data) return value - #else // TODO: Android / Linux storage return nil From a9aff4f3c5cbfb9dae04cd95482efef04718c85f Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Wed, 19 Aug 2026 20:46:24 -0700 Subject: [PATCH 09/13] WIP --- .../OAuthKit/Keychain/Keychain+Storage.swift | 183 ++++++++++++++++++ Sources/OAuthKit/Keychain/Keychain.swift | 133 ++----------- 2 files changed, 200 insertions(+), 116 deletions(-) create mode 100644 Sources/OAuthKit/Keychain/Keychain+Storage.swift diff --git a/Sources/OAuthKit/Keychain/Keychain+Storage.swift b/Sources/OAuthKit/Keychain/Keychain+Storage.swift new file mode 100644 index 0000000..608eafc --- /dev/null +++ b/Sources/OAuthKit/Keychain/Keychain+Storage.swift @@ -0,0 +1,183 @@ +// +// Keychain+Extensions.swift +// OAuthKit +// +// Created by Kevin McKee on 8/19/26. +// + +import Foundation +#if canImport(Security) +import Security +#endif + +/// The default account to use. +private let defaultAccount = "oauthkit" +/// The default token identifier suffix. +private let tokenIdentifier = "oauth-token" + +extension Keychain { + + // Provides the storage protocols used for storing sensitive data. + // The storage operations should all be wrapped in a locking + // mechanism to prevent any potential data races. + protocol Storage { + + /// The owning account identifier. + /// - Parameter account: a key indicating the account owner. Ideally, use the application identifier for this value. + init(account: String) + + /// The account owner of this keychain storage. + var account: String { get } + + /// Returns a list of keys owned by this account. + var keys: [String] { get } + + /// Sets the data for the specified key + /// - Parameters: + /// - data: the data to store for the specified key + /// - key: the key to use for the data + /// - Returns: true if able to set the data, otherwise false + func set(_ data: Data, for key: String) throws -> 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 e4d6e34..68421f4 100644 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ b/Sources/OAuthKit/Keychain/Keychain.swift @@ -10,58 +10,27 @@ import Foundation 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.") + self.storage = DefaultStorage(account: account) } /// Queries the keychain for keys. var keys: [String] { - - var results = [String]() - #if canImport(Security) - 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) - } - } - } - - #else - // TODO: Android / Linux storage - #endif - return results.filter{ $0.starts(with: applicationTag)}.sorted{ $0 < $1} + storage?.keys ?? [] } /// Sets the value for the specified key. @@ -75,26 +44,12 @@ class Keychain: @unchecked Sendable { lock.lock() defer { lock.unlock() } - #if canImport(Security) - 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 - #else - // TODO: Android / Linux storage - return false - #endif + 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 { @@ -102,48 +57,19 @@ class Keychain: @unchecked Sendable { lock.lock() defer { lock.unlock() } - let account = accountKey(key) - - #if canImport(Security) - 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 - #else - // TODO: Android / Linux storage - return nil - #endif } /// Clears the keychain /// - 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. @@ -154,33 +80,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 { - - #if canImport(Security) - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrAccount as String: key - ] - let status = SecItemDelete(query as CFDictionary) - return status == noErr - #else - // TODO: Android / Linux storage - return false - #endif - } - - /// 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) } } From a469da38fe88162bf181654ceca7400063afc93d Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Wed, 19 Aug 2026 20:51:42 -0700 Subject: [PATCH 10/13] WIP --- Sources/OAuthKit/Keychain/Keychain.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Sources/OAuthKit/Keychain/Keychain.swift b/Sources/OAuthKit/Keychain/Keychain.swift index 68421f4..3ada386 100644 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ b/Sources/OAuthKit/Keychain/Keychain.swift @@ -25,7 +25,9 @@ class Keychain: @unchecked Sendable { /// - 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) + #endif } /// Queries the keychain for keys. From 22556ad41d17c9b3305271747b92a1d1ad5e458a Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Wed, 19 Aug 2026 20:54:54 -0700 Subject: [PATCH 11/13] WIP --- Sources/OAuthKit/Keychain/Keychain.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Sources/OAuthKit/Keychain/Keychain.swift b/Sources/OAuthKit/Keychain/Keychain.swift index 3ada386..bcb5b9a 100644 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ b/Sources/OAuthKit/Keychain/Keychain.swift @@ -27,6 +27,10 @@ class Keychain: @unchecked Sendable { 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 } From 10f09fb45dc1ed6c67342c9131cd3f5b73c21e98 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 20 Aug 2026 09:44:28 -0700 Subject: [PATCH 12/13] README updates --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 337f42b..dcf8fa1 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. 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). + From f5317b4ca2e1f359b17a4de27aada37b9e8c6e37 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 20 Aug 2026 09:45:57 -0700 Subject: [PATCH 13/13] README updates --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dcf8fa1..0877c61 100644 --- a/README.md +++ b/README.md @@ -318,5 +318,5 @@ You can find the complete Swift DocC documentation for the [OAuthKit Framework h ## 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. 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). +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).