mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-09 22:36:01 +05:45
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
250 lines
11 KiB
Swift
250 lines
11 KiB
Swift
// Copyright 2023 Google LLC
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
import Foundation
|
|
|
|
class AuthStoredUserManager {
|
|
/// Key of user access group stored in user defaults. Used for retrieve the
|
|
/// user access group at launch.
|
|
private static let storedUserAccessGroupKey = "firebase_auth_stored_user_access_group"
|
|
|
|
/// Default value for kSecAttrAccount of shared keychain items.
|
|
private static let sharedKeychainAccountValue = "firebase_auth_firebase_user"
|
|
|
|
/// The key to encode and decode the stored user.
|
|
private static let storedUserCoderKey = "firebase_auth_stored_user_coder_key"
|
|
|
|
/// Mediator object used to access the keychain.
|
|
private let keychainServices: AuthKeychainServices
|
|
|
|
/// Mediator object used to access user defaults.
|
|
private let userDefaults: AuthUserDefaults
|
|
|
|
/// Designated initializer.
|
|
/// - Parameter serviceName: The service name to initialize with.
|
|
/// - Parameter keychainServices: The keychain manager (or a fake in unit tests)
|
|
init(serviceName: String, keychainServices: AuthKeychainServices) {
|
|
userDefaults = AuthUserDefaults(service: serviceName)
|
|
self.keychainServices = keychainServices
|
|
}
|
|
|
|
/// Get the user access group stored locally.
|
|
/// - Returns: The stored user access group; otherwise, `nil`.
|
|
func getStoredUserAccessGroup() -> String? {
|
|
if let data = userDefaults.data(forKey: Self.storedUserAccessGroupKey) {
|
|
let userAccessGroup = String(data: data, encoding: .utf8)
|
|
return userAccessGroup
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// The setter of the user access group stored locally.
|
|
/// - Parameter accessGroup: The access group to be store.
|
|
func setStoredUserAccessGroup(accessGroup: String?) {
|
|
if let data = accessGroup?.data(using: .utf8) {
|
|
userDefaults.setData(data, forKey: Self.storedUserAccessGroupKey)
|
|
} else {
|
|
userDefaults.removeData(forKey: Self.storedUserAccessGroupKey)
|
|
}
|
|
}
|
|
|
|
// MARK: - User for Access Group
|
|
|
|
/// The getter of the user stored locally.
|
|
/// - Parameters:
|
|
/// - accessGroup: The access group to retrieve the user from.
|
|
/// - shareAuthStateAcrossDevices: If `true`, the keychain will be synced
|
|
/// across the end-user's iCloud.
|
|
/// - projectIdentifier: An identifier of the project that the user
|
|
/// associates with.
|
|
/// - Returns: The stored user for the given attributes.
|
|
/// - Throws: An error if the operation failed.
|
|
func getStoredUser(accessGroup: String,
|
|
shareAuthStateAcrossDevices: Bool,
|
|
projectIdentifier: String) throws -> User? {
|
|
let query = keychainQuery(
|
|
accessGroup: accessGroup,
|
|
shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
|
|
projectIdentifier: projectIdentifier
|
|
)
|
|
guard let data = try keychainServices.getItem(query: query) else {
|
|
return nil
|
|
}
|
|
let unarchiver = try NSKeyedUnarchiver(forReadingFrom: data)
|
|
return unarchiver.decodeObject(of: User.self, forKey: Self.storedUserCoderKey)
|
|
}
|
|
|
|
/// The setter of the user stored locally.
|
|
/// - Parameters:
|
|
/// - user: The user to be stored.
|
|
/// - accessGroup: The access group to store the user in.
|
|
/// - shareAuthStateAcrossDevices: If `true`, the keychain will be
|
|
/// synced across the end-user's iCloud.
|
|
/// - projectIdentifier: An identifier of the project that the user
|
|
/// associates with.
|
|
/// - Throws: An error if the operation failed.
|
|
func setStoredUser(user: User,
|
|
accessGroup: String,
|
|
shareAuthStateAcrossDevices: Bool,
|
|
projectIdentifier: String) throws {
|
|
var query = keychainQuery(
|
|
accessGroup: accessGroup,
|
|
shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
|
|
projectIdentifier: projectIdentifier
|
|
)
|
|
|
|
if shareAuthStateAcrossDevices {
|
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
} else {
|
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
}
|
|
|
|
// TODO(ncooke3): The Objective-C code has an #if for watchOS here.
|
|
// Does this work for watchOS?
|
|
|
|
let archiver = NSKeyedArchiver(requiringSecureCoding: false)
|
|
archiver.encode(user, forKey: Self.storedUserCoderKey)
|
|
archiver.finishEncoding()
|
|
|
|
// In Firebase 10, the below query contained the `kSecAttrSynchronizable`
|
|
// key set to `true` when `shareAuthStateAcrossDevices == true`. This
|
|
// allows a user entry to be shared across devices via the iCloud keychain.
|
|
// For the purpose of this discussion, such a user entry will be referred
|
|
// to as a "iCloud entry". Conversely, a "non-iCloud entry" will refer to a
|
|
// user entry stored when `shareAuthStateAcrossDevices == false`. Keep in
|
|
// mind that this class exclusively manages user entries stored in
|
|
// device-specific keychain access groups, so both iCloud and non-iCloud
|
|
// entries are implicitly available at the device level to apps that
|
|
// have access rights to the specific keychain access group used.
|
|
//
|
|
// The iCloud/non-iCloud distinction is important because entries stored
|
|
// with `kSecAttrSynchronizable == true` can only be retrieved when the
|
|
// search query includes `kSecAttrSynchronizable == true`. Likewise,
|
|
// entries stored without the `kSecAttrSynchronizable` key (or
|
|
// `kSecAttrSynchronizable == false`) can only be retrieved when
|
|
// the search query omits `kSecAttrSynchronizable` or sets it to `false`.
|
|
//
|
|
// So for each access group, the SDK manages up to two buckets in the
|
|
// keychain, one for iCloud entries and one for non-iCloud entries.
|
|
//
|
|
// From Firebase 11.0.0 up to but not including 11.3.0, the
|
|
// `kSecAttrSynchronizable` key was *not* included in the query when
|
|
// `shareAuthStateAcrossDevices == true`. This had the effect of the iCloud
|
|
// bucket being inaccessible, and iCloud and non-iCloud entries attempting
|
|
// to be written to the same bucket. This was problematic because the
|
|
// two types of entries use another flag, the `kSecAttrAccessible` flag,
|
|
// with different values. If two queries are identical apart from different
|
|
// values for their `kSecAttrAccessible` key, whichever query written to
|
|
// the keychain first won't be accessible for reading or updating via the
|
|
// other query (resulting in a OSStatus of -25300 indicating the queried
|
|
// item cannot be found). And worse, attempting to write the other query to
|
|
// the keychain won't work because the write will conflict with the
|
|
// previously written query (resulting in a OSStatus of -25299 indicating a
|
|
// duplicate item already exists in the keychain). This formed the basis
|
|
// for the issues this bug caused.
|
|
//
|
|
// The missing key was added back in 11.3, but adding back the key
|
|
// introduced a new issue. If the buggy version succeeded at writing an
|
|
// iCloud entry to the non-iCloud bucket (e.g. keychain was empty before
|
|
// iCloud entry was written), then all future non-iCloud writes would fail
|
|
// due to the mismatching `kSecAttrAccessible` flag and throw an
|
|
// unrecoverable error. To address this the below error handling is used to
|
|
// detect such cases, remove the "corrupt" iCloud entry stored by the buggy
|
|
// version in the non-iCloud bucket, and retry writing the current
|
|
// non-iCloud entry again.
|
|
do {
|
|
try keychainServices.setItem(archiver.encodedData, withQuery: query)
|
|
} catch let error as NSError {
|
|
guard shareAuthStateAcrossDevices == false,
|
|
error.localizedFailureReason == "SecItemAdd (-25299)" else {
|
|
// The error is not related to the 11.0 - 11.2 issue described above,
|
|
// and should be rethrown.
|
|
throw error
|
|
}
|
|
// We are trying to write a non-iCloud entry but a corrupt iCloud entry
|
|
// is likely preventing it from happening.
|
|
//
|
|
// The corrupt query was supposed to contain the following keys:
|
|
// {
|
|
// kSecAttrSynchronizable: true,
|
|
// kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
|
|
// }
|
|
// Instead, it contained:
|
|
// {
|
|
// kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlock
|
|
// }
|
|
//
|
|
// Excluding `kSecAttrSynchronizable` treats the query as if it's false
|
|
// and the entry won't be shared in iCloud across devices. It is instead
|
|
// written to the non-iCloud bucket. This query is corrupting the
|
|
// non-iCloud bucket because its `kSecAttrAccessible` value is not
|
|
// compatible with the value used for non-iCloud entries. To delete it,
|
|
// a compatible query is formed by swapping the accessibility flag
|
|
// out for `kSecAttrAccessibleAfterFirstUnlock`. This frees up the bucket
|
|
// so the non-iCloud entry can attempt to be written again.
|
|
let corruptQuery = query
|
|
.merging([kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock]) { $1 }
|
|
try keychainServices.removeItem(query: corruptQuery)
|
|
try keychainServices.setItem(archiver.encodedData, withQuery: query)
|
|
}
|
|
}
|
|
|
|
/// Remove the user that stored locally.
|
|
/// - Parameters:
|
|
/// - accessGroup: The access group to remove the user from.
|
|
/// - shareAuthStateAcrossDevices: If `true`, the keychain will be
|
|
/// synced across the end-user's iCloud.
|
|
/// - projectIdentifier: An identifier of the project that the user
|
|
/// associates with.
|
|
/// - Throws: An error if the operation failed.
|
|
func removeStoredUser(accessGroup: String,
|
|
shareAuthStateAcrossDevices: Bool,
|
|
projectIdentifier: String) throws {
|
|
var query = keychainQuery(
|
|
accessGroup: accessGroup,
|
|
shareAuthStateAcrossDevices: shareAuthStateAcrossDevices,
|
|
projectIdentifier: projectIdentifier
|
|
)
|
|
|
|
if shareAuthStateAcrossDevices {
|
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
|
} else {
|
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
|
}
|
|
|
|
try keychainServices.removeItem(query: query)
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
private func keychainQuery(accessGroup: String,
|
|
shareAuthStateAcrossDevices: Bool,
|
|
projectIdentifier: String) -> [String: Any] {
|
|
var query: [String: Any] = [
|
|
kSecClass as String: kSecClassGenericPassword,
|
|
kSecAttrAccessGroup as String: accessGroup,
|
|
kSecAttrService as String: projectIdentifier,
|
|
kSecAttrAccount as String: Self.sharedKeychainAccountValue,
|
|
]
|
|
query[kSecUseDataProtectionKeychain as String] = true
|
|
|
|
if shareAuthStateAcrossDevices {
|
|
query[kSecAttrSynchronizable as String] = true
|
|
}
|
|
|
|
return query
|
|
}
|
|
}
|