// Copyright 2024 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 /// Actor to serialize the update profile calls. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) actor UserProfileUpdate { func link(user: User, with credential: AuthCredential) async throws -> AuthDataResult { let accessToken = try await user.internalGetTokenAsync(backend: user.backend) let request = VerifyAssertionRequest(providerID: credential.provider, requestConfiguration: user.requestConfiguration) credential.prepare(request) request.accessToken = accessToken do { let response = try await user.backend.call(with: request) guard let idToken = response.idToken, let refreshToken = response.refreshToken, let providerID = response.providerID else { fatalError("Internal Auth Error: missing token in EmailLinkSignInResponse") } try await updateTokenAndRefreshUser(user: user, idToken: idToken, refreshToken: refreshToken, expirationDate: response.approximateExpirationDate) let updatedOAuthCredential = OAuthCredential(withVerifyAssertionResponse: response) let additionalUserInfo = AdditionalUserInfo(providerID: providerID, profile: response.profile, username: response.username, isNewUser: response.isNewUser) return AuthDataResult(withUser: user, additionalUserInfo: additionalUserInfo, credential: updatedOAuthCredential) } catch { user.signOutIfTokenIsInvalid(withError: error) throw error } } func unlink(user: User, fromProvider provider: String) async throws -> User { let accessToken = try await user.internalGetTokenAsync(backend: user.backend) let request = SetAccountInfoRequest( accessToken: accessToken, requestConfiguration: user.requestConfiguration ) if user.providerDataRaw[provider] == nil { throw AuthErrorUtils.noSuchProviderError() } request.deleteProviders = [provider] do { let response = try await user.backend.call(with: request) // We can't just use the provider info objects in SetAccountInfoResponse // because they don't have localID and email fields. Remove the specific // provider manually. user.providerDataRaw.removeValue(forKey: provider) if provider == EmailAuthProvider.id { user.hasEmailPasswordCredential = false } #if os(iOS) // After successfully unlinking a phone auth provider, remove the phone number // from the cached user info. if provider == PhoneAuthProvider.id { user.phoneNumber = nil } #endif if let idToken = response.idToken, let refreshToken = response.refreshToken { let tokenService = SecureTokenService( withRequestConfiguration: user.requestConfiguration, accessToken: idToken, accessTokenExpirationDate: response.approximateExpirationDate, refreshToken: refreshToken ) try await setTokenService(user: user, tokenService: tokenService) return user } } catch { user.signOutIfTokenIsInvalid(withError: error) throw error } if let error = user.updateKeychain() { throw error } return user } /// Performs a setAccountInfo request by mutating the results of a getAccountInfo response, /// atomically in regards to other calls to this method. /// - Parameter changeBlock: A block responsible for mutating a template `SetAccountInfoRequest` func executeUserUpdateWithChanges(user: User, changeBlock: @escaping (GetAccountInfoResponse.User, SetAccountInfoRequest) -> Void) async throws { let userAccountInfo = try await getAccountInfoRefreshingCache(user) let accessToken = try await user.internalGetTokenAsync(backend: user.backend) // Mutate setAccountInfoRequest in block let setAccountInfoRequest = SetAccountInfoRequest( accessToken: accessToken, requestConfiguration: user.requestConfiguration ) changeBlock(userAccountInfo, setAccountInfoRequest) do { let accountInfoResponse = try await user.backend.call(with: setAccountInfoRequest) if let idToken = accountInfoResponse.idToken, let refreshToken = accountInfoResponse.refreshToken { let tokenService = SecureTokenService( withRequestConfiguration: user.requestConfiguration, accessToken: idToken, accessTokenExpirationDate: accountInfoResponse.approximateExpirationDate, refreshToken: refreshToken ) try await setTokenService(user: user, tokenService: tokenService) } } catch { user.signOutIfTokenIsInvalid(withError: error) throw error } } // Update the new token and refresh user info again. func updateTokenAndRefreshUser(user: User, idToken: String, refreshToken: String, expirationDate: Date?) async throws { user.tokenService = SecureTokenService( withRequestConfiguration: user.requestConfiguration, accessToken: idToken, accessTokenExpirationDate: expirationDate, refreshToken: refreshToken ) let accessToken = try await user.internalGetTokenAsync(backend: user.backend) let getAccountInfoRequest = GetAccountInfoRequest( accessToken: accessToken, requestConfiguration: user.requestConfiguration ) do { let response = try await user.backend.call(with: getAccountInfoRequest) user.isAnonymous = false user.update(withGetAccountInfoResponse: response) } catch { user.signOutIfTokenIsInvalid(withError: error) throw error } if let error = user.updateKeychain() { throw error } } /// Sets a new token service for the `User` instance. /// /// The method makes sure the token service has access and refresh token and the new tokens /// are saved in the keychain before calling back. /// - Parameter tokenService: The new token service object. /// - Parameter callback: The block to be called in the global auth working queue once finished. func setTokenService(user: User, tokenService: SecureTokenService) async throws { _ = try await tokenService.fetchAccessToken(forcingRefresh: false, backend: user.backend) user.tokenService = tokenService if let error = user.updateKeychain() { throw error } } /// Gets the users' account data from the server, updating our local values. /// - Parameter callback: Invoked when the request to getAccountInfo has completed, or when an /// error has been detected. Invoked asynchronously on the auth global work queue in the future. func getAccountInfoRefreshingCache(_ user: User) async throws -> GetAccountInfoResponse.User { let token = try await user.internalGetTokenAsync(backend: user.backend) let request = GetAccountInfoRequest(accessToken: token, requestConfiguration: user.requestConfiguration) do { let accountInfoResponse = try await user.backend.call(with: request) user.update(withGetAccountInfoResponse: accountInfoResponse) if let error = user.updateKeychain() { throw error } return (accountInfoResponse.users?.first)! } catch { user.signOutIfTokenIsInvalid(withError: error) throw error } } }