AuthStoredUserManager.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. // Copyright 2023 Google LLC
  2. //
  3. // Licensed under the Apache License, Version 2.0 (the "License");
  4. // you may not use this file except in compliance with the License.
  5. // You may obtain a copy of the License at
  6. //
  7. // http://www.apache.org/licenses/LICENSE-2.0
  8. //
  9. // Unless required by applicable law or agreed to in writing, software
  10. // distributed under the License is distributed on an "AS IS" BASIS,
  11. // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. // See the License for the specific language governing permissions and
  13. // limitations under the License.
  14. import Foundation
  15. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *)
  16. class AuthStoredUserManager {
  17. /// Key of user access group stored in user defaults. Used for retrieve the
  18. /// user access group at launch.
  19. private static let storedUserAccessGroupKey = "firebase_auth_stored_user_access_group"
  20. /// Default value for kSecAttrAccount of shared keychain items.
  21. private static let sharedKeychainAccountValue = "firebase_auth_firebase_user"
  22. /// The key to encode and decode the stored user.
  23. private static let storedUserCoderKey = "firebase_auth_stored_user_coder_key"
  24. /// Mediator object used to access the keychain.
  25. private let keychainServices: AuthKeychainServices
  26. /// Mediator object used to access user defaults.
  27. private let userDefaults: AuthUserDefaults
  28. /// Designated initializer.
  29. /// - Parameter serviceName: The service name to initialize with.
  30. /// - Parameter keychainServices: The keychain manager (or a fake in unit tests)
  31. init(serviceName: String, keychainServices: AuthKeychainServices) {
  32. userDefaults = AuthUserDefaults(service: serviceName)
  33. self.keychainServices = keychainServices
  34. }
  35. /// Get the user access group stored locally.
  36. /// - Returns: The stored user access group; otherwise, `nil`.
  37. func getStoredUserAccessGroup() -> String? {
  38. if let data = userDefaults.data(forKey: Self.storedUserAccessGroupKey) {
  39. let userAccessGroup = String(data: data, encoding: .utf8)
  40. return userAccessGroup
  41. } else {
  42. return nil
  43. }
  44. }
  45. /// The setter of the user access group stored locally.
  46. /// - Parameter accessGroup: The access group to be store.
  47. func setStoredUserAccessGroup(accessGroup: String?) {
  48. if let data = accessGroup?.data(using: .utf8) {
  49. userDefaults.setData(data, forKey: Self.storedUserAccessGroupKey)
  50. } else {
  51. userDefaults.removeData(forKey: Self.storedUserAccessGroupKey)
  52. }
  53. }
  54. // MARK: - User for Access Group
  55. /// The getter of the user stored locally.
  56. /// - Parameters:
  57. /// - accessGroup: The access group to retrieve the user from.
  58. /// - shareAuthStateAcrossDevices: If `true`, the keychain will be synced
  59. /// across the end-user's iCloud.
  60. /// - projectIdentifier: An identifier of the project that the user
  61. /// associates with.
  62. /// - Returns: The stored user for the given attributes.
  63. /// - Throws: An error if the operation failed.
  64. func getStoredUser(accessGroup: String,
  65. shareAuthStateAcrossDevices: Bool,
  66. projectIdentifier: String) throws -> User? {
  67. let query = keychainQuery(
  68. accessGroup: accessGroup,
  69. shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
  70. projectIdentifier: projectIdentifier
  71. )
  72. guard let data = try keychainServices.getItem(query: query) else {
  73. return nil
  74. }
  75. let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
  76. return unarchiver.decodeObject(of: User.self, forKey: Self.storedUserCoderKey)
  77. }
  78. /// The setter of the user stored locally.
  79. /// - Parameters:
  80. /// - user: The user to be stored.
  81. /// - accessGroup: The access group to store the user in.
  82. /// - shareAuthStateAcrossDevices: If `true`, the keychain will be
  83. /// synced across the end-user's iCloud.
  84. /// - projectIdentifier: An identifier of the project that the user
  85. /// associates with.
  86. /// - Throws: An error if the operation failed.
  87. func setStoredUser(user: User,
  88. accessGroup: String,
  89. shareAuthStateAcrossDevices: Bool,
  90. projectIdentifier: String) throws {
  91. var query = keychainQuery(
  92. accessGroup: accessGroup,
  93. shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
  94. projectIdentifier: projectIdentifier
  95. )
  96. if shareAuthStateAcrossDevices {
  97. query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
  98. } else {
  99. query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  100. }
  101. // TODO(ncooke3): The Objective-C code has an #if for watchOS here.
  102. // Does this work for watchOS?
  103. let archiver = NSKeyedArchiver(requiringSecureCoding: false)
  104. archiver.encode(user, forKey: Self.storedUserCoderKey)
  105. archiver.finishEncoding()
  106. // In Firebase 10, the below query contained the `kSecAttrSynchronizable`
  107. // key set to `true` when `shareAuthStateAcrossDevices == true`. This
  108. // allows a user entry to be shared across devices via the iCloud keychain.
  109. // For the purpose of this discussion, such a user entry will be referred
  110. // to as a "iCloud entry". Conversely, a "non-iCloud entry" will refer to a
  111. // user entry stored when `shareAuthStateAcrossDevices == false`. Keep in
  112. // mind that this class exclusively manages user entries stored in
  113. // device-specific keychain access groups, so both iCloud and non-iCloud
  114. // entries are implicitly available at the device level to apps that
  115. // have access rights to the specific keychain access group used.
  116. //
  117. // The iCloud/non-iCloud distinction is important because entries stored
  118. // with `kSecAttrSynchronizable == true` can only be retrieved when the
  119. // search query includes `kSecAttrSynchronizable == true`. Likewise,
  120. // entries stored without the `kSecAttrSynchronizable` key (or
  121. // `kSecAttrSynchronizable == false`) can only be retrieved when
  122. // the search query omits `kSecAttrSynchronizable` or sets it to `false`.
  123. //
  124. // So for each access group, the SDK manages up to two buckets in the
  125. // keychain, one for iCloud entries and one for non-iCloud entries.
  126. //
  127. // From Firebase 11.0.0 up to but not including 11.3.0, the
  128. // `kSecAttrSynchronizable` key was *not* included in the query when
  129. // `shareAuthStateAcrossDevices == true`. This had the effect of the iCloud
  130. // bucket being inaccessible, and iCloud and non-iCloud entries attempting
  131. // to be written to the same bucket. This was problematic because the
  132. // two types of entries use another flag, the `kSecAttrAccessible` flag,
  133. // with different values. If two queries are identical apart from different
  134. // values for their `kSecAttrAccessible` key, whichever query written to
  135. // the keychain first won't be accessible for reading or updating via the
  136. // other query (resulting in a OSStatus of -25300 indicating the queried
  137. // item cannot be found). And worse, attempting to write the other query to
  138. // the keychain won't work because the write will conflict with the
  139. // previously written query (resulting in a OSStatus of -25299 indicating a
  140. // duplicate item already exists in the keychain). This formed the basis
  141. // for the issues this bug caused.
  142. //
  143. // The missing key was added back in 11.3, but adding back the key
  144. // introduced a new issue. If the buggy version succeeded at writing an
  145. // iCloud entry to the non-iCloud bucket (e.g. keychain was empty before
  146. // iCloud entry was written), then all future non-iCloud writes would fail
  147. // due to the mismatching `kSecAttrAccessible` flag and throw an
  148. // unrecoverable error. To address this the below error handling is used to
  149. // detect such cases, remove the "corrupt" iCloud entry stored by the buggy
  150. // version in the non-iCloud bucket, and retry writing the current
  151. // non-iCloud entry again.
  152. do {
  153. try keychainServices.setItem(archiver.encodedData, withQuery: query)
  154. } catch let error as NSError {
  155. guard shareAuthStateAcrossDevices == false,
  156. error.localizedFailureReason == "SecItemAdd (-25299)" else {
  157. // The error is not related to the 11.0 - 11.2 issue described above,
  158. // and should be rethrown.
  159. throw error
  160. }
  161. // We are trying to write a non-iCloud entry but a corrupt iCloud entry
  162. // is likely preventing it from happening.
  163. //
  164. // The corrupt query was supposed to contain the following keys:
  165. // {
  166. // kSecAttrSynchronizable: true,
  167. // kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
  168. // }
  169. // Instead, it contained:
  170. // {
  171. // kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
  172. // }
  173. //
  174. // Excluding `kSecAttrSynchronizable` treats the query as if it's false
  175. // and the entry won't be shared in iCloud across devices. It is instead
  176. // written to the non-iCloud bucket. This query is corrupting the
  177. // non-iCloud bucket because its `kSecAttrAccessible` value is not
  178. // compatible with the value used for non-iCloud entries. To delete it,
  179. // a compatible query is formed by swapping the accessibility flag
  180. // out for `kSecAttrAccessibleAfterFirstUnlock`. This frees up the bucket
  181. // so the non-iCloud entry can attempt to be written again.
  182. let corruptQuery = query
  183. .merging([kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock]) { $1 }
  184. try keychainServices.removeItem(query: corruptQuery)
  185. try keychainServices.setItem(archiver.encodedData, withQuery: query)
  186. }
  187. }
  188. /// Remove the user that stored locally.
  189. /// - Parameters:
  190. /// - accessGroup: The access group to remove the user from.
  191. /// - shareAuthStateAcrossDevices: If `true`, the keychain will be
  192. /// synced across the end-user's iCloud.
  193. /// - projectIdentifier: An identifier of the project that the user
  194. /// associates with.
  195. /// - Throws: An error if the operation failed.
  196. func removeStoredUser(accessGroup: String,
  197. shareAuthStateAcrossDevices: Bool,
  198. projectIdentifier: String) throws {
  199. var query = keychainQuery(
  200. accessGroup: accessGroup,
  201. shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
  202. projectIdentifier: projectIdentifier
  203. )
  204. if shareAuthStateAcrossDevices {
  205. query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
  206. } else {
  207. query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
  208. }
  209. try keychainServices.removeItem(query: query)
  210. }
  211. // MARK: - Private Helpers
  212. private func keychainQuery(accessGroup: String,
  213. shareAuthStateAcrossDevices: Bool,
  214. projectIdentifier: String) -> [String: Any] {
  215. var query: [String: Any] = [
  216. kSecClass as String: kSecClassGenericPassword,
  217. kSecAttrAccessGroup as String: accessGroup,
  218. kSecAttrService as String: projectIdentifier,
  219. kSecAttrAccount as String: Self.sharedKeychainAccountValue,
  220. ]
  221. query[kSecUseDataProtectionKeychain as String] = true
  222. if shareAuthStateAcrossDevices {
  223. query[kSecAttrSynchronizable as String] = true
  224. }
  225. return query
  226. }
  227. }