mirror of
https://github.com/firebase/firebase-ios-sdk
synced 2026-11-09 22:36:01 +05:45
Fix Xcode 16 continuation crashes (#13521)
This commit is contained in:
1 parent
b761b15097
commit
9f68d4f4f1
11 files changed
+528
-669
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -31,8 +31,7 @@ protocol AuthBackendRPCIssuer: NSObjectProtocol {
|
||||
/// on the auth global work queue in the future.
|
||||
func asyncCallToURL<T: AuthRPCRequest>(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<T: AuthRPCRequest>(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 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continuation.resume(returning: response)
|
||||
}
|
||||
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()
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private class func clientError(withServerErrorMessage serverErrorMessage: String,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Task {
|
||||
do {
|
||||
let authDataResult = try await self.userProfileUpdate.link(user: self, with: credential)
|
||||
await MainActor.run {
|
||||
completion?(authDataResult, nil)
|
||||
}
|
||||
guard let requestConfiguration = self.auth?.requestConfiguration else {
|
||||
fatalError("Internal Error: Unexpected nil requestConfiguration.")
|
||||
}
|
||||
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()
|
||||
Task {
|
||||
do {
|
||||
try await userProfileUpdate.executeUserUpdateWithChanges(user: self,
|
||||
changeBlock: changeBlock)
|
||||
await MainActor.run {
|
||||
callback(nil)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
callback(error)
|
||||
return
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,16 @@ class FakeBackendRPCIssuer: NSObject, AuthBackendRPCIssuer {
|
||||
var secureTokenErrorString: String?
|
||||
var recaptchaSiteKey = "unset recaptcha siteKey"
|
||||
|
||||
func asyncCallToURL<T>(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<T: AuthRPCRequest>(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],
|
||||
|
||||
Reference in new issue
Block a user