From a81627de35fc1ac4d8c61777d927f11dac58e3cc Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 24 Sep 2026 08:34:17 -0700 Subject: [PATCH 1/7] WIP --- .../OAuthKit/Keychain/Keychain+Storage.swift | 183 ------------------ Sources/OAuthKit/Keychain/Keychain.swift | 93 --------- Sources/OAuthKit/OAuth+Storage.swift | 69 +++++++ Sources/OAuthKit/OAuth.swift | 28 +-- Sources/OAuthKit/Storage/Keychain.swift | 150 ++++++++++++++ Tests/OAuthKitTests/OAWebViewTests.swift | 6 +- Tests/OAuthKitTests/OAuthTests.swift | 16 +- 7 files changed, 246 insertions(+), 299 deletions(-) delete mode 100644 Sources/OAuthKit/Keychain/Keychain+Storage.swift delete mode 100644 Sources/OAuthKit/Keychain/Keychain.swift create mode 100644 Sources/OAuthKit/OAuth+Storage.swift create mode 100644 Sources/OAuthKit/Storage/Keychain.swift diff --git a/Sources/OAuthKit/Keychain/Keychain+Storage.swift b/Sources/OAuthKit/Keychain/Keychain+Storage.swift deleted file mode 100644 index 608eafc..0000000 --- a/Sources/OAuthKit/Keychain/Keychain+Storage.swift +++ /dev/null @@ -1,183 +0,0 @@ -// -// 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 deleted file mode 100644 index bcb5b9a..0000000 --- a/Sources/OAuthKit/Keychain/Keychain.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// Keychain.swift -// -// -// Created by Kevin McKee -// - -import Foundation -#if canImport(Security) -import Security -#endif - -/// 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 storage: Storage? = nil - - private init() { } - - /// 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] { - storage?.keys ?? [] - } - - /// 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(_ value: Codable, for key: String) throws -> Bool { - assert(key.isNotEmpty, "❌ The keychain key cannot be empty.") - lock.lock() - defer { lock.unlock() } - - guard let storage else { return false } - let data = try encoder.encode(value) - return try storage.set(data, for: key) - } - - /// 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 { - - lock.lock() - defer { lock.unlock() } - - 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 - } - - /// Clears the keychain - /// - Returns: true if values were cleared, otherwise false. - @discardableResult - func clear() -> Bool { - lock.lock() - defer { lock.unlock() } - return storage?.clear() ?? false - } - - /// Deletes the value for the specified key. - /// - Parameter key: the key to delete - /// - Returns: true if able to delete from the keychain, otherwise false - @discardableResult - func delete(key: String) -> Bool { - lock.lock() - defer { lock.unlock() } - - guard let storage else { return false } - let account = storage.accountKey(key) - return storage.delete(key: account) - } -} diff --git a/Sources/OAuthKit/OAuth+Storage.swift b/Sources/OAuthKit/OAuth+Storage.swift new file mode 100644 index 0000000..5e2930f --- /dev/null +++ b/Sources/OAuthKit/OAuth+Storage.swift @@ -0,0 +1,69 @@ +// +// OAuth+Storage.swift +// OAuthKit +// +// Created by Kevin McKee +// + +import Foundation + +/// The default token identifier suffix. +private let tokenIdentifier = "oauth-token" + +extension OAuth { + + // Provides the storage protocols used for storing token data. + // The storage operations should all be wrapped in a locking + // mechanism to prevent any potential data races. + public protocol Storage: Sendable { + + /// 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: + /// - value: the value to store for the specified key + /// - key: the key to use for the data + /// - Returns: true if able to set the data, otherwise false + @discardableResult + func set(_ value: Codable, for key: String) throws -> Bool + + /// Fetches storeed data from the storage 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 -> T? where T: Codable + + /// 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 + + /// Clears all values and keys for the current account. + /// - Returns: true if values were cleared, otherwise false. + @discardableResult + 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 + } +} + +public extension OAuth.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/OAuth.swift b/Sources/OAuthKit/OAuth.swift index c91de1a..606adce 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -13,6 +13,8 @@ import LocalAuthentication #endif import Observation +/// The default account to use. +private let defaultAccount = "oauthkit" /// The default file name that holds the list of providers. private let defaultResourceName = "oauth" /// The default file extension. @@ -49,7 +51,7 @@ public final class OAuth: Sendable { public var urlSession: URLSession = .init(configuration: .ephemeral) @ObservationIgnored - var keychain: Keychain = .default + var storage: Storage #if os(macOS) || os(iOS) || os(visionOS) @ObservationIgnored @@ -97,6 +99,7 @@ public final class OAuth: Sendable { /// - providers: the list of oauth providers /// - options: the configuration options to apply public init(providers: [Provider] = [Provider](), options: [Option: Any]? = nil) { + self.storage = Keychain(defaultAccount) self.providers = providers configure(options) } @@ -106,6 +109,7 @@ public final class OAuth: Sendable { /// - bundle: the bundle to load the oauth provider configuration information from. /// - options: the configuration options to apply public init(_ bundle: Bundle, options: [Option: Any]? = nil) { + self.storage = Keychain(defaultAccount) self.providers = loadProviders(bundle) configure(options) } @@ -161,7 +165,7 @@ public extension OAuth { /// Removes all tokens and clears the OAuth state func clear() { debugPrint("⚠️ [Clearing oauth state]") - keychain.clear() + storage.clear() state = .empty } } @@ -185,7 +189,7 @@ private extension OAuth { /// Loads authorizations from the keychain. func loadAuthorizations() { for provider in providers { - if let authorization: OAuth.Authorization = try? keychain.get(key: provider.id) { + if let authorization: OAuth.Authorization = try? storage.get(key: provider.id) { publish(state: .authorized(provider, authorization)) } } @@ -226,7 +230,7 @@ private extension OAuth { // Override the keychain to use the custom application tag if let applicationTag = options[.applicationTag] as? String, applicationTag.isNotEmpty { - self.keychain = .init(applicationTag) + self.storage = Keychain(applicationTag) } } monitor() @@ -333,13 +337,13 @@ private extension OAuth { if #available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *) { #if compiler(>=6.2) // Swfit 6.2 compilers and above - Task.immediate(operation: operation) + _ = Task.immediate(operation: operation) #else // Swift 6.1 compiler - Task(priority: priority, operation: operation) + _ = Task(priority: priority, operation: operation) #endif } else { - Task(priority: priority, operation: operation) + _ = Task(priority: priority, operation: operation) } } } @@ -379,7 +383,7 @@ extension OAuth { // Store the authorization let authorization = Authorization(issuer: provider.id, token: token) - guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { + guard let stored = try? storage.set(authorization, for: authorization.issuer), stored else { return publish(state: .error(provider, .keychain)) } @@ -391,7 +395,7 @@ extension OAuth { /// - Parameters: /// - provider: the provider to request a refresh token for func refreshToken(provider: Provider) async { - guard let auth: OAuth.Authorization = try? keychain.get(key: provider.id) else { return } + guard let auth: OAuth.Authorization = try? storage.get(key: provider.id) else { return } // If we can't build a refresh request simply bail as no refresh token // was returned in the original auth request @@ -417,7 +421,7 @@ extension OAuth { // Store the authorization let authorization = Authorization(issuer: provider.id, token: token) - guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { + guard let stored = try? storage.set(authorization, for: authorization.issuer), stored else { return publish(state: .error(provider, .keychain)) } publish(state: .authorized(provider, authorization)) @@ -469,7 +473,7 @@ extension OAuth { // Store the authorization let authorization = Authorization(issuer: provider.id, token: token) - guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { + guard let stored = try? storage.set(authorization, for: authorization.issuer), stored else { return publish(state: .error(provider, .keychain)) } publish(state: .authorized(provider, authorization)) @@ -504,7 +508,7 @@ extension OAuth { // Store the authorization let authorization = Authorization(issuer: provider.id, token: token) - guard let stored = try? keychain.set(authorization, for: authorization.issuer), stored else { + guard let stored = try? storage.set(authorization, for: authorization.issuer), stored else { return publish(state: .error(provider, .keychain)) } publish(state: .authorized(provider, authorization)) diff --git a/Sources/OAuthKit/Storage/Keychain.swift b/Sources/OAuthKit/Storage/Keychain.swift new file mode 100644 index 0000000..aedb49a --- /dev/null +++ b/Sources/OAuthKit/Storage/Keychain.swift @@ -0,0 +1,150 @@ +// +// Keychain.swift +// +// +// Created by Kevin McKee +// + +import Foundation +#if canImport(Security) +import Security +#endif + +/// A helper class used to interact with Keychain storage access. Wraps all storage write operations with threadsafe locks. +class Keychain: OAuth.Storage, @unchecked Sendable { + + private let lock: NSLock = .init() + private let encoder: JSONEncoder = .init() + private let decoder: JSONDecoder = .init() + public var account: String + + /// Initializes the keychain with an overridden accound identifier. + /// - Parameter account: a key indicating the account owner. Ideally, use the application identifier for this value. + required init(_ account: String) { + assert(account.isNotEmpty, "❌ The account identifier cannot be empty.") + self.account = account + } + + /// Queries the keychain for keys. + 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(_ value: Codable, for key: String) throws -> Bool { + assert(key.isNotEmpty, "❌ The keychain key cannot be empty.") + lock.lock() + defer { lock.unlock() } + + let data = try encoder.encode(value) + + let account = accountKey(key) + deleteNoLock(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 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 { + + 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 + } + + let value = try? decoder.decode(T.self, from: data) + return value + } + + /// 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 } + } + + /// Deletes the value for the specified key. + /// - Parameter key: the key to delete + /// - Returns: true if able to delete from the keychain, otherwise false + @discardableResult + func delete(key: String) -> Bool { + 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 + } +} diff --git a/Tests/OAuthKitTests/OAWebViewTests.swift b/Tests/OAuthKitTests/OAWebViewTests.swift index e8ff289..dcb44bc 100644 --- a/Tests/OAuthKitTests/OAWebViewTests.swift +++ b/Tests/OAuthKitTests/OAWebViewTests.swift @@ -31,8 +31,8 @@ final class OAWebViewTests { oauth.providers.filter{ $0.id == "GitHub" }.first! } - var keychain: Keychain { - oauth.keychain + var storage: OAuth.Storage { + oauth.storage } /// Initializer. @@ -146,7 +146,7 @@ final class OAWebViewTests { case .empty, .error, .authorizing, .requestingAccessToken, .requestingDeviceCode, .receivedDeviceCode: break case .authorized(_, _): - keychain.clear() + storage.clear() return true } } diff --git a/Tests/OAuthKitTests/OAuthTests.swift b/Tests/OAuthKitTests/OAuthTests.swift index 0ffe63f..78856c3 100644 --- a/Tests/OAuthKitTests/OAuthTests.swift +++ b/Tests/OAuthKitTests/OAuthTests.swift @@ -17,8 +17,8 @@ final class OAuthTests { let oauth: OAuth let tag: String - var keychain: Keychain { - oauth.keychain + var storage: OAuth.Storage { + oauth.storage } var provider: OAuth.Provider { oauth.providers.filter{ $0.id == "GitHub" }.first! @@ -89,7 +89,7 @@ final class OAuthTests { let key = provider.id let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: "email", type: "Bearer") let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) - let inserted = try! keychain.set(auth, for: key) + let inserted = try! storage.set(auth, for: key) #expect(inserted == true) let options: [OAuth.Option: Any] = [ @@ -100,7 +100,7 @@ final class OAuthTests { ] let restoredOAuth: OAuth = .init(.module, options: options) #expect(restoredOAuth.state == .authorized(provider, auth)) - keychain.clear() + storage.clear() } /// Tests the custom date extension operator. @@ -217,7 +217,7 @@ final class OAuthTests { #expect(request!.url!.absoluteString.contains("grant_type=refresh_token")) #expect(request!.url!.absoluteString.contains("refresh_token=\(token.refreshToken!)")) let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) - try! keychain.set(auth, for: provider.id) + try! storage.set(auth, for: provider.id) oauth.authorize(provider: provider, grantType: .refreshToken) let result = await waitForAuthorization(oauth) #expect(result == true) @@ -228,7 +228,7 @@ final class OAuthTests { let key = provider.id let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 0, scope: "email", type: "Bearer") let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) - let inserted = try! keychain.set(auth, for: key) + let inserted = try! storage.set(auth, for: key) #expect(inserted == true) let options: [OAuth.Option: Any] = [ @@ -266,7 +266,7 @@ final class OAuthTests { // Refresh Token let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 0, scope: "email", type: "Bearer") let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) - try! oauth.keychain.set(auth, for: provider.id) + try! oauth.storage.set(auth, for: provider.id) await oauth.refreshToken(provider: provider) #expect(oauth.state == .error(provider, .decoding)) oauth.clear() @@ -307,7 +307,7 @@ final class OAuthTests { // Refresh Token let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 0, scope: "email", type: "Bearer") let auth: OAuth.Authorization = .init(issuer: provider.id, token: token) - try! oauth.keychain.set(auth, for: provider.id) + try! oauth.storage.set(auth, for: provider.id) await oauth.refreshToken(provider: provider) #expect(oauth.state == .error(provider, .badResponse)) oauth.clear() From da4a37677aaa9b78584f34de9191d1b3b29a2d4d Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 24 Sep 2026 08:34:26 -0700 Subject: [PATCH 2/7] WIP --- Sources/OAuthKit/Storage/Keychain.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/OAuthKit/Storage/Keychain.swift b/Sources/OAuthKit/Storage/Keychain.swift index aedb49a..d7205ce 100644 --- a/Sources/OAuthKit/Storage/Keychain.swift +++ b/Sources/OAuthKit/Storage/Keychain.swift @@ -17,7 +17,7 @@ class Keychain: OAuth.Storage, @unchecked Sendable { private let encoder: JSONEncoder = .init() private let decoder: JSONDecoder = .init() public var account: String - + /// Initializes the keychain with an overridden accound identifier. /// - Parameter account: a key indicating the account owner. Ideally, use the application identifier for this value. required init(_ account: String) { @@ -63,7 +63,7 @@ class Keychain: OAuth.Storage, @unchecked Sendable { defer { lock.unlock() } let data = try encoder.encode(value) - + let account = accountKey(key) deleteNoLock(account) @@ -84,7 +84,7 @@ class Keychain: OAuth.Storage, @unchecked Sendable { lock.lock() defer { lock.unlock() } - + let account = accountKey(key) let query: [String: Any] = [ From a183c6829f21ef36eb50401b4b68ff3f888c0936 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Thu, 24 Sep 2026 09:07:18 -0700 Subject: [PATCH 3/7] WIP --- Sources/OAuthKit/OAuth+Storage.swift | 2 +- Sources/OAuthKit/OAuth.swift | 22 +++++- .../OAuthKit/Storage/InMemoryStorage.swift | 77 ++++++++++++++++++ Tests/OAuthKitTests/KeychainTests.swift | 49 ------------ Tests/OAuthKitTests/StorageTests.swift | 79 +++++++++++++++++++ Tests/OAuthKitTests/Tags+Extensions.swift | 2 +- 6 files changed, 178 insertions(+), 53 deletions(-) create mode 100644 Sources/OAuthKit/Storage/InMemoryStorage.swift delete mode 100644 Tests/OAuthKitTests/KeychainTests.swift create mode 100644 Tests/OAuthKitTests/StorageTests.swift diff --git a/Sources/OAuthKit/OAuth+Storage.swift b/Sources/OAuthKit/OAuth+Storage.swift index 5e2930f..b67fee9 100644 --- a/Sources/OAuthKit/OAuth+Storage.swift +++ b/Sources/OAuthKit/OAuth+Storage.swift @@ -21,7 +21,7 @@ extension OAuth { /// - 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. + /// The account owner of this token storage. var account: String { get } /// Returns a list of keys owned by this account. diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index 606adce..5b730c5 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -50,6 +50,7 @@ public final class OAuth: Sendable { @ObservationIgnored public var urlSession: URLSession = .init(configuration: .ephemeral) + /// The token storage facility to use. @ObservationIgnored var storage: Storage @@ -99,7 +100,7 @@ public final class OAuth: Sendable { /// - providers: the list of oauth providers /// - options: the configuration options to apply public init(providers: [Provider] = [Provider](), options: [Option: Any]? = nil) { - self.storage = Keychain(defaultAccount) + self.storage = makeStorage(options: options) self.providers = providers configure(options) } @@ -109,7 +110,7 @@ public final class OAuth: Sendable { /// - bundle: the bundle to load the oauth provider configuration information from. /// - options: the configuration options to apply public init(_ bundle: Bundle, options: [Option: Any]? = nil) { - self.storage = Keychain(defaultAccount) + self.storage = makeStorage(options: options) self.providers = loadProviders(bundle) configure(options) } @@ -514,3 +515,20 @@ extension OAuth { publish(state: .authorized(provider, authorization)) } } + +/// Builds the token storage facility based on OS and options. +fileprivate func makeStorage(options: [OAuth.Option: Any]? = nil) -> OAuth.Storage { + + var storageAccount: String = defaultAccount + + // Override from account from passed in options + if let options, let applicationTag = options[.applicationTag] as? String, applicationTag.isNotEmpty { + storageAccount = applicationTag + } + + #if canImport(Security) + return Keychain(storageAccount) + #elseif os(Android) + #elseif os(Linux) + #endif +} diff --git a/Sources/OAuthKit/Storage/InMemoryStorage.swift b/Sources/OAuthKit/Storage/InMemoryStorage.swift new file mode 100644 index 0000000..c912e9a --- /dev/null +++ b/Sources/OAuthKit/Storage/InMemoryStorage.swift @@ -0,0 +1,77 @@ +// +// InMemoryStorage.swift +// OAuthKit +// +// Created by Kevin McKee on 9/24/26. +// + +import Foundation + +/// A storage implementation that holds all of the tokens in-memory. +class InMemoryStorage: OAuth.Storage, @unchecked Sendable { + + private var values: [String: Codable] = .init() + private let lock: NSLock = .init() + public var account: String + + /// Initializes the storage with an overridden accound identifier. + /// - Parameter account: a key indicating the account owner. Ideally, use the application identifier for this value. + required init(_ account: String) { + assert(account.isNotEmpty, "❌ The account identifier cannot be empty.") + self.account = account + } + + var keys: [String] { + values.keys.sorted() + } + + /// 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(_ value: Codable, for key: String) throws -> Bool { + assert(key.isNotEmpty, "❌ The keychain key cannot be empty.") + lock.lock() + defer { lock.unlock() } + + let key = accountKey(key) + values[key] = value + return true + } + + /// Fetches a stored value from storage with the specified key and attempts to decode it from the implied generic. + /// - Parameter key: the storage key + /// - Returns: the generic codeable for the specified key or nil if not found + func get(key: String) throws -> T? where T: Codable { + + lock.lock() + defer { lock.unlock() } + + let key = accountKey(key) + return values[key] as? T + } + + /// Clears all values from storage + /// - Returns: true if values were cleared, otherwise false. + @discardableResult + func clear() -> Bool { + lock.lock() + defer { lock.unlock() } + values.removeAll() + return true + } + + /// Deletes the value for the specified key. + /// - Parameter key: the key to delete + /// - Returns: true if able to delete from the keychain, otherwise false + @discardableResult + func delete(key: String) -> Bool { + lock.lock() + defer { lock.unlock() } + let key = accountKey(key) + values.removeValue(forKey: key) + return true + } +} diff --git a/Tests/OAuthKitTests/KeychainTests.swift b/Tests/OAuthKitTests/KeychainTests.swift deleted file mode 100644 index 4827a70..0000000 --- a/Tests/OAuthKitTests/KeychainTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// KeychainTests.swift -// -// -// Created by Kevin McKee -// - -@testable import OAuthKit -import Testing - -@Suite("Keychain Tests", .tags(.keychain)) -final class KeychainTests { - - let keychain: Keychain - - deinit { - keychain.clear() - } - - /// Initializer. - init() async throws { - let tag: String = "oauthkit.test." + .secureRandom() - keychain = .init(tag) - } - - @Test("Storing keychain values") - func whenStoring() async throws { - let key = "Github" - let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: "email", type: "Bearer") - - let inserted = try! keychain.set(token, for: key) - #expect(inserted == true) - - let found: OAuth.Token = try! keychain.get(key: key)! - - #expect(token.accessToken.isNotEmpty) - #expect(token.accessToken == found.accessToken) - #expect(token.expiresIn == found.expiresIn) - #expect(token.scope == found.scope) - #expect(token.type == found.type) - - let keys = keychain.keys - debugPrint("🔐", keys) - #expect(keys.count == 1) - - let deleted = keychain.delete(key: key) - #expect(deleted == true) - } -} diff --git a/Tests/OAuthKitTests/StorageTests.swift b/Tests/OAuthKitTests/StorageTests.swift new file mode 100644 index 0000000..030cae7 --- /dev/null +++ b/Tests/OAuthKitTests/StorageTests.swift @@ -0,0 +1,79 @@ +// +// StorageTests.swift +// +// +// Created by Kevin McKee +// + +@testable import OAuthKit +import Testing + +@Suite("Storage Tests", .tags(.storage)) +final class StorageTests { + + let keychain: Keychain + let inMemoryStorage: InMemoryStorage + + deinit { + keychain.clear() + inMemoryStorage.clear() + } + + /// Initializer. + init() async throws { + let tag: String = "oauthkit.test." + .secureRandom() + keychain = .init(tag) + inMemoryStorage = .init(tag) + } + + @Test("Storing keychain values") + func whenStoringKeychainValues() async throws { + let key = "Github" + let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: "email", type: "Bearer") + + let inserted = try! keychain.set(token, for: key) + #expect(inserted == true) + + let found: OAuth.Token = try! keychain.get(key: key)! + + #expect(token.accessToken.isNotEmpty) + #expect(token.accessToken == found.accessToken) + #expect(token.expiresIn == found.expiresIn) + #expect(token.scope == found.scope) + #expect(token.type == found.type) + + let keys = keychain.keys + debugPrint("🔐", keys) + #expect(keys.count == 1) + + let deleted = keychain.delete(key: key) + #expect(deleted == true) + #expect(keychain.keys.count == 0) + } + + @Test("Storing in-memory values") + func whenStoringInMemoryValues() async throws { + let key = "Github" + let token: OAuth.Token = .init(accessToken: .secureRandom(), refreshToken: .secureRandom(), expiresIn: 3600, scope: "email", type: "Bearer") + + let inserted = try! inMemoryStorage.set(token, for: key) + #expect(inserted == true) + + let found: OAuth.Token = try! inMemoryStorage.get(key: key)! + + #expect(token.accessToken.isNotEmpty) + #expect(token.accessToken == found.accessToken) + #expect(token.expiresIn == found.expiresIn) + #expect(token.scope == found.scope) + #expect(token.type == found.type) + + let keys = inMemoryStorage.keys + debugPrint("🔐", keys) + #expect(keys.count == 1) + + let deleted = inMemoryStorage.delete(key: key) + #expect(deleted == true) + #expect(inMemoryStorage.keys.count == 0) + } + +} diff --git a/Tests/OAuthKitTests/Tags+Extensions.swift b/Tests/OAuthKitTests/Tags+Extensions.swift index 211bb44..7dcf49e 100644 --- a/Tests/OAuthKitTests/Tags+Extensions.swift +++ b/Tests/OAuthKitTests/Tags+Extensions.swift @@ -9,7 +9,7 @@ import Testing extension Tag { @Tag static var oauth: Self - @Tag static var keychain: Self + @Tag static var storage: Self @Tag static var utility: Self @Tag static var views: Self } From 9d1da2205d98b1eec20eb838d2cb2923b6f71eb8 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Sat, 26 Sep 2026 16:04:31 -0700 Subject: [PATCH 4/7] WIP --- Sources/OAuthKit/OAuth+Option.swift | 3 +++ Sources/OAuthKit/OAuth+Storage.swift | 7 +++++++ Sources/OAuthKit/OAuth.swift | 21 +++++++++---------- .../OAuthKit/Storage/InMemoryStorage.swift | 3 +-- Tests/OAuthKitTests/OAuthTests.swift | 6 ++++++ 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/Sources/OAuthKit/OAuth+Option.swift b/Sources/OAuthKit/OAuth+Option.swift index 0cc85e8..94bc2be 100644 --- a/Sources/OAuthKit/OAuth+Option.swift +++ b/Sources/OAuthKit/OAuth+Option.swift @@ -46,5 +46,8 @@ public extension OAuth.Option { /// This is disabled by default, but this can be turned on to allow developers to use an ephemeral webkit datastore /// that effectively implements private browsing and forces a new login attempt every time an authorization flow is started. static let useNonPersistentWebDataStore: OAuth.Option = .init(rawValue: "useNonPersistentWebDataStore") + + /// A key used for providing a custom token storage. + static let storage: OAuth.Option = .init(rawValue: "storage") } diff --git a/Sources/OAuthKit/OAuth+Storage.swift b/Sources/OAuthKit/OAuth+Storage.swift index b67fee9..f6ec56f 100644 --- a/Sources/OAuthKit/OAuth+Storage.swift +++ b/Sources/OAuthKit/OAuth+Storage.swift @@ -67,3 +67,10 @@ public extension OAuth.Storage { account + "." + key + "." + tokenIdentifier } } + +//public extension OAuth.Storage where Self: Equatable { +// +// static func == (lhs: any OAuth.Storage, rhs: any OAuth.Storage) -> Bool { +// return false +// } +//} diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index 5b730c5..f5d4e3b 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -224,15 +224,14 @@ private extension OAuth { } #endif + if let customStorage = options[.storage] as? Storage { + self.storage = customStorage + } + // Override the url session if let urlSession = options[.urlSession] as? URLSession { self.urlSession = urlSession } - - // Override the keychain to use the custom application tag - if let applicationTag = options[.applicationTag] as? String, applicationTag.isNotEmpty { - self.storage = Keychain(applicationTag) - } } monitor() restore() @@ -516,19 +515,19 @@ extension OAuth { } } -/// Builds the token storage facility based on OS and options. +/// Builds the default token storage facility based on OS and options. fileprivate func makeStorage(options: [OAuth.Option: Any]? = nil) -> OAuth.Storage { - var storageAccount: String = defaultAccount + var account: String = defaultAccount // Override from account from passed in options if let options, let applicationTag = options[.applicationTag] as? String, applicationTag.isNotEmpty { - storageAccount = applicationTag + account = applicationTag } #if canImport(Security) - return Keychain(storageAccount) - #elseif os(Android) - #elseif os(Linux) + return Keychain(account) + #elseif os(Android) || os(Linux) + return InMemoryStorage(account) #endif } diff --git a/Sources/OAuthKit/Storage/InMemoryStorage.swift b/Sources/OAuthKit/Storage/InMemoryStorage.swift index c912e9a..c1ef54c 100644 --- a/Sources/OAuthKit/Storage/InMemoryStorage.swift +++ b/Sources/OAuthKit/Storage/InMemoryStorage.swift @@ -14,7 +14,7 @@ class InMemoryStorage: OAuth.Storage, @unchecked Sendable { private let lock: NSLock = .init() public var account: String - /// Initializes the storage with an overridden accound identifier. + /// Initializes the storage with an account identifier. /// - Parameter account: a key indicating the account owner. Ideally, use the application identifier for this value. required init(_ account: String) { assert(account.isNotEmpty, "❌ The account identifier cannot be empty.") @@ -45,7 +45,6 @@ class InMemoryStorage: OAuth.Storage, @unchecked Sendable { /// - Parameter key: the storage key /// - Returns: the generic codeable for the specified key or nil if not found func get(key: String) throws -> T? where T: Codable { - lock.lock() defer { lock.unlock() } diff --git a/Tests/OAuthKitTests/OAuthTests.swift b/Tests/OAuthKitTests/OAuthTests.swift index 78856c3..79d4fe4 100644 --- a/Tests/OAuthKitTests/OAuthTests.swift +++ b/Tests/OAuthKitTests/OAuthTests.swift @@ -44,16 +44,19 @@ final class OAuthTests { oauth = .init(.module, options: options) #expect(oauth.useNonPersistentWebDataStore == true) #expect(oauth.urlSession == Self.urlSession) + #expect(oauth.storage is Keychain) } /// Tests the initialization with providers. @Test("When Initializing") func whenInitializing() async throws { let appTag: String = .secureRandom() + let storage = InMemoryStorage(appTag) let options: [OAuth.Option: Any] = [ .applicationTag: appTag, .autoRefresh: true, .urlSession: Self.urlSession, + .storage: storage ] let providers: [OAuth.Provider] = [ .init(id: .secureRandom(), @@ -66,6 +69,9 @@ final class OAuthTests { #expect(customOAuth.providers.count == 1) #expect(customOAuth.useNonPersistentWebDataStore == false) #expect(customOAuth.urlSession == Self.urlSession) + #expect(customOAuth.providers.count == 1) + #expect(customOAuth.storage.account == appTag) + #expect(customOAuth.storage is InMemoryStorage) } @Test("When Requiring Local Authentication") From 916cf2f7cc80603b55d34963e6ff2cce17c6387f Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Tue, 20 Oct 2026 12:44:07 -0700 Subject: [PATCH 5/7] WIP --- Sources/OAuthKit/OAuth+Storage.swift | 7 ------- Sources/OAuthKit/OAuthKit.docc/Configuration.md | 12 ++++++++++++ Sources/OAuthKit/Storage/InMemoryStorage.swift | 2 +- Sources/OAuthKit/Storage/Keychain.swift | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Sources/OAuthKit/OAuth+Storage.swift b/Sources/OAuthKit/OAuth+Storage.swift index f6ec56f..b67fee9 100644 --- a/Sources/OAuthKit/OAuth+Storage.swift +++ b/Sources/OAuthKit/OAuth+Storage.swift @@ -67,10 +67,3 @@ public extension OAuth.Storage { account + "." + key + "." + tokenIdentifier } } - -//public extension OAuth.Storage where Self: Equatable { -// -// static func == (lhs: any OAuth.Storage, rhs: any OAuth.Storage) -> Bool { -// return false -// } -//} diff --git a/Sources/OAuthKit/OAuthKit.docc/Configuration.md b/Sources/OAuthKit/OAuthKit.docc/Configuration.md index 63109c6..0605b6d 100644 --- a/Sources/OAuthKit/OAuthKit.docc/Configuration.md +++ b/Sources/OAuthKit/OAuthKit.docc/Configuration.md @@ -83,3 +83,15 @@ let oauth: OAuth = .init(.module, options: options) > Tip: See ``OAuth/Option`` for a complete list of configuration options. +### Custom Token Storage +The ``OAuth/Option/storage`` option allows you to provide a custom token storage repository that conforms to the ``OAuth/Storage`` protocol. [Kotlin Interoperability with Swift](https://kotlinlang.org/docs/native-objc-interop.html) + +```swift +// Custom Token Storage +let storage: Storage = MyCustomStorage("com.myapp.AppName") + +let options: [OAuth.Option: Any] = [ + .storage: storage +] +let oauth: OAuth = .init(.module, options: options) +``` diff --git a/Sources/OAuthKit/Storage/InMemoryStorage.swift b/Sources/OAuthKit/Storage/InMemoryStorage.swift index c1ef54c..38092f5 100644 --- a/Sources/OAuthKit/Storage/InMemoryStorage.swift +++ b/Sources/OAuthKit/Storage/InMemoryStorage.swift @@ -10,9 +10,9 @@ import Foundation /// A storage implementation that holds all of the tokens in-memory. class InMemoryStorage: OAuth.Storage, @unchecked Sendable { + public let account: String private var values: [String: Codable] = .init() private let lock: NSLock = .init() - public var account: String /// Initializes the storage with an account identifier. /// - Parameter account: a key indicating the account owner. Ideally, use the application identifier for this value. diff --git a/Sources/OAuthKit/Storage/Keychain.swift b/Sources/OAuthKit/Storage/Keychain.swift index d7205ce..94d8b27 100644 --- a/Sources/OAuthKit/Storage/Keychain.swift +++ b/Sources/OAuthKit/Storage/Keychain.swift @@ -13,10 +13,10 @@ import Security /// A helper class used to interact with Keychain storage access. Wraps all storage write operations with threadsafe locks. class Keychain: OAuth.Storage, @unchecked Sendable { + public let account: String private let lock: NSLock = .init() private let encoder: JSONEncoder = .init() private let decoder: JSONDecoder = .init() - public var account: String /// Initializes the keychain with an overridden accound identifier. /// - Parameter account: a key indicating the account owner. Ideally, use the application identifier for this value. From 92c85a0ab899681c1e755a39427e20067808ecb5 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Mon, 26 Oct 2026 13:02:15 -0700 Subject: [PATCH 6/7] Comment --- Sources/OAuthKit/OAuth.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index f5d4e3b..e8b972c 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -224,6 +224,7 @@ private extension OAuth { } #endif + // Override the default token storage if let customStorage = options[.storage] as? Storage { self.storage = customStorage } From 811b8e5409a63304c51d6aa0b239eb1811307582 Mon Sep 17 00:00:00 2001 From: Kevin McKee Date: Mon, 26 Oct 2026 13:04:13 -0700 Subject: [PATCH 7/7] WIP --- Sources/OAuthKit/OAuth.swift | 1 + Sources/OAuthKit/Storage/InMemoryStorage.swift | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/OAuthKit/OAuth.swift b/Sources/OAuthKit/OAuth.swift index e8b972c..bf550cf 100644 --- a/Sources/OAuthKit/OAuth.swift +++ b/Sources/OAuthKit/OAuth.swift @@ -529,6 +529,7 @@ fileprivate func makeStorage(options: [OAuth.Option: Any]? = nil) -> OAuth.Stora #if canImport(Security) return Keychain(account) #elseif os(Android) || os(Linux) + // TODO: Android and Linux Default Secure Storage return InMemoryStorage(account) #endif } diff --git a/Sources/OAuthKit/Storage/InMemoryStorage.swift b/Sources/OAuthKit/Storage/InMemoryStorage.swift index 38092f5..7809077 100644 --- a/Sources/OAuthKit/Storage/InMemoryStorage.swift +++ b/Sources/OAuthKit/Storage/InMemoryStorage.swift @@ -7,7 +7,7 @@ import Foundation -/// A storage implementation that holds all of the tokens in-memory. +/// A storage implementation that holds all of the tokens in-memory. class InMemoryStorage: OAuth.Storage, @unchecked Sendable { public let account: String