main
swift 160 lines 5.78 KB
Raw
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