| 1 | #if canImport(Security) |
| 2 | import AuthCore |
| 3 | import Foundation |
| 4 | import Security |
| 5 | |
| 6 | public enum SmbCloudCredentialsManagerError: Error, LocalizedError, Sendable { |
| 7 | case encodingFailed(String) |
| 8 | case decodingFailed(String) |
| 9 | case keychainOperationFailed(status: OSStatus, message: String) |
| 10 | |
| 11 | public var errorDescription: String? { |
| 12 | switch self { |
| 13 | case .encodingFailed(let message): |
| 14 | return message |
| 15 | case .decodingFailed(let message): |
| 16 | return message |
| 17 | case .keychainOperationFailed(_, let message): |
| 18 | return message |
| 19 | } |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | /// Keychain-backed ``SmbCloudCredentialsStore`` for Apple platforms. |
| 24 | /// |
| 25 | /// This type is only available where the `Security` framework exists |
| 26 | /// (iOS, macOS, tvOS, watchOS, visionOS). On other platforms, conform your |
| 27 | /// own type to ``SmbCloudCredentialsStore`` or use |
| 28 | /// ``SmbCloudInMemoryCredentialsStore`` from `AuthCore`. |
| 29 | public struct SmbCloudCredentialsManager: SmbCloudCredentialsStore { |
| 30 | public let service: String |
| 31 | public let account: String |
| 32 | public let accessGroup: String? |
| 33 | |
| 34 | public init( |
| 35 | service: String? = nil, |
| 36 | account: String = "current", |
| 37 | accessGroup: String? = nil |
| 38 | ) { |
| 39 | self.service = service?.smbCloudNilIfBlank ?? Self.defaultServiceName |
| 40 | self.account = account |
| 41 | self.accessGroup = accessGroup?.smbCloudNilIfBlank |
| 42 | } |
| 43 | |
| 44 | public func store(_ session: SmbCloudSession) throws { |
| 45 | let encodedSession: Data |
| 46 | do { |
| 47 | encodedSession = try JSONEncoder().encode(session.storedRepresentation()) |
| 48 | } catch { |
| 49 | throw SmbCloudCredentialsManagerError.encodingFailed( |
| 50 | "Failed to encode smbCloud session: \(error.localizedDescription)" |
| 51 | ) |
| 52 | } |
| 53 | |
| 54 | var addQuery = baseQuery() |
| 55 | addQuery[kSecValueData as String] = encodedSession |
| 56 | addSecureAccessibility(to: &addQuery) |
| 57 | |
| 58 | let addStatus = SecItemAdd(addQuery as CFDictionary, nil) |
| 59 | switch addStatus { |
| 60 | case errSecSuccess: |
| 61 | return |
| 62 | case errSecDuplicateItem: |
| 63 | let attributesToUpdate = [ |
| 64 | kSecValueData as String: encodedSession |
| 65 | ] |
| 66 | let updateStatus = SecItemUpdate( |
| 67 | baseQuery() as CFDictionary, attributesToUpdate as CFDictionary) |
| 68 | guard updateStatus == errSecSuccess else { |
| 69 | throw keychainError(for: updateStatus) |
| 70 | } |
| 71 | default: |
| 72 | throw keychainError(for: addStatus) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | public func current() throws -> SmbCloudSession? { |
| 77 | var query = baseQuery() |
| 78 | query[kSecReturnData as String] = true |
| 79 | query[kSecMatchLimit as String] = kSecMatchLimitOne |
| 80 | |
| 81 | var item: CFTypeRef? |
| 82 | let status = SecItemCopyMatching(query as CFDictionary, &item) |
| 83 | switch status { |
| 84 | case errSecSuccess: |
| 85 | guard let data = item as? Data else { |
| 86 | throw SmbCloudCredentialsManagerError.decodingFailed( |
| 87 | "The keychain item did not contain valid session data." |
| 88 | ) |
| 89 | } |
| 90 | |
| 91 | do { |
| 92 | return try JSONDecoder().decode(SmbCloudSession.self, from: data) |
| 93 | .storedRepresentation() |
| 94 | } catch { |
| 95 | throw SmbCloudCredentialsManagerError.decodingFailed( |
| 96 | "Failed to decode smbCloud session: \(error.localizedDescription)" |
| 97 | ) |
| 98 | } |
| 99 | case errSecItemNotFound: |
| 100 | return nil |
| 101 | default: |
| 102 | throw keychainError(for: status) |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | public func clear() throws { |
| 107 | let status = SecItemDelete(baseQuery() as CFDictionary) |
| 108 | switch status { |
| 109 | case errSecSuccess, errSecItemNotFound: |
| 110 | return |
| 111 | default: |
| 112 | throw keychainError(for: status) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | extension SmbCloudCredentialsManager { |
| 118 | fileprivate static var defaultServiceName: String { |
| 119 | if let bundleIdentifier = Bundle.main.bundleIdentifier?.smbCloudNilIfBlank { |
| 120 | return "\(bundleIdentifier).smbcloud.auth" |
| 121 | } |
| 122 | |
| 123 | return "xyz.smbcloud.auth" |
| 124 | } |
| 125 | |
| 126 | fileprivate func baseQuery() -> [String: Any] { |
| 127 | var query: [String: Any] = [ |
| 128 | kSecClass as String: kSecClassGenericPassword, |
| 129 | kSecAttrService as String: service, |
| 130 | kSecAttrAccount as String: account, |
| 131 | ] |
| 132 | |
| 133 | if let accessGroup { |
| 134 | query[kSecAttrAccessGroup as String] = accessGroup |
| 135 | } |
| 136 | |
| 137 | return query |
| 138 | } |
| 139 | |
| 140 | fileprivate func addSecureAccessibility(to query: inout [String: Any]) { |
| 141 | #if os(iOS) || os(tvOS) || os(watchOS) || os(visionOS) |
| 142 | query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly |
| 143 | #endif |
| 144 | } |
| 145 | |
| 146 | fileprivate func keychainError(for status: OSStatus) -> SmbCloudCredentialsManagerError { |
| 147 | let message = |
| 148 | SecCopyErrorMessageString(status, nil) as String? |
| 149 | ?? "Keychain operation failed with status \(status)." |
| 150 | return .keychainOperationFailed(status: status, message: message) |
| 151 | } |
| 152 | } |
| 153 | |
| 154 | extension String { |
| 155 | /// Module-local helper mirroring AuthCore's internal `nilIfEmpty`. |
| 156 | fileprivate var smbCloudNilIfBlank: String? { |
| 157 | trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self |
| 158 | } |
| 159 | } |
| 160 | #endif |