diff --git a/FirebaseAuth/CHANGELOG.md b/FirebaseAuth/CHANGELOG.md index 253206a553e..e781526007c 100644 --- a/FirebaseAuth/CHANGELOG.md +++ b/FirebaseAuth/CHANGELOG.md @@ -1,3 +1,7 @@ +# 11.2.0 +- [Fixed] Fixed crashes that could occur in Swift continuation blocks running in the Xcode 16 + betas. (#13480) + # 11.1.0 - [fixed] Fixed `Swift.error` conformance for `AuthErrorCode`. (#13430) - [added] Added custom provider support to `AuthProviderID`. Note that this change will be breaking diff --git a/FirebaseAuth/Docs/threading.md b/FirebaseAuth/Docs/threading.md index 9c48a74d1e3..2050e0579f6 100644 --- a/FirebaseAuth/Docs/threading.md +++ b/FirebaseAuth/Docs/threading.md @@ -22,10 +22,7 @@ has its target queue set to this auth global work queue. This way we don't have to think about which variables may be contested. We only need to make sure all public APIs that may have thread-safety issues make the dispatch. The auth global work queue is defined in -[FIRAuthGlobalWorkQueue.h](../Source/Private/FIRAuthGlobalWorkQueue.h) -and any serial task queue created by -[FIRAuthSerialTaskQueue.h](../Source/Private/FIRAuthSerialTaskQueue.h) -already has its target set properly. +[FIRAuthGlobalWorkQueue.h](../Source/Private/FIRAuthGlobalWorkQueue.h). In following sub-sections, we divided methods into three categories, according to the two criteria below: diff --git a/FirebaseAuth/Sources/Swift/Auth/AuthSerialTaskQueue.swift b/FirebaseAuth/Sources/Swift/Auth/AuthSerialTaskQueue.swift deleted file mode 100644 index 4d7e04234d9..00000000000 --- a/FirebaseAuth/Sources/Swift/Auth/AuthSerialTaskQueue.swift +++ /dev/null @@ -1,39 +0,0 @@ -// 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 - -typealias AuthSerialTaskCompletionBlock = () -> Void - -class AuthSerialTaskQueue: NSObject { - private let dispatchQueue: DispatchQueue - - override init() { - dispatchQueue = DispatchQueue( - label: "com.google.firebase.auth.serialTaskQueue", - target: kAuthGlobalWorkQueue - ) - super.init() - } - - func enqueueTask(_ task: @escaping ((_ complete: @escaping AuthSerialTaskCompletionBlock) - -> Void)) { - dispatchQueue.async { - self.dispatchQueue.suspend() - task { - self.dispatchQueue.resume() - } - } - } -} diff --git a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift index 61a78271347..8dc2349c308 100644 --- a/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift +++ b/FirebaseAuth/Sources/Swift/AuthProvider/PhoneAuthProvider.swift @@ -201,7 +201,7 @@ import Foundation } /// Starts the flow to verify the client via silent push notification. - /// - Parameter retryOnInvalidAppCredential: Whether of not the flow should be retried if an + /// - Parameter retryOnInvalidAppCredential: Whether or not the flow should be retried if an /// AuthErrorCodeInvalidAppCredential error is returned from the backend. /// - Parameter phoneNumber: The phone number to be verified. /// - Parameter callback: The callback to be invoked on the global work queue when the flow is diff --git a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift index 2b09fd1b3cf..5a5e6d0664a 100644 --- a/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift +++ b/FirebaseAuth/Sources/Swift/Backend/AuthBackend.swift @@ -31,8 +31,7 @@ protocol AuthBackendRPCIssuer: NSObjectProtocol { /// on the auth global work queue in the future. func asyncCallToURL(with request: T, body: Data?, - contentType: String, - completionHandler: @escaping ((Data?, Error?) -> Void)) + contentType: String) async -> (Data?, Error?) } @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @@ -51,20 +50,22 @@ class AuthBackendRPCIssuerImplementation: NSObject, AuthBackendRPCIssuer { func asyncCallToURL(with request: T, body: Data?, - contentType: String, - completionHandler: @escaping ((Data?, Error?) - -> Void)) { + contentType: String) async -> (Data?, Error?) { let requestConfiguration = request.requestConfiguration() - AuthBackend.request(withURL: request.requestURL(), - contentType: contentType, - requestConfiguration: requestConfiguration) { request in - let fetcher = self.fetcherService.fetcher(with: request) - if let _ = requestConfiguration.emulatorHostAndPort { - fetcher.allowLocalhostRequest = true - fetcher.allowedInsecureSchemes = ["http"] + let request = await AuthBackend.request(withURL: request.requestURL(), + contentType: contentType, + requestConfiguration: requestConfiguration) + let fetcher = fetcherService.fetcher(with: request) + if let _ = requestConfiguration.emulatorHostAndPort { + fetcher.allowLocalhostRequest = true + fetcher.allowedInsecureSchemes = ["http"] + } + fetcher.bodyData = body + + return await withUnsafeContinuation { continuation in + fetcher.beginFetch { data, error in + continuation.resume(returning: (data, error)) } - fetcher.bodyData = body - fetcher.beginFetch(completionHandler: completionHandler) } } } @@ -98,8 +99,7 @@ class AuthBackend: NSObject { class func request(withURL url: URL, contentType: String, - requestConfiguration: AuthRequestConfiguration, - completion: @escaping (URLRequest) -> Void) { + requestConfiguration: AuthRequestConfiguration) async -> URLRequest { var request = URLRequest(url: url) request.setValue(contentType, forHTTPHeaderField: "Content-Type") let additionalFrameworkMarker = requestConfiguration @@ -121,18 +121,15 @@ class AuthBackend: NSObject { request.setValue(languageCode, forHTTPHeaderField: "X-Firebase-Locale") } if let appCheck = requestConfiguration.appCheck { - appCheck.getToken(forcingRefresh: false) { tokenResult in - if let error = tokenResult.error { - AuthLog.logWarning(code: "I-AUT000018", - message: "Error getting App Check token; using placeholder " + - "token instead. Error: \(error)") - } - request.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck") - completion(request) + let tokenResult = await appCheck.getToken(forcingRefresh: false) + if let error = tokenResult.error { + AuthLog.logWarning(code: "I-AUT000018", + message: "Error getting App Check token; using placeholder " + + "token instead. Error: \(error)") } - } else { - completion(request) + request.setValue(tokenResult.token, forHTTPHeaderField: "X-Firebase-AppCheck") } + return request } } @@ -270,135 +267,104 @@ private class AuthBackendRPCImplementation: NSObject, AuthBackendImplementation throw AuthErrorUtils.JSONSerializationErrorForUnencodableType() } } - return try await withCheckedThrowingContinuation { continuation in - rpcIssuer - .asyncCallToURL(with: request, body: bodyData, contentType: "application/json") { - data, error in - // If there is an error with no body data at all, then this must be a - // network error. - guard let data = data else { - if let error = error { - continuation.resume(throwing: AuthErrorUtils.networkError(underlyingError: error)) - return - } else { - // TODO: this was ignored before - fatalError("Auth Internal error: RPC call didn't return data or an error.") - } - } - // Try to decode the HTTP response data which may contain either a - // successful response or error message. - var dictionary: [String: AnyHashable] - do { - let rawDecode = try JSONSerialization.jsonObject(with: data, - options: JSONSerialization - .ReadingOptions - .mutableLeaves) - guard let decodedDictionary = rawDecode as? [String: AnyHashable] else { - if error != nil { - continuation.resume( - throwing: AuthErrorUtils.unexpectedErrorResponse(deserializedResponse: rawDecode, - underlyingError: error) - ) - return - } else { - continuation.resume( - throwing: AuthErrorUtils.unexpectedResponse(deserializedResponse: rawDecode) - ) - return - } - } - dictionary = decodedDictionary - } catch let jsonError { - if error != nil { - // We have an error, but we couldn't decode the body, so we have no - // additional information other than the raw response and the - // original NSError (the jsonError is inferred by the error code - // (AuthErrorCodeUnexpectedHTTPResponse, and is irrelevant.) - continuation.resume( - throwing: AuthErrorUtils.unexpectedErrorResponse( - data: data, - underlyingError: error - ) - ) - return - } else { - // This is supposed to be a "successful" response, but we couldn't - // deserialize the body. - continuation.resume( - throwing: AuthErrorUtils.unexpectedResponse(data: data, underlyingError: jsonError) - ) - return - } - } + let (data, error) = await rpcIssuer + .asyncCallToURL(with: request, body: bodyData, contentType: "application/json") + // If there is an error with no body data at all, then this must be a + // network error. + guard let data = data else { + if let error = error { + throw AuthErrorUtils.networkError(underlyingError: error) + } else { + // TODO: this was ignored before + fatalError("Auth Internal error: RPC call didn't return data or an error.") + } + } + // Try to decode the HTTP response data which may contain either a + // successful response or error message. + var dictionary: [String: AnyHashable] + var rawDecode: Any + do { + rawDecode = try JSONSerialization.jsonObject( + with: data, options: JSONSerialization.ReadingOptions.mutableLeaves + ) + } catch let jsonError { + if error != nil { + // We have an error, but we couldn't decode the body, so we have no + // additional information other than the raw response and the + // original NSError (the jsonError is inferred by the error code + // (AuthErrorCodeUnexpectedHTTPResponse, and is irrelevant.) + throw AuthErrorUtils.unexpectedErrorResponse(data: data, underlyingError: error) + } else { + // This is supposed to be a "successful" response, but we couldn't + // deserialize the body. + throw AuthErrorUtils.unexpectedResponse(data: data, underlyingError: jsonError) + } + } + guard let decodedDictionary = rawDecode as? [String: AnyHashable] else { + if error != nil { + throw AuthErrorUtils.unexpectedErrorResponse(deserializedResponse: rawDecode, + underlyingError: error) + } else { + throw AuthErrorUtils.unexpectedResponse(deserializedResponse: rawDecode) + } + } + dictionary = decodedDictionary - let response = T.Response() + let response = T.Response() - // At this point we either have an error with successfully decoded - // details in the body, or we have a response which must pass further - // validation before we know it's truly successful. We deal with the - // case where we have an error with successfully decoded error details - // first: - if error != nil { - if let errorDictionary = dictionary["error"] as? [String: AnyHashable] { - if let errorMessage = errorDictionary["message"] as? String { - if let clientError = AuthBackendRPCImplementation.clientError( - withServerErrorMessage: errorMessage, - errorDictionary: errorDictionary, - response: response, - error: error - ) { - continuation.resume(throwing: clientError) - return - } - } - // Not a message we know, return the message directly. - continuation.resume( - throwing: AuthErrorUtils.unexpectedErrorResponse( - deserializedResponse: errorDictionary, - underlyingError: error - ) - ) - return - } - // No error message at all, return the decoded response. - continuation.resume( - throwing: AuthErrorUtils - .unexpectedErrorResponse(deserializedResponse: dictionary, underlyingError: error) - ) - return + // At this point we either have an error with successfully decoded + // details in the body, or we have a response which must pass further + // validation before we know it's truly successful. We deal with the + // case where we have an error with successfully decoded error details + // first: + if error != nil { + if let errorDictionary = dictionary["error"] as? [String: AnyHashable] { + if let errorMessage = errorDictionary["message"] as? String { + if let clientError = AuthBackendRPCImplementation.clientError( + withServerErrorMessage: errorMessage, + errorDictionary: errorDictionary, + response: response, + error: error + ) { + throw clientError } + } + // Not a message we know, return the message directly. + throw AuthErrorUtils.unexpectedErrorResponse( + deserializedResponse: errorDictionary, + underlyingError: error + ) + } + // No error message at all, return the decoded response. + throw AuthErrorUtils + .unexpectedErrorResponse(deserializedResponse: dictionary, underlyingError: error) + } - // Finally, we try to populate the response object with the JSON values. - do { - try response.setFields(dictionary: dictionary) - } catch { - continuation.resume( - throwing: AuthErrorUtils - .RPCResponseDecodingError(deserializedResponse: dictionary, underlyingError: error) - ) - return - } - // In case returnIDPCredential of a verifyAssertion request is set to - // @YES, the server may return a 200 with a response that may contain a - // server error. - if let verifyAssertionRequest = request as? VerifyAssertionRequest { - if verifyAssertionRequest.returnIDPCredential { - if let errorMessage = dictionary["errorMessage"] as? String { - if let clientError = AuthBackendRPCImplementation.clientError( - withServerErrorMessage: errorMessage, - errorDictionary: dictionary, - response: response, - error: error - ) { - continuation.resume(throwing: clientError) - return - } - } - } + // Finally, we try to populate the response object with the JSON values. + do { + try response.setFields(dictionary: dictionary) + } catch { + throw AuthErrorUtils + .RPCResponseDecodingError(deserializedResponse: dictionary, underlyingError: error) + } + // In case returnIDPCredential of a verifyAssertion request is set to + // @YES, the server may return a 200 with a response that may contain a + // server error. + if let verifyAssertionRequest = request as? VerifyAssertionRequest { + if verifyAssertionRequest.returnIDPCredential { + if let errorMessage = dictionary["errorMessage"] as? String { + if let clientError = AuthBackendRPCImplementation.clientError( + withServerErrorMessage: errorMessage, + errorDictionary: dictionary, + response: response, + error: error + ) { + throw clientError } - continuation.resume(returning: response) } + } } + return response } private class func clientError(withServerErrorMessage serverErrorMessage: String, diff --git a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift index e31f2046f1d..e06ef8eaf0f 100644 --- a/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift +++ b/FirebaseAuth/Sources/Swift/SystemService/SecureTokenService.swift @@ -16,10 +16,102 @@ import Foundation private let kFiveMinutes = 5 * 60.0 +@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) +actor SecureTokenServiceInternal { + /// Fetch a fresh ephemeral access token for the ID associated with this instance. The token + /// received in the callback should be considered short lived and not cached. + /// + /// Invoked asynchronously on the auth global work queue in the future. + /// - Parameter forceRefresh: Forces the token to be refreshed. + /// - Returns : A tuple with the token and flag of whether it was updated. + func fetchAccessToken(forcingRefresh forceRefresh: Bool, + service: SecureTokenService) async throws -> (String?, Bool) { + if !forceRefresh, hasValidAccessToken(service: service) { + return (service.accessToken, false) + } else { + AuthLog.logDebug(code: "I-AUT000017", message: "Fetching new token from backend.") + return try await requestAccessToken(retryIfExpired: true, service: service) + } + } + + /// Makes a request to STS for an access token. + /// + /// This handles both the case that the token has not been granted yet and that it just needs + /// needs to be refreshed. + /// + /// - Returns: Token and Bool indicating if update occurred. + private func requestAccessToken(retryIfExpired: Bool, + service: SecureTokenService) async throws -> (String?, Bool) { + // TODO: This was a crash in ObjC SDK, should it callback with an error? + guard let refreshToken = service.refreshToken, + let requestConfiguration = service.requestConfiguration else { + fatalError("refreshToken and requestConfiguration should not be nil") + } + + let request = SecureTokenRequest.refreshRequest(refreshToken: refreshToken, + requestConfiguration: requestConfiguration) + let response = try await AuthBackend.call(with: request) + var tokenUpdated = false + if let newAccessToken = response.accessToken, + newAccessToken.count > 0, + newAccessToken != service.accessToken { + if let tokenResult = try? AuthTokenResult.tokenResult(token: newAccessToken) { + // There is an edge case where the request for a new access token may be made right + // before the app goes inactive, resulting in the callback being invoked much later + // with an expired access token. This does not fully solve the issue, as if the + // callback is invoked less than an hour after the request is made, a token is not + // re-requested here but the approximateExpirationDate will still be off since that + // is computed at the time the token is received. + if retryIfExpired { + let expirationDate = tokenResult.expirationDate + if expirationDate.timeIntervalSinceNow <= kFiveMinutes { + // We only retry once, to avoid an infinite loop in the case that an end-user has + // their local time skewed by over an hour. + return try await requestAccessToken(retryIfExpired: false, service: service) + } + } + } + service.accessToken = newAccessToken + service.accessTokenExpirationDate = response.approximateExpirationDate + tokenUpdated = true + AuthLog.logDebug( + code: "I-AUT000017", + message: "Updated access token. Estimated expiration date: " + + "\(String(describing: service.accessTokenExpirationDate)), current date: \(Date())" + ) + } + if let newRefreshToken = response.refreshToken, + newRefreshToken != service.refreshToken { + service.refreshToken = newRefreshToken + tokenUpdated = true + } + return (response.accessToken, tokenUpdated) + } + + private func hasValidAccessToken(service: SecureTokenService) -> Bool { + if let accessTokenExpirationDate = service.accessTokenExpirationDate, + accessTokenExpirationDate.timeIntervalSinceNow > kFiveMinutes { + AuthLog.logDebug(code: "I-AUT000017", + message: "Has valid access token. Estimated expiration date:" + + "\(accessTokenExpirationDate), current date: \(Date())") + return true + } + AuthLog.logDebug( + code: "I-AUT000017", + message: "Does not have valid access token. Estimated expiration date:" + + "\(String(describing: service.accessTokenExpirationDate)), current date: \(Date())" + ) + return false + } +} + /// A class represents a credential that proves the identity of the app. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @objc(FIRSecureTokenService) // objc Needed for decoding old versions class SecureTokenService: NSObject, NSSecureCoding { + /// Internal actor to enforce serialization + private let internalService: SecureTokenServiceInternal + /// The configuration for making requests to server. var requestConfiguration: AuthRequestConfiguration? @@ -47,11 +139,11 @@ class SecureTokenService: NSObject, NSSecureCoding { accessToken: String, accessTokenExpirationDate: Date?, refreshToken: String) { + internalService = SecureTokenServiceInternal() self.requestConfiguration = requestConfiguration self.accessToken = accessToken - self.refreshToken = refreshToken self.accessTokenExpirationDate = accessTokenExpirationDate - taskQueue = AuthSerialTaskQueue() + self.refreshToken = refreshToken } /// Fetch a fresh ephemeral access token for the ID associated with this instance. The token @@ -59,32 +151,11 @@ class SecureTokenService: NSObject, NSSecureCoding { /// /// Invoked asynchronously on the auth global work queue in the future. /// - Parameter forceRefresh: Forces the token to be refreshed. - /// - Parameter callback: Callback block that will be called to return either the token or an - /// error. - func fetchAccessToken(forcingRefresh forceRefresh: Bool, - callback: @escaping (String?, Error?, Bool) -> Void) { - taskQueue.enqueueTask { complete in - if !forceRefresh, self.hasValidAccessToken() { - complete() - callback(self.accessToken, nil, false) - } else { - AuthLog.logDebug(code: "I-AUT000017", message: "Fetching new token from backend.") - Task { - do { - let (token, tokenUpdated) = try await self.requestAccessToken(retryIfExpired: true) - complete() - callback(token, nil, tokenUpdated) - } catch { - complete() - callback(nil, error, false) - } - } - } - } + /// - Returns : A tuple with the token and flag of whether it was updated. + func fetchAccessToken(forcingRefresh forceRefresh: Bool) async throws -> (String?, Bool) { + return try await internalService.fetchAccessToken(forcingRefresh: forceRefresh, service: self) } - private let taskQueue: AuthSerialTaskQueue - // MARK: NSSecureCoding // Secure coding keys @@ -126,77 +197,4 @@ class SecureTokenService: NSObject, NSSecureCoding { forKey: SecureTokenService.kAccessTokenExpirationDateKey ) } - - // MARK: Private methods - - /// Makes a request to STS for an access token. - /// - /// This handles both the case that the token has not been granted yet and that it just - /// needs to be refreshed. The caller is responsible for making sure that this is occurring in - /// a `_taskQueue` task. - /// - /// Because this method is guaranteed to only be called from tasks enqueued in - /// `_taskQueue`, we do not need any @synchronized guards around access to _accessToken/etc. - /// since only one of those tasks is ever running at a time, and those tasks are the only - /// access to and mutation of these instance variables. - /// - Returns: Token and Bool indicating if update occurred. - private func requestAccessToken(retryIfExpired: Bool) async throws -> (String?, Bool) { - // TODO: This was a crash in ObjC SDK, should it callback with an error? - guard let refreshToken, let requestConfiguration else { - fatalError("refreshToken and requestConfiguration should not be nil") - } - - let request = SecureTokenRequest.refreshRequest(refreshToken: refreshToken, - requestConfiguration: requestConfiguration) - let response = try await AuthBackend.call(with: request) - var tokenUpdated = false - if let newAccessToken = response.accessToken, - newAccessToken.count > 0, - newAccessToken != accessToken { - if let tokenResult = try? AuthTokenResult.tokenResult(token: newAccessToken) { - // There is an edge case where the request for a new access token may be made right - // before the app goes inactive, resulting in the callback being invoked much later - // with an expired access token. This does not fully solve the issue, as if the - // callback is invoked less than an hour after the request is made, a token is not - // re-requested here but the approximateExpirationDate will still be off since that - // is computed at the time the token is received. - if retryIfExpired { - let expirationDate = tokenResult.expirationDate - if expirationDate.timeIntervalSinceNow <= kFiveMinutes { - // We only retry once, to avoid an infinite loop in the case that an end-user has - // their local time skewed by over an hour. - return try await requestAccessToken(retryIfExpired: false) - } - } - } - accessToken = newAccessToken - accessTokenExpirationDate = response.approximateExpirationDate - tokenUpdated = true - AuthLog.logDebug( - code: "I-AUT000017", - message: "Updated access token. Estimated expiration date: " + - "\(String(describing: accessTokenExpirationDate)), current date: \(Date())" - ) - } - if let newRefreshToken = response.refreshToken, - newRefreshToken != self.refreshToken { - self.refreshToken = newRefreshToken - tokenUpdated = true - } - return (response.accessToken, tokenUpdated) - } - - private func hasValidAccessToken() -> Bool { - if let accessTokenExpirationDate, - accessTokenExpirationDate.timeIntervalSinceNow > kFiveMinutes { - AuthLog.logDebug(code: "I-AUT000017", - message: "Has valid access token. Estimated expiration date:" + - "\(accessTokenExpirationDate), current date: \(Date())") - return true - } - AuthLog.logDebug(code: "I-AUT000017", - message: "Does not have valid access token. Estimated expiration date:" + - "\(String(describing: accessTokenExpirationDate)), current date: \(Date())") - return false - } } diff --git a/FirebaseAuth/Sources/Swift/User/User.swift b/FirebaseAuth/Sources/Swift/User/User.swift index d534186401b..e063a658230 100644 --- a/FirebaseAuth/Sources/Swift/User/User.swift +++ b/FirebaseAuth/Sources/Swift/User/User.swift @@ -29,7 +29,7 @@ extension User: NSSecureCoding {} @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) @objc(FIRUser) open class User: NSObject, UserInfo { /// Indicates the user represents an anonymous user. - @objc public private(set) var isAnonymous: Bool + @objc public internal(set) var isAnonymous: Bool /// Indicates the user represents an anonymous user. @objc open func anonymous() -> Bool { return isAnonymous } @@ -47,7 +47,7 @@ extension User: NSSecureCoding {} return Array(providerDataRaw.values) } - private var providerDataRaw: [String: UserInfoImpl] + var providerDataRaw: [String: UserInfoImpl] /// Metadata associated with the Firebase user in question. @objc public private(set) var metadata: UserMetadata @@ -376,7 +376,13 @@ extension User: NSSecureCoding {} return } // Successful reauthenticate - self.setTokenService(tokenService: user.tokenService) { error in + do { + try await self.userProfileUpdate.setTokenService(user: self, + tokenService: user.tokenService) + User.callInMainThreadWithAuthDataResultAndError(callback: completion, + result: authResult, + error: nil) + } catch { User.callInMainThreadWithAuthDataResultAndError(callback: completion, result: authResult, error: error) @@ -678,51 +684,15 @@ extension User: NSSecureCoding {} } #endif - self.taskQueue.enqueueTask { complete in - let completeWithError = { result, error in - complete() - User.callInMainThreadWithAuthDataResultAndError(callback: completion, result: result, - error: error) - } - self.internalGetToken { accessToken, error in - if let error { - completeWithError(nil, error) - return - } - guard let requestConfiguration = self.auth?.requestConfiguration else { - fatalError("Internal Error: Unexpected nil requestConfiguration.") + Task { + do { + let authDataResult = try await self.userProfileUpdate.link(user: self, with: credential) + await MainActor.run { + completion?(authDataResult, nil) } - let request = VerifyAssertionRequest(providerID: credential.provider, - requestConfiguration: requestConfiguration) - credential.prepare(request) - request.accessToken = accessToken - Task { - do { - let response = try await AuthBackend.call(with: request) - guard let idToken = response.idToken, - let refreshToken = response.refreshToken, - let providerID = response.providerID else { - fatalError("Internal Auth Error: missing token in VerifyAssertionResponse") - } - let additionalUserInfo = AdditionalUserInfo(providerID: providerID, - profile: response.profile, - username: response.username, - isNewUser: response.isNewUser) - let updatedOAuthCredential = OAuthCredential(withVerifyAssertionResponse: response) - try await self.updateTokenAndRefreshUser( - idToken: idToken, - refreshToken: refreshToken, - expirationDate: response.approximateExpirationDate, - requestConfiguration: requestConfiguration - ) - let result = AuthDataResult(withUser: self, additionalUserInfo: additionalUserInfo, - credential: updatedOAuthCredential) - completeWithError(result, nil) - } catch { - self.signOutIfTokenIsInvalid(withError: error) - completeWithError(nil, error) - return - } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -831,67 +801,15 @@ extension User: NSSecureCoding {} /// fails. @objc open func unlink(fromProvider provider: String, completion: ((User?, Error?) -> Void)? = nil) { - taskQueue.enqueueTask { complete in - let completeAndCallbackWithError = { error in - complete() - User.callInMainThreadWithUserAndError(callback: completion, user: self, - error: error) - } - self.internalGetToken { accessToken, error in - if let error { - completeAndCallbackWithError(error) - return + Task { + do { + let user = try await unlink(fromProvider: provider) + await MainActor.run { + completion?(user, nil) } - guard let requestConfiguration = self.auth?.requestConfiguration else { - fatalError("Internal Error: Unexpected nil requestConfiguration.") - } - let request = SetAccountInfoRequest(requestConfiguration: requestConfiguration) - request.accessToken = accessToken - - if self.providerDataRaw[provider] == nil { - completeAndCallbackWithError(AuthErrorUtils.noSuchProviderError()) - return - } - request.deleteProviders = [provider] - Task { - do { - let response = try await AuthBackend.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. - self.providerDataRaw.removeValue(forKey: provider) - if provider == EmailAuthProvider.id { - self.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 { - self.phoneNumber = nil - } - #endif - if let idToken = response.idToken, - let refreshToken = response.refreshToken { - let tokenService = SecureTokenService(withRequestConfiguration: requestConfiguration, - accessToken: idToken, - accessTokenExpirationDate: response - .approximateExpirationDate, - refreshToken: refreshToken) - self.setTokenService(tokenService: tokenService) { error in - completeAndCallbackWithError(error) - } - return - } - if let error = self.updateKeychain() { - completeAndCallbackWithError(error) - return - } - completeAndCallbackWithError(nil) - } catch { - self.signOutIfTokenIsInvalid(withError: error) - completeAndCallbackWithError(error) - return - } + } catch { + await MainActor.run { + completion?(nil, error) } } } @@ -912,15 +830,7 @@ extension User: NSSecureCoding {} /// - Returns: The user. @available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) open func unlink(fromProvider provider: String) async throws -> User { - return try await withCheckedThrowingContinuation { continuation in - self.unlink(fromProvider: provider) { result, error in - if let result { - continuation.resume(returning: result) - } else if let error { - continuation.resume(throwing: error) - } - } - } + return try await userProfileUpdate.unlink(user: self, fromProvider: provider) } /// Initiates email verification for the user. @@ -1148,7 +1058,7 @@ extension User: NSSecureCoding {} init(withTokenService tokenService: SecureTokenService) { providerDataRaw = [:] - taskQueue = AuthSerialTaskQueue() + userProfileUpdate = UserProfileUpdate() self.tokenService = tokenService isAnonymous = false isEmailVerified = false @@ -1212,10 +1122,10 @@ extension User: NSSecureCoding {} @objc open var phoneNumber: String? /// Whether or not the user can be authenticated by using Firebase email and password. - private var hasEmailPasswordCredential: Bool + var hasEmailPasswordCredential: Bool /// Used to serialize the update profile calls. - private var taskQueue: AuthSerialTaskQueue + private let userProfileUpdate: UserProfileUpdate /// A strong reference to a requestConfiguration instance associated with this user instance. var requestConfiguration: AuthRequestConfiguration @@ -1322,115 +1232,41 @@ extension User: NSSecureCoding {} func executeUserUpdateWithChanges(changeBlock: @escaping (GetAccountInfoResponseUser, SetAccountInfoRequest) -> Void, callback: @escaping (Error?) -> Void) { - taskQueue.enqueueTask { complete in - self.getAccountInfoRefreshingCache { user, error in - if let error { - complete() - callback(error) - return + Task { + do { + try await userProfileUpdate.executeUserUpdateWithChanges(user: self, + changeBlock: changeBlock) + await MainActor.run { + callback(nil) } - guard let user else { - fatalError("Internal error: Both user and error are nil") - } - Task { - do { - let accessToken = try await self.internalGetTokenAsync() - if let configuration = self.auth?.requestConfiguration { - // Mutate setAccountInfoRequest in block - let setAccountInfoRequest = SetAccountInfoRequest(requestConfiguration: configuration) - setAccountInfoRequest.accessToken = accessToken - changeBlock(user, setAccountInfoRequest) - do { - let accountInfoResponse = try await AuthBackend.call(with: setAccountInfoRequest) - if let idToken = accountInfoResponse.idToken, - let refreshToken = accountInfoResponse.refreshToken { - let tokenService = SecureTokenService( - withRequestConfiguration: configuration, - accessToken: idToken, - accessTokenExpirationDate: accountInfoResponse.approximateExpirationDate, - refreshToken: refreshToken - ) - self.setTokenService(tokenService: tokenService) { error in - complete() - callback(error) - } - return - } - complete() - callback(nil) - } catch { - self.signOutIfTokenIsInvalid(withError: error) - complete() - callback(error) - } - } - } catch { - complete() - callback(error) - } + } catch { + await MainActor.run { + callback(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. - private func setTokenService(tokenService: SecureTokenService, - callback: @escaping (Error?) -> Void) { - tokenService.fetchAccessToken(forcingRefresh: false) { token, error, tokenUpdated in - if let error { - callback(error) - return - } - self.tokenService = tokenService - if let error = self.updateKeychain() { - callback(error) - return - } - callback(nil) - } - } - /// 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. - private func getAccountInfoRefreshingCache(callback: @escaping (GetAccountInfoResponseUser?, - Error?) -> Void) { - internalGetToken { token, error in - if let error { - callback(nil, error) - return - } - guard let token else { - fatalError("Internal Error: Both error and token are nil.") - } - guard let requestConfiguration = self.auth?.requestConfiguration else { - fatalError("Internal Error: Unexpected nil requestConfiguration.") - } - let request = GetAccountInfoRequest(accessToken: token, - requestConfiguration: requestConfiguration) - Task { - do { - let accountInfoResponse = try await AuthBackend.call(with: request) - self.update(withGetAccountInfoResponse: accountInfoResponse) - if let error = self.updateKeychain() { - callback(nil, error) - return - } - callback(accountInfoResponse.users?.first, nil) - } catch { - self.signOutIfTokenIsInvalid(withError: error) + func getAccountInfoRefreshingCache(callback: @escaping (GetAccountInfoResponseUser?, + Error?) -> Void) { + Task { + do { + let responseUser = try await userProfileUpdate.getAccountInfoRefreshingCache(self) + await MainActor.run { + callback(responseUser, nil) + } + } catch { + await MainActor.run { callback(nil, error) } } } } - private func update(withGetAccountInfoResponse response: GetAccountInfoResponse) { + func update(withGetAccountInfoResponse response: GetAccountInfoResponse) { guard let user = response.users?.first else { // Silent fallthrough in ObjC code. AuthLog.logWarning(code: "I-AUT000016", message: "Missing user in GetAccountInfoResponse") @@ -1581,7 +1417,7 @@ extension User: NSSecureCoding {} } catch { self.signOutIfTokenIsInvalid(withError: error) User.callInMainThreadWithAuthDataResultAndError(callback: completion, - complete: nil, result: nil, error: error) + result: nil, error: error) } } } @@ -1723,31 +1559,18 @@ extension User: NSSecureCoding {} refreshToken: String, expirationDate: Date?, requestConfiguration: AuthRequestConfiguration) async throws { - tokenService = SecureTokenService( - withRequestConfiguration: requestConfiguration, - accessToken: idToken, - accessTokenExpirationDate: expirationDate, - refreshToken: refreshToken - ) - let accessToken = try await internalGetTokenAsync() - let getAccountInfoRequest = GetAccountInfoRequest(accessToken: accessToken, - requestConfiguration: requestConfiguration) - do { - let response = try await AuthBackend.call(with: getAccountInfoRequest) - isAnonymous = false - update(withGetAccountInfoResponse: response) - } catch { - signOutIfTokenIsInvalid(withError: error) - throw error - } - if let error = updateKeychain() { - throw error - } + return try await userProfileUpdate + .updateTokenAndRefreshUser( + user: self, + idToken: idToken, + refreshToken: refreshToken, + expirationDate: expirationDate + ) } /// Signs out this user if the user or the token is invalid. /// - Parameter error: The error from the server. - private func signOutIfTokenIsInvalid(withError error: Error) { + func signOutIfTokenIsInvalid(withError error: Error) { let code = (error as NSError).code if code == AuthErrorCode.userNotFound.rawValue || code == AuthErrorCode.userDisabled.rawValue || @@ -1764,31 +1587,32 @@ extension User: NSSecureCoding {} /// on the global work thread in the future. func internalGetToken(forceRefresh: Bool = false, callback: @escaping (String?, Error?) -> Void) { - tokenService.fetchAccessToken(forcingRefresh: forceRefresh) { token, error, tokenUpdated in - if let error { - self.signOutIfTokenIsInvalid(withError: error) + Task { + do { + let token = try await internalGetTokenAsync(forceRefresh: forceRefresh) + callback(token, nil) + } catch { callback(nil, error) - return - } - if tokenUpdated { - if let error = self.updateKeychain() { - callback(nil, error) - return - } } - callback(token, nil) } } + /// Retrieves the Firebase authentication token, possibly refreshing it if it has expired. + /// - Parameter forceRefresh func internalGetTokenAsync(forceRefresh: Bool = false) async throws -> String { - return try await withCheckedThrowingContinuation { continuation in - self.internalGetToken(forceRefresh: forceRefresh) { token, error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume(returning: token!) + do { + let (token, tokenUpdated) = try await tokenService.fetchAccessToken( + forcingRefresh: forceRefresh + ) + if tokenUpdated { + if let error = updateKeychain() { + throw error } } + return token! + } catch { + signOutIfTokenIsInvalid(withError: error) + throw error } } @@ -1829,14 +1653,10 @@ extension User: NSSecureCoding {} private class func callInMainThreadWithAuthDataResultAndError(callback: ( (AuthDataResult?, Error?) -> Void )?, - complete: AuthSerialTaskCompletionBlock? = nil, result: AuthDataResult? = nil, error: Error? = nil) { if let callback { DispatchQueue.main.async { - if let complete { - complete() - } callback(result, error) } } @@ -1932,7 +1752,7 @@ extension User: NSSecureCoding {} self.tenantID = tenantID // The `heartbeatLogger` and `appCheck` will be set later via a property update. requestConfiguration = AuthRequestConfiguration(apiKey: apiKey, appID: appID) - taskQueue = AuthSerialTaskQueue() + userProfileUpdate = UserProfileUpdate() #if os(iOS) self.multiFactor = multiFactor ?? MultiFactor() super.init() diff --git a/FirebaseAuth/Sources/Swift/User/UserProfileUpdate.swift b/FirebaseAuth/Sources/Swift/User/UserProfileUpdate.swift new file mode 100644 index 00000000000..75ff6a9168c --- /dev/null +++ b/FirebaseAuth/Sources/Swift/User/UserProfileUpdate.swift @@ -0,0 +1,195 @@ +// 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() + let request = VerifyAssertionRequest(providerID: credential.provider, + requestConfiguration: user.requestConfiguration) + credential.prepare(request) + request.accessToken = accessToken + do { + let response = try await AuthBackend.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() + let request = SetAccountInfoRequest(requestConfiguration: user.requestConfiguration) + request.accessToken = accessToken + + if user.providerDataRaw[provider] == nil { + throw AuthErrorUtils.noSuchProviderError() + } + request.deleteProviders = [provider] + do { + let response = try await AuthBackend.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 (GetAccountInfoResponseUser, + SetAccountInfoRequest) + -> Void) async throws { + let userAccountInfo = try await getAccountInfoRefreshingCache(user) + let accessToken = try await user.internalGetTokenAsync() + + // Mutate setAccountInfoRequest in block + let setAccountInfoRequest = + SetAccountInfoRequest(requestConfiguration: user.requestConfiguration) + setAccountInfoRequest.accessToken = accessToken + changeBlock(userAccountInfo, setAccountInfoRequest) + do { + let accountInfoResponse = try await AuthBackend.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() + let getAccountInfoRequest = GetAccountInfoRequest( + accessToken: accessToken, + requestConfiguration: user.requestConfiguration + ) + do { + let response = try await AuthBackend.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) + 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 + -> GetAccountInfoResponseUser { + let token = try await user.internalGetTokenAsync() + let request = GetAccountInfoRequest(accessToken: token, + requestConfiguration: user.requestConfiguration) + do { + let accountInfoResponse = try await AuthBackend.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 + } + } +} diff --git a/FirebaseAuth/Tests/Unit/AuthBackendRPCImplentationTests.swift b/FirebaseAuth/Tests/Unit/AuthBackendRPCImplentationTests.swift index 09f997a569a..7fa7f4dec76 100644 --- a/FirebaseAuth/Tests/Unit/AuthBackendRPCImplentationTests.swift +++ b/FirebaseAuth/Tests/Unit/AuthBackendRPCImplentationTests.swift @@ -433,7 +433,7 @@ class AuthBackendRPCImplementationTests: RPCBaseTests { let request = FakeRequest(withRequestBody: [:]) let responseError = NSError(domain: kFakeErrorDomain, code: kFakeErrorCode) rpcIssuer.respondBlock = { - try self.rpcIssuer.respond(withJSON: [:], error: responseError) + let _ = try self.rpcIssuer.respond(withJSON: [:], error: responseError) } do { let _ = try await rpcImplementation.call(with: request) @@ -590,6 +590,8 @@ class AuthBackendRPCImplementationTests: RPCBaseTests { try self.rpcIssuer.respond(withJSON: [:]) } _ = try? await rpcImplementation.call(with: request) + // Make sure completeRequest updates. + usleep(10000) // Then let expectedHeader = HeartbeatLoggingTestUtils.nonEmptyHeartbeatsPayload.headerValue() @@ -616,7 +618,10 @@ class AuthBackendRPCImplementationTests: RPCBaseTests { // Just force return from async call. try self.rpcIssuer.respond(withJSON: [:]) } - _ = try await rpcImplementation.call(with: request) + _ = try? await rpcImplementation.call(with: request) + // Make sure completeRequest updates. + usleep(10000) + let completeRequest = try XCTUnwrap(rpcIssuer.completeRequest) let headerValue = completeRequest.value(forHTTPHeaderField: "X-Firebase-AppCheck") XCTAssertEqual(headerValue, fakeAppCheck.fakeAppCheckToken) @@ -646,6 +651,8 @@ class AuthBackendRPCImplementationTests: RPCBaseTests { try self.rpcIssuer.respond(withJSON: [:]) } _ = try? await rpcImplementation.call(with: request) + // Make sure completRequest updates. + usleep(10000) // Then let completeRequest = try XCTUnwrap(rpcIssuer.completeRequest) diff --git a/FirebaseAuth/Tests/Unit/AuthSerialTaskQueueTests.swift b/FirebaseAuth/Tests/Unit/AuthSerialTaskQueueTests.swift deleted file mode 100644 index 20ec8f94e68..00000000000 --- a/FirebaseAuth/Tests/Unit/AuthSerialTaskQueueTests.swift +++ /dev/null @@ -1,101 +0,0 @@ -// 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 -import XCTest - -@testable import FirebaseAuth - -@available(iOS 13, tvOS 13, macOS 10.15, macCatalyst 13, watchOS 7, *) -class SerialTaskQueueTests: XCTestCase { - func testExecution() { - let expectation = self.expectation(description: #function) - let queue = AuthSerialTaskQueue() - queue.enqueueTask { completionArg in - completionArg() - expectation.fulfill() - } - waitForExpectations(timeout: 5) - } - - func testCompletion() { - let expectation = self.expectation(description: #function) - let queue = AuthSerialTaskQueue() - var completion: (() -> Void)? - queue.enqueueTask { completionArg in - completion = completionArg - expectation.fulfill() - } - var executed = false - var nextExpectation: XCTestExpectation? - queue.enqueueTask { completionArg in - executed = true - completionArg() - nextExpectation?.fulfill() - } - // The second task should not be executed until the first is completed. - waitForExpectations(timeout: 5) - XCTAssertNotNil(completion) - XCTAssertFalse(executed) - nextExpectation = self.expectation(description: "next") - completion?() - waitForExpectations(timeout: 5) - XCTAssertTrue(executed) - } - - func testTargetQueue() { - let expectation = self.expectation(description: #function) - let queue = AuthSerialTaskQueue() - var executed = false - kAuthGlobalWorkQueue.suspend() - queue.enqueueTask { completionArg in - executed = true - completionArg() - expectation.fulfill() - } - // The task should not executed until the global work queue is resumed. - RPCBaseTests.waitSleep() - XCTAssertFalse(executed) - kAuthGlobalWorkQueue.resume() - waitForExpectations(timeout: 5) - XCTAssertTrue(executed) - } - - func testTaskQueueNoAffectTargetQueue() { - let queue = AuthSerialTaskQueue() - var completion: (() -> Void)? - queue.enqueueTask { completionArg in - completion = completionArg - } - var executed = false - var nextExpectation: XCTestExpectation? - queue.enqueueTask { completionArg in - executed = true - completionArg() - nextExpectation?.fulfill() - } - let expectation = self.expectation(description: #function) - kAuthGlobalWorkQueue.async { - expectation.fulfill() - } - // The task queue waiting for completion should not affect the global work queue. - waitForExpectations(timeout: 5) - XCTAssertNotNil(completion) - XCTAssertFalse(executed) - nextExpectation = self.expectation(description: "next") - completion?() - waitForExpectations(timeout: 5) - XCTAssertTrue(executed) - } -} diff --git a/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift b/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift index d51ccd255da..f8a1001436b 100644 --- a/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift +++ b/FirebaseAuth/Tests/Unit/Fakes/FakeBackendRPCIssuer.swift @@ -77,6 +77,16 @@ class FakeBackendRPCIssuer: NSObject, AuthBackendRPCIssuer { var secureTokenErrorString: String? var recaptchaSiteKey = "unset recaptcha siteKey" + func asyncCallToURL(with request: T, body: Data?, + contentType: String) async -> (Data?, Error?) + where T: FirebaseAuth.AuthRPCRequest { + return await withCheckedContinuation { continuation in + self.asyncCallToURL(with: request, body: body, contentType: contentType) { data, error in + continuation.resume(returning: (data, error)) + } + } + } + func asyncCallToURL(with request: T, body: Data?, contentType: String, @@ -138,10 +148,11 @@ class FakeBackendRPCIssuer: NSObject, AuthBackendRPCIssuer { requestData = body // Use the real implementation so that the complete request can // be verified during testing. - AuthBackend.request(withURL: requestURL!, - contentType: contentType, - requestConfiguration: request.requestConfiguration()) { request in - self.completeRequest = request + Task { + self.completeRequest = await AuthBackend.request(withURL: requestURL!, + contentType: contentType, + requestConfiguration: request + .requestConfiguration()) } decodedRequest = try? JSONSerialization.jsonObject(with: body) as? [String: Any] } @@ -156,22 +167,23 @@ class FakeBackendRPCIssuer: NSObject, AuthBackendRPCIssuer { } } - @discardableResult func respond(serverErrorMessage errorMessage: String) throws -> Data { + func respond(serverErrorMessage errorMessage: String) throws { let error = NSError(domain: NSCocoaErrorDomain, code: 0) - return try respond(serverErrorMessage: errorMessage, error: error) + try respond(serverErrorMessage: errorMessage, error: error) } - @discardableResult - func respond(serverErrorMessage errorMessage: String, error: NSError) throws -> Data { - return try respond(withJSON: ["error": ["message": errorMessage]], error: error) + func respond(serverErrorMessage errorMessage: String, error: NSError) throws { + let _ = try respond(withJSON: ["error": ["message": errorMessage]], error: error) } @discardableResult func respond(underlyingErrorMessage errorMessage: String, message: String = "See the reason") throws -> Data { let error = NSError(domain: NSCocoaErrorDomain, code: 0) - return try respond(withJSON: ["error": ["message": message, - "errors": [["reason": errorMessage]]] as [String: Any]], - error: error) + return try respond( + withJSON: ["error": ["message": message, + "errors": [["reason": errorMessage]]] as [String: Any]], + error: error + ) } @discardableResult func respond(withJSON json: [String: Any],