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+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 new file mode 100644 index 0000000..b67fee9 --- /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 token 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..bf550cf 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. @@ -48,8 +50,9 @@ public final class OAuth: Sendable { @ObservationIgnored public var urlSession: URLSession = .init(configuration: .ephemeral) + /// The token storage facility to use. @ObservationIgnored - var keychain: Keychain = .default + var storage: Storage #if os(macOS) || os(iOS) || os(visionOS) @ObservationIgnored @@ -97,6 +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 = makeStorage(options: options) self.providers = providers configure(options) } @@ -106,6 +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 = makeStorage(options: options) self.providers = loadProviders(bundle) configure(options) } @@ -161,7 +166,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 +190,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)) } } @@ -219,15 +224,15 @@ private extension OAuth { } #endif + // Override the default token storage + 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.keychain = .init(applicationTag) - } } monitor() restore() @@ -333,13 +338,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 +384,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 +396,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 +422,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 +474,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,9 +509,27 @@ 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)) } } + +/// Builds the default token storage facility based on OS and options. +fileprivate func makeStorage(options: [OAuth.Option: Any]? = nil) -> OAuth.Storage { + + var account: String = defaultAccount + + // Override from account from passed in options + if let options, let applicationTag = options[.applicationTag] as? String, applicationTag.isNotEmpty { + account = applicationTag + } + + #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/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 new file mode 100644 index 0000000..7809077 --- /dev/null +++ b/Sources/OAuthKit/Storage/InMemoryStorage.swift @@ -0,0 +1,76 @@ +// +// 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 { + + public let account: String + private var values: [String: Codable] = .init() + private let lock: NSLock = .init() + + /// 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.") + 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/Sources/OAuthKit/Storage/Keychain.swift b/Sources/OAuthKit/Storage/Keychain.swift new file mode 100644 index 0000000..94d8b27 --- /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 { + + public let account: String + private let lock: NSLock = .init() + private let encoder: JSONEncoder = .init() + private let decoder: JSONDecoder = .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. + 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/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/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..79d4fe4 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! @@ -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") @@ -89,7 +95,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 +106,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 +223,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 +234,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 +272,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 +313,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() 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 }